diff --git a/skills/ux-design/SKILL.md b/.agents/skills/ux-design/SKILL.md similarity index 93% rename from skills/ux-design/SKILL.md rename to .agents/skills/ux-design/SKILL.md index 9a72d17..1645605 100644 --- a/skills/ux-design/SKILL.md +++ b/.agents/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? diff --git a/skills/ux-observability/SKILL.md b/.agents/skills/ux-observability/SKILL.md similarity index 100% rename from skills/ux-observability/SKILL.md rename to .agents/skills/ux-observability/SKILL.md diff --git a/AGENTS.md b/AGENTS.md index c1ed5ea..ef29797 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,8 +11,8 @@ ## UX For any user-facing interface work, follow the -[UX Design](skills/ux-design/SKILL.md) and -[UX Observability](skills/ux-observability/SKILL.md) skills. +[UX Design](.agents/skills/ux-design/SKILL.md) and +[UX Observability](.agents/skills/ux-observability/SKILL.md) skills. UX is not considered complete unless the interface is both usable and measurable. Before considering UI work complete, apply the UX Review checklist from these skills. diff --git a/Makefile b/Makefile index f1970ca..0652fcd 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ .PHONY: test-frontend test-frontend: + cd frontend/packages/sxchnge_wallet && flutter test + cd frontend/packages/sxchnge_kyc && flutter test cd frontend/packages/sxchnge_analytics && flutter test cd frontend/packages/sxchnge_auth && flutter test cd frontend/packages/sxchnge_theme && flutter test diff --git a/README.md b/README.md index 398e9c5..da77542 100644 --- a/README.md +++ b/README.md @@ -169,3 +169,34 @@ Follow [`AGENTS.md`](AGENTS.md) and the applicable scoped guidelines, including Implementation work uses a Gitea task and linked branch, validation, commits, a push and a PR. Merge only when authorized. Record scope, validation and known limitations in the task and [implementation record](docs/implementation.md). + +## Identity verification preview + +Open `/#/identity` or use **Limits → Verify identity** in the workspace. The optional flow covers passport and selfie selection, review, +submission, pending review, approval, correction requests and denial in EN/RU. +Use sample images only: photos are never uploaded and drafts are not persisted. + +The shared [sxchnge_kyc package](frontend/packages/sxchnge_kyc/README.md) provides +identity and limits services/providers for web and future mobile clients. Limits +are fetched through `LimitsProvider → LimitsService`; fixture amounts and simulated +review decisions demonstrate the future backend contract. Higher limits apply +only when returned by that service. Production review/storage/support and financial +authorization are not connected. See the [UX plan](docs/ux/kyc.md) for state, error, +privacy and measurement details. + +## Exchange workspace preview + +Choose **Explore your accounts** on login/email confirmation, or open `/#/accounts`. +Currency cards show only available funds. Select an account, then top up, exchange +or pay out through input → review → status. Review shows fees and actual debit and +credit; status explains where the money is, including processing and failed returns. +Activity, account balances and limits use one coherent demo ledger. + +Settings owns English/Russian account language. Limits links to optional KYC; +no verification remains a valid account state with basic limits. + +The shared [wallet package](frontend/packages/sxchnge_wallet/README.md) is reusable +by mobile clients. Transactions, rates, fees, recipients and profile storage are +explicitly simulated and reset on reload. No real money moves. See the +[workspace UX plan](docs/ux/exchange-workspace.md) and +[implementation record](docs/implementation.md) for scope and validation. diff --git a/docs/implementation.md b/docs/implementation.md index 839c37b..1b3dafe 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -4,7 +4,7 @@ [Task #10](https://git.sendico.io/tech/sxchnge/issues/10), branch `skills-10`, delivered in [PR #11](https://git.sendico.io/tech/sxchnge/pulls/11) into `main`, -adds a concise root UX policy linking two independent skills in `skills/`: +adds a concise root UX policy linking two independent skills, now consolidated under `.agents/skills/`: UX Design covers usable interfaces and its design review; UX Observability covers purposeful journey measurement and its observability review. Both preserve product conventions and avoid prescribing frameworks, vendors, or visual styles. @@ -123,6 +123,16 @@ Implementation commit `f1cbf64`; 68 tests and affected-package analysis/formatti plus web build passed. Final browser verification confirms accessible recipient text and keyboard recovery. No merge or actual PostHog collection was performed. +## Consolidated skill location + +Task [#16](https://git.sendico.io/tech/sxchnge/issues/16) consolidates all four +repository skills under `.agents/skills/`. UX Design and UX Observability moved +without content changes; root policy links and this record now use that location. +The old root `skills/` directory is removed. Validation compares moved content +byte-for-byte, checks all four skill paths and updated UX links, and runs +`git diff --check`. No runtime tests are needed for this move. + +Delivery: [PR #17](https://git.sendico.io/tech/sxchnge/pulls/17), branch `skills-16`. ## Editor configuration cleanup Task [#14](https://git.sendico.io/tech/sxchnge/issues/14) removes the root VS Code @@ -154,3 +164,117 @@ Documentation only; no runtime tests were run. implementation should incorporate KYC PR #21 and usage PR #23. - Validation: documentation links and diff checks; no runtime changes or tests. - Delivery: [PR #25](https://git.sendico.io/tech/sxchnge/pulls/25), committed and pushed; not merged. +## Optional identity verification and backend-owned limits — task #20 + +- Branch: `kyc-20`, based on `61ccfc1` (main after README PR #19). +- Planned the UX before implementation in [docs/ux/kyc.md](ux/kyc.md), including + optional basic access, two-photo preparation/review, pending and decision states, + errors, recovery, privacy and measurement definitions. +- Added the mobile-reusable `sxchnge_kyc` package, injectable identity/photo/limits + services, transport mappers and providers. Followed Sendico's visible-limits + ownership boundary; current tier/caps/remaining amounts come from a service. +- Added themed EN/RU `/identity` screens, login/email-confirmation entry points, + route exit guards, review/replace/discard controls, safe error messages and + demo-only backoffice decision controls. No real documents are transmitted. +- Reviewed full frontend scope and corrected web image-metadata validation, + stale-state messaging, cancelled-picker analytics, step focus/scroll behavior, + duplicate submissions and sensitive image cache cleanup. +- Validation: format check for all 27 changed/new Dart files; analyzer passes for + app, sxchnge_kyc and sxchnge_analytics; `make test-frontend` passes **86 tests** + (10 KYC, 7 analytics, 20 auth, 3 theme, 3 consumer example, 43 app). Web build + passes. Actual browser selection of generated PNG samples reached pending + review with unchanged basic limits. EN/RU, 320px/1.6 text scale, accessibility + labels/tap targets, route guards and all decision paths have widget coverage. +- Limits are illustrative backend-stub fixtures. Real authenticated uploads, + private storage, staff review, support, authoritative limits enforcement and + server-side review analytics remain integration work. Native mobile and + VoiceOver/TalkBack checks remain release QA. Security scans were not requested. +- Delivery: [PR #21](https://git.sendico.io/tech/sxchnge/pulls/21), pushed on `kyc-20`; not merged. Final browser review also preserved heading semantics when moving keyboard focus. + +## Limits usage display — task #22 + +- Branch `limits-22`, stacked on `kyc-20` / PR #21 (merge #21 first). +- Added explicit used/committed and reserved fields through DTO, mapper, service + and model. Remaining is independently supplied by the backend stand-in. +- Daily/monthly cards show used, reserved, available, cap and upgrade amounts, + with a labeled theme-based occupied-capacity indicator and exhausted text. + Dummy fixtures contain nonzero activity; usage survives tier increases. +- UX review: EN/RU labels explain pending holds, no color-only information, + 320px/1.6-scale widget checks, actual browser visual verification, zero and + over-cap ratio handling. Existing KYC telemetry is sufficient; no usage amounts + are sent to analytics. +- Validation passed: all 91 frontend tests, analyze app and sxchnge_kyc, + format check of all 9 affected Dart files, web build and diff checks. +- Delivery: [PR #23](https://git.sendico.io/tech/sxchnge/pulls/23), unmerged. Merge #21 first and retarget #23 to main. + +## UX simplicity preference + +Task [#26](https://git.sendico.io/tech/sxchnge/issues/26), delivered in +[PR #27](https://git.sendico.io/tech/sxchnge/pulls/27) on `skills-26`, makes +simplicity an explicit preference in the UX Design skill and its review checklist. +Prefer fewer concepts, choices, and controls when they serve the same need; retain +essential information, accessibility, and recovery. Documentation only. +Validation: skill-creator `quick_validate.py` passed in an isolated environment +with PyYAML (the system Python lacked it); `git diff --check` passed. No runtime +tests were needed. The PR is not merged. + +## Exchange workspace — task #28 + +- Branch `workspace-28`, stacked on `limits-22` / PR #23. Merge KYC #21 first, + then limits #23, then retarget this workspace contribution to main. The approved + plan from #24/#25 is included and updated for the user's available-only direction. +- Implemented Accounts, Activity/detail, Limits and Settings in a responsive + go_router shell, with top-up, exchange and payout input/review/status flows. + One action group follows the selected currency; all colors use theme roles. + Only available balances are displayed. Status explains credit timing, outgoing + amount plus fee, completion and full return after failure. +- Shared `sxchnge_wallet` service/providers own coherent demo balances, quotes, + idempotent submissions, reconciliation, history, account language and live + snapshot refresh. The limits adapter reuses the KYC DTO/mapper and refreshes + usage when operations change. Widgets consume provider models. +- Language is in account settings, with failed-save feedback and retry. Public + auth and verified-email screens enter the workspace. KYC remains optional; + the demo service enforces its basic/verified outgoing caps at submission. +- UX review: EN/RU at 320px with 1.6 text scaling; available-only presentation; + field preservation after errors; explicit draft discard; quote expiry; + loading/stale/empty states; operation outcome explanations; accessible labels, + semantic status announcements and focus after quote/errors. Browser walkthrough + verifies quote amounts and service-confirmed credit with the exchange handoff. + Native mobile and dedicated VoiceOver/TalkBack testing remain release QA. +- Measurement review: typed draft/quote/submission/settlement steps with scoped + journey IDs, safe failure enums, simulated flag and deduplicated terminal + outcomes. No financial amounts, recipients or account IDs in events. Pending + app closure is not draft abandonment. Real PostHog delivery and cross-session + server outcomes remain integration work. +- Validation passed: `make test-frontend` (115 tests), `flutter analyze` in app, + sxchnge_wallet, sxchnge_analytics and sxchnge_kyc; format check of all 36 changed + Dart files; `flutter build web`; `git diff --check`. Review fixes included + narrow Russian layouts, limit-provider typing, automatic pending refresh, + single-use quotes and ambiguous-response recovery. No security scan requested. +- Demo limits: opening EUR 75, fixed rates/fees and three currencies, no real + payment methods/bank validation/storage/auth enforcement. Account preferences, + history and balances reset on reload. No receipt export, date pagination, + notification preferences or security settings beyond an honest capability note. + Backend adapters must supply these before production use; see the wallet README. +- Delivery: [PR #29](https://git.sendico.io/tech/sxchnge/pulls/29), pushed and + unmerged. Task and PR have frontend label, tech owner and SXCHNGE project. + Browser demo walkthrough also completed exchange and payout, confirming source + currency handoffs and exact fee/debit/receipt values on the result screen. + +### Workspace copy cleanup — follow-up to #28 + +At the user's request, removed repeated demo/preview banners from the workspace, +transaction forms, auth, email confirmation, identity and limits. Confirmation +and result copy now uses ordinary product wording in EN/RU; the mobile wordmark +remains without the extra note. Removed unused localization entries and duplicate +email-state copy. Existing journey measurement, simulation flags, service behavior, +funds explanations and recovery controls are unchanged. Development constraints +belong in this record and package README, not repeated throughout the product UI. + +Delivery continues in [PR #29](https://git.sendico.io/tech/sxchnge/pulls/29) on +`workspace-28`. This copy change does not connect real authentication, email, +payment, support or backoffice services. +Validation: all 115 frontend tests, app analysis, app Dart formatting, web build +and diff checks pass. Updated existing copy expectations; telemetry tests still +verify simulated events never count as real business success. No new click events +were needed for this presentation-only cleanup. diff --git a/docs/ux/exchange-workspace.md b/docs/ux/exchange-workspace.md index 4d61a5e..f828190 100644 --- a/docs/ux/exchange-workspace.md +++ b/docs/ux/exchange-workspace.md @@ -1,6 +1,6 @@ # Exchange workspace — UX plan -Task [#24](https://git.sendico.io/tech/sxchnge/issues/24). **Plan only; no app changes.** +Task [#24](https://git.sendico.io/tech/sxchnge/issues/24). Approved plan; implementation tracked in task #28. ## Product direction @@ -20,10 +20,10 @@ Keep decoration secondary to balances, amounts, recipients and transaction terms | Destination | What it contains | Main next action | |---|---|---| -| Accounts (default) | Currency accounts, available balances, reserved amounts, recent activity, actionable pending items | Top up, exchange, pay out | -| Currency account | Available/booked balance, holds, relevant activity and applicable limits | Act on this currency | +| Accounts (default) | Currency accounts, available balances, recent activity, actionable pending items | Top up, exchange, pay out | +| Currency account | Available balance, relevant activity and applicable limits | Act on this currency | | Activity | All operations, filters, search, processing items | Inspect an operation or resolve an actionable issue | -| Limits | Current caps, used, reserved, available, period/reset information and upgrade benefits | Verify identity when useful | +| Limits | Current caps, used and available, period/reset information and upgrade benefits | Verify identity when useful | | Settings | Profile, language, security, notifications and help | Manage the account | Desktop: a persistent sidebar with **Accounts · Activity · Limits · Settings**. @@ -71,7 +71,7 @@ Your accounts [Add currency account, if supported] [ EUR · available balance ] [ USD · available balance ] … Selected account: EUR -Available: … Reserved: … +Available: … [Top up] [Exchange] [Pay out] Action needed / in progress (only when there is something useful to show) @@ -80,9 +80,8 @@ Recent activity [See all] - Each card shows currency code/name, available amount and a text status when restricted. Do not rely on flags to identify currencies. -- Distinguish available, booked and reserved balances. “Available” is the default - spendable number; disclose the balance breakdown on account detail. Backend - balances are authoritative; a list of recent operations is not a balance ledger. +- Show only available balances. Held/reserved/booked amounts are internal service + details, not customer-facing concepts. Backend balances remain authoritative. - Do not sum different currencies directly. A future total in a reporting currency must be labeled estimated, with valuation currency/time and a service-provided conversion. It is not needed for the first interface. @@ -368,3 +367,43 @@ repeated submit, expired quote, zero balances, no methods, denied KYC, and logou This document is a design deliverable, not evidence that these screens exist or that accessibility/payment flows have been tested. Existing demo implementation and pending PRs remain unchanged by this task. + +## Implementation simplification — user direction for #28 + +Only available balances are shown. Customer limits show used and available, with +no held/reserved breakdown or explanatory jargon. Keep four navigation destinations +and straightforward input → review → result flows. Demo funding/payout methods +are explicitly simulated, not representations of live supported rails. + +## Implemented demo scope — #28 + +Four primary destinations and three transaction journeys are implemented. The +single demo method needs no method picker; top-up and exchange collect an amount, +payout additionally collects sample destination text. Review shows fees, source +debit, destination credit and rate. There are no live payment instructions, +redirects, saved recipients, security controls or notification preferences. +Settings implements account language and links to identity. Unsupported production +capabilities remain future work, not decorative controls. + +Pending top-up: money has not reached available balance. Pending outgoing: total +amount plus fee has left available balance, and processing continues. Completed +exchange/top-up: destination balance is credited. Failed outgoing: amount plus fee +is available again. These explanations appear on canonical operation detail. +Pending snapshots refresh automatically across screens; refresh failures mark data +potentially stale. The limits service independently supplies usage and remaining. +The customer sees aggregate consumed capacity and available capacity, not internal +reservations. + +Activity offers kind/status/currency filters and reference search; operation detail +is deep-linkable while the demo session exists. Date filtering, pagination, +receipts, persistent history, actual reset timestamps and production support await +backend contracts. Transient drafts survive pushed settings/limits screens and +locale changes; closing a draft requires discard confirmation. Browser reload +resets the entire demo and is not durable recovery. + +Measurement uses existing random journey IDs and typed quote/submission/settlement +steps. Only service-confirmed terminal status closes the transaction outcome. +Accepted operations are excluded from draft-abandonment on app disposal. Actual +server events, persistent reconciliation and cross-session conversion remain +integration gaps. No automatic click tracking, replay or financial data collection +is introduced. diff --git a/docs/ux/kyc.md b/docs/ux/kyc.md new file mode 100644 index 0000000..554e098 --- /dev/null +++ b/docs/ux/kyc.md @@ -0,0 +1,164 @@ +# Identity verification UX plan + +Task [#20](https://git.sendico.io/tech/sxchnge/issues/20). Plan recorded before implementation. + +## User outcome and scope + +Understand current access, decide whether higher limits are useful, and submit two +readable photos for a human review. No KYC is a valid account state. Email +confirmation and identity verification remain separate. The existing app is a demo: +this release simulates review, holds photos only in memory and never uploads them. +It must say so before selection and submission; use sample images only. + +## Journey + +1. **Verification & limits**: status, active tier, basic versus verified daily and + monthly limits, and one relevant next action. Preview policy is explicitly + labeled as fixtures in the stub service. A draft, pending review, + correction request or denial keeps basic limits; only approval enables the + verified tier. This is presentation, never financial authorization. +2. **Passport**: photograph the open identity/photo page of a valid passport; + all edges visible, readable text, no glare or fingers covering details. +3. **Selfie with passport**: face and open passport visible in the same photo, + unobstructed and in focus. Explain the difference with an icon and guidance. + Each slot supports JPEG/PNG up to 10 MiB, preview, replacement and removal. + Validate content/decodability, size and dimensions, not just extensions. +4. **Review**: both previews, ability to return and replace, explicit confirmation + that photos belong to the user, then submit for human review. No automatic + approval, invented review SLA or instant increase in limits. +5. **Review status**: pending message, unchanged limits and manual refresh. A + correctable rejection has a specific safe reason and a fresh submission path; + final denial offers support guidance and no futile resubmit control. Approval + clearly shows the active higher tier. No forced upgrade or dead-end gate. + +Back preserves the in-memory draft; leaving the flow asks whether to discard +selected photos. Refresh/closing clears a demo session. Successful submission +releases photo bytes. Never put images, paths or filenames in routes, analytics, +logs, preferences or task records. Demo decision controls are explicitly separate +from customer actions and operate only with the dummy service. + +## State and recovery contract + +| State | Tier | Action | +|---|---|---| +| Not verified | Basic | Start optional verification | +| Local draft | Basic | Continue / discard | +| Loading status | Unknown | Wait; don't assert basic/verified before response | +| Status unavailable | Last known, marked stale | Retry; block new submission until refreshed | +| In review | Basic | Refresh; continue account use | +| Changes requested | Basic | Reason: unreadable passport, unreadable selfie, expired passport; replace and resubmit | +| Denied | Basic | Explain decision category; contact support for review | +| Verified | Verified | See increased limits | + +Picker cancellation is neutral. Invalid type, oversized/corrupt/too-small image, +permission/read failure and unavailable submission have distinct recoverable +messages. Keep valid photos on failed replacement/submission. Serialize operations, +prevent duplicate submits, ignore results after disposal. Server adapters must +provide idempotent submissions and current status/revision, enforce account and +email access, handle session expiry and rate limits, and validate images again. +Do not infer identity validity from client image checks. + +## Measurement + +Use existing typed AnalyticsService/JourneyRecorder. A KYC submission journey +starts when the user starts/restarts verification. Semantic stages: preparation, +passport selection, selfie selection, submission. Replacements/retries are +operations in the same journey. Confirmed service acceptance is the terminal +submission outcome, **not identity approval**; demo outcomes are simulated. +Explicit draft discard/navigation or 30 minutes of settled inactivity marks +abandonment. Do not count time waiting for human review as abandonment. Review +status refresh is a separate terminal observation, not a new approval conversion. + +Measure start-to-submission conversion, per-stage failures and recovery, elapsed +submission time and explicit/inactivity abandonment. Only enum reason categories, +random journey IDs, operation sequence and elapsed time are collected. No photo +content/metadata, names, emails, document numbers, URLs or exception strings. +Disable third-party session replay/autocapture on identity routes when PostHog is +added. Production time-to-decision and approval rate require server-side review +events correlated by an opaque submission ID; frontend refresh cannot establish +those metrics. Cross-session resume requires the future backend. + +## Visual and accessibility plan + +Reuse theme colors/typography, rounded panels, locale selector and GoRouter. +Single scrollable mobile column; constrained desktop reading width. Text labels +and icons accompany status colors. Visible numbered steps, back/skip controls, +minimum touch targets, keyboard-accessible buttons, live-region operation/error +feedback, image semantic labels without filenames. Check EN/RU, 320px viewport, +large text, success/failure paths and analytics isolation before delivery. + +## Production integration prerequisites + +This frontend scope does not implement a staff backoffice, document storage or a +real KYC policy. An authenticated backend must own entitlements, limits and review +transitions. Provide private upload/download authorization, human-review roles, +audit trail, safe rejection categories, retention/deletion policy, user privacy +notice/support destination, idempotency and malware/content checks before enabling +real documents. No production claims, deadlines or statutory requirements are +invented here. Native camera capture can later complement the shared file picker. + +## Sendico limits alignment + +Inspected Sendico ADR 0001 and `api/proto/limits/v1/limits.proto` +(`ListVisibleLimits`, `VisibleLimitSlot`). No matching Flutter limits provider was +found in that checkout. Follow its ownership boundary: LimitsService.fetch() +returns backend-evaluated tier, visible caps, remaining capacity and upgrade +comparison. LimitsProvider loads/refreshes those models; widgets never derive +active limits from KYC. The dummy service performs the backend stand-in evaluation. +The visible projection does not reserve capacity or authorize payments. + +## Implementation and review evidence + +- Optional `/identity` demo entry from login and confirmed-email screens. No live + session is implied. GoRouter exit guard and discard confirmation protect drafts; + reload/closing the browser remains destructive to this explicitly transient demo. +- Shared package uses transport DTO → mapper → service → model → provider. Limits + are independently fetched and expose current/remaining/upgrade values. +- Typed analytics separates submission from status observation; picker cancellation + has a neutral cancelled signal. Simulated submission is not real identity approval. +- Review fixed a Flutter Web incompatibility: encoded ImageDescriptor dimensions + are unsupported. Image metadata is now read before bounded decoding using the + cross-platform image package. Actual browser file selection with a generated + 640×640 PNG succeeds for both slots and reaches pending review with basic limits. +- EN/RU widget journeys cover approval, correction/resubmission, denial/support, + errors, discard, and 320px width at 1.6 text scale. Controls use theme colors and + labels; headings/errors expose live-region semantics, step changes move focus + and scroll context. Native VoiceOver/TalkBack and device camera QA remain release + checks for future mobile consumers; no camera capture is implemented here. +- Photo previews evict their image-cache entry on replacement/disposal. Providers + release photo references on exit/submission/disposal. No secure-memory-erasure + guarantee or browser-termination telemetry is claimed. + +## Measurement definitions for dashboards + +The denominator for submission conversion is journeys containing a requested +`kycPreparation` step; status-only refresh journeys are excluded. Numerator is a +terminal `kycSubmission` service outcome, counted once per journey. Exclude +`simulated=true` from production dashboards. Stage failure rate divides failed +operations by requested operations of that stage; cancelled pickers are reported +separately. Compare recovered submissions within the same journey, and calculate +duration from the first preparation request to acceptance. Explicit exit and +settled 30-minute inactivity are separate abandonment categories. Refresh after an +uncertain submission reconciles server status but cannot prove an end-to-end +submission conversion; production correlation needs the backend submission ID. + +Final validation: format check (27 Dart files), analyzer in all three affected +packages, `make test-frontend` (86 tests) and release web build passed. The UX +checklist was reviewed against the implemented demo scope; production/backend +and native assistive-technology gaps are recorded above rather than represented +as complete. + +## Limits usage correction — task #22 + +Visible limits include backend-provided committed (`usedMinor`) and reserved +(`reservedMinor`) usage, matching Sendico's `VisibleLimitSlot`. Show both amounts, +the current cap and authoritative remaining capacity for each period. A labeled +theme-based indicator visualizes used plus reserved against the cap; clamp only +the visual ratio, never rewrite the returned monetary values. No capacity is +communicated in text when remaining is zero. Daily/monthly demo fixtures now have +nonzero activity, retained when caps increase after approval. + +This is a display correction within the existing KYC journey, so no additional +analytics events or financial data collection are introduced. Tests cover mapping +without inferring remaining, unused/exhausted/over-cap/zero-cap ratios and EN/RU +usage display at 320px with 1.6 text scaling. diff --git a/frontend/app/lib/auth/form.dart b/frontend/app/lib/auth/form.dart index d0781cb..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, @@ -310,6 +305,10 @@ class _AuthFormState extends State { AuthFeedback(state: state, signUp: _signUp), ], const SizedBox(height: 20), + TextButton( + onPressed: () => context.go('/accounts'), + child: Text(l.wEnter), + ), Wrap( alignment: WrapAlignment.center, crossAxisAlignment: WrapCrossAlignment.center, 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 69df6ec..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,11 +494,977 @@ abstract class AppLocalizations { /// **'Change email'** String get verificationChangeEmail; - /// No description provided for @demoResend. + /// No description provided for @kycTitle. /// /// In en, this message translates to: - /// **'Prepare demo email again'** - String get demoResend; + /// **'Verification & limits'** + String get kycTitle; + + /// No description provided for @kycIntro. + /// + /// In en, this message translates to: + /// **'Your account, at your pace. Verify your identity when you need higher limits.'** + String get kycIntro; + + /// No description provided for @kycUnverified. + /// + /// In en, this message translates to: + /// **'Not verified'** + String get kycUnverified; + + /// No description provided for @kycPending. + /// + /// In en, this message translates to: + /// **'In review'** + String get kycPending; + + /// No description provided for @kycApproved. + /// + /// In en, this message translates to: + /// **'Identity verified'** + String get kycApproved; + + /// No description provided for @kycChanges. + /// + /// In en, this message translates to: + /// **'New photos needed'** + String get kycChanges; + + /// No description provided for @kycDenied. + /// + /// In en, this message translates to: + /// **'Verification declined'** + String get kycDenied; + + /// No description provided for @kycUnverifiedBody. + /// + /// In en, this message translates to: + /// **'You can keep using your account with basic limits. Submit a passport photo and a selfie holding it to request an upgrade.'** + String get kycUnverifiedBody; + + /// No description provided for @kycPendingBody. + /// + /// In en, this message translates to: + /// **'Your submission is awaiting a human review. Basic limits remain active until approval. You do not need to submit again.'** + String get kycPendingBody; + + /// No description provided for @kycApprovedBody. + /// + /// In en, this message translates to: + /// **'Your review is complete. Check your current limits below.'** + String get kycApprovedBody; + + /// No description provided for @kycChangesBody. + /// + /// In en, this message translates to: + /// **'Your account remains available with basic limits. Replace the photos and submit a new review request.'** + String get kycChangesBody; + + /// No description provided for @kycDeniedBody. + /// + /// In en, this message translates to: + /// **'Basic limits remain available. If you believe this decision is incorrect, contact support for a review.'** + String get kycDeniedBody; + + /// No description provided for @kycPassportReason. + /// + /// In en, this message translates to: + /// **'The passport details could not be read. Retake the photo in good light, with every edge visible.'** + String get kycPassportReason; + + /// No description provided for @kycSelfieReason. + /// + /// In en, this message translates to: + /// **'Your face or passport was not clear in the selfie. Keep both visible and in focus.'** + String get kycSelfieReason; + + /// No description provided for @kycExpiredReason. + /// + /// In en, this message translates to: + /// **'The passport has expired. Use a valid passport and take both photos again.'** + String get kycExpiredReason; + + /// No description provided for @kycDeniedReason. + /// + /// In en, this message translates to: + /// **'We could not verify your identity from this submission.'** + String get kycDeniedReason; + + /// No description provided for @kycStart. + /// + /// In en, this message translates to: + /// **'Verify identity'** + String get kycStart; + + /// No description provided for @kycResubmit. + /// + /// In en, this message translates to: + /// **'Replace photos'** + String get kycResubmit; + + /// No description provided for @kycRefresh. + /// + /// In en, this message translates to: + /// **'Refresh status & limits'** + String get kycRefresh; + + /// No description provided for @kycSupport. + /// + /// In en, this message translates to: + /// **'Contact support'** + String get kycSupport; + + /// No description provided for @kycSupportBody. + /// + /// In en, this message translates to: + /// **'Support is currently unavailable. Please try again later.'** + String get kycSupportBody; + + /// No description provided for @kycBasic. + /// + /// In en, this message translates to: + /// **'Basic'** + String get kycBasic; + + /// No description provided for @kycVerifiedTier. + /// + /// In en, this message translates to: + /// **'Verified'** + String get kycVerifiedTier; + + /// No description provided for @kycLimits. + /// + /// In en, this message translates to: + /// **'Your limits'** + String get kycLimits; + + /// No description provided for @kycDay. + /// + /// In en, this message translates to: + /// **'Daily volume'** + String get kycDay; + + /// No description provided for @kycMonth. + /// + /// In en, this message translates to: + /// **'Monthly volume'** + String get kycMonth; + + /// No description provided for @kycCurrent. + /// + /// In en, this message translates to: + /// **'Current cap'** + String get kycCurrent; + + /// No description provided for @kycRemaining. + /// + /// In en, this message translates to: + /// **'Available'** + String get kycRemaining; + + /// No description provided for @kycAfter. + /// + /// In en, this message translates to: + /// **'After approval'** + String get kycAfter; + + /// No description provided for @kycLimitsError. + /// + /// In en, this message translates to: + /// **'Limits could not be refreshed. Any values below are out of date. Retry to see your current access.'** + String get kycLimitsError; + + /// No description provided for @kycLimitsEmpty. + /// + /// In en, this message translates to: + /// **'No limits were returned. Refresh or check with support before making a transaction.'** + String get kycLimitsEmpty; + + /// No description provided for @kycLoading. + /// + /// In en, this message translates to: + /// **'Loading your status…'** + String get kycLoading; + + /// No description provided for @kycPassport. + /// + /// In en, this message translates to: + /// **'Passport photo'** + String get kycPassport; + + /// No description provided for @kycSelfie. + /// + /// In en, this message translates to: + /// **'Selfie with passport'** + String get kycSelfie; + + /// No description provided for @kycReview. + /// + /// In en, this message translates to: + /// **'Review your photos'** + String get kycReview; + + /// No description provided for @kycPassportHelp. + /// + /// In en, this message translates to: + /// **'Open your valid passport to the photo page. Show all four edges and keep text sharp. Avoid glare, blur and covered details.'** + String get kycPassportHelp; + + /// No description provided for @kycSelfieHelp. + /// + /// In en, this message translates to: + /// **'Hold the open passport beside your face. Show your whole face and the photo page in the same image. Keep both sharp and uncovered.'** + String get kycSelfieHelp; + + /// No description provided for @kycFileRules. + /// + /// In en, this message translates to: + /// **'JPEG or PNG · up to 10 MB · at least 600 × 600 pixels. Take a photo with your camera, then choose it here.'** + String get kycFileRules; + + /// No description provided for @kycChoose. + /// + /// In en, this message translates to: + /// **'Choose photo'** + String get kycChoose; + + /// No description provided for @kycReplace. + /// + /// In en, this message translates to: + /// **'Choose a different photo'** + String get kycReplace; + + /// No description provided for @kycRemove. + /// + /// In en, this message translates to: + /// **'Remove photo'** + String get kycRemove; + + /// No description provided for @kycNext. + /// + /// In en, this message translates to: + /// **'Continue'** + String get kycNext; + + /// No description provided for @kycBack. + /// + /// In en, this message translates to: + /// **'Back'** + String get kycBack; + + /// No description provided for @kycSkip. + /// + /// In en, this message translates to: + /// **'Keep basic limits'** + String get kycSkip; + + /// No description provided for @kycClose. + /// + /// In en, this message translates to: + /// **'Back to account'** + String get kycClose; + + /// No description provided for @kycReviewHelp. + /// + /// In en, this message translates to: + /// **'Check that both photos are readable and show the same passport. A staff member will review them; submission does not immediately raise your limits.'** + String get kycReviewHelp; + + /// No description provided for @kycConfirm. + /// + /// In en, this message translates to: + /// **'These photos show me and my own valid passport.'** + String get kycConfirm; + + /// No description provided for @kycSubmit. + /// + /// In en, this message translates to: + /// **'Submit for review'** + String get kycSubmit; + + /// No description provided for @kycWorking. + /// + /// In en, this message translates to: + /// **'Processing… Please keep this page open.'** + String get kycWorking; + + /// No description provided for @kycDiscardTitle. + /// + /// In en, this message translates to: + /// **'Discard these photos?'** + String get kycDiscardTitle; + + /// No description provided for @kycDiscardBody. + /// + /// In en, this message translates to: + /// **'Your draft is only in memory. Leaving will remove the selected photos; you can start again later.'** + String get kycDiscardBody; + + /// No description provided for @kycStay. + /// + /// In en, this message translates to: + /// **'Keep editing'** + String get kycStay; + + /// No description provided for @kycDiscard. + /// + /// In en, this message translates to: + /// **'Discard & leave'** + String get kycDiscard; + + /// No description provided for @kycErrorUnavailable. + /// + /// In en, this message translates to: + /// **'The service is unavailable. Your selected photos are still here. Try again.'** + String get kycErrorUnavailable; + + /// No description provided for @kycErrorInternal. + /// + /// In en, this message translates to: + /// **'Something went wrong. Try again; your selected photos have been kept.'** + String get kycErrorInternal; + + /// No description provided for @kycErrorPermission. + /// + /// In en, this message translates to: + /// **'We could not open that photo. Check file access or choose another image.'** + String get kycErrorPermission; + + /// No description provided for @kycErrorFormat. + /// + /// In en, this message translates to: + /// **'Choose a JPEG or PNG image. Other formats are not supported.'** + String get kycErrorFormat; + + /// No description provided for @kycErrorLarge. + /// + /// In en, this message translates to: + /// **'This image is too large. Choose one under 10 MB and 40 megapixels.'** + String get kycErrorLarge; + + /// No description provided for @kycErrorUnreadable. + /// + /// In en, this message translates to: + /// **'This image is damaged or cannot be read. Choose another photo.'** + String get kycErrorUnreadable; + + /// No description provided for @kycErrorSmall. + /// + /// In en, this message translates to: + /// **'The photo is too small. Use an image at least 600 pixels wide and high.'** + String get kycErrorSmall; + + /// No description provided for @kycErrorIncomplete. + /// + /// In en, this message translates to: + /// **'Choose both photos and confirm they belong to you before submitting.'** + String get kycErrorIncomplete; + + /// No description provided for @kycErrorSession. + /// + /// In en, this message translates to: + /// **'Your session has expired. Return to login, then check your verification status.'** + String get kycErrorSession; + + /// No description provided for @kycErrorRate. + /// + /// In en, this message translates to: + /// **'Too many requests. Wait a moment before trying again.'** + String get kycErrorRate; + + /// No description provided for @kycErrorConflict. + /// + /// In en, this message translates to: + /// **'Your review status has changed. Refresh it before continuing.'** + String get kycErrorConflict; + + /// No description provided for @kycDemoControls. + /// + /// In en, this message translates to: + /// **'Review scenarios'** + String get kycDemoControls; + + /// No description provided for @kycDemoApprove. + /// + /// In en, this message translates to: + /// **'Approve'** + String get kycDemoApprove; + + /// No description provided for @kycDemoCorrect. + /// + /// In en, this message translates to: + /// **'Request clearer passport'** + String get kycDemoCorrect; + + /// No description provided for @kycDemoSelfie. + /// + /// In en, this message translates to: + /// **'Request clearer selfie'** + String get kycDemoSelfie; + + /// No description provided for @kycDemoExpired. + /// + /// In en, this message translates to: + /// **'Passport expired'** + String get kycDemoExpired; + + /// No description provided for @kycDemoDeny. + /// + /// In en, this message translates to: + /// **'Decline'** + String get kycDemoDeny; + + /// No description provided for @kycPreview. + /// + /// In en, this message translates to: + /// **'Explore verification & limits'** + String get kycPreview; + + /// No description provided for @kycChooseFirst. + /// + /// In en, this message translates to: + /// **'Choose a photo to continue.'** + String get kycChooseFirst; + + /// No description provided for @kycStatusStale. + /// + /// In en, this message translates to: + /// **'Status could not be refreshed. The status shown is the last known result.'** + String get kycStatusStale; + + /// No description provided for @kycUsed. + /// + /// In en, this message translates to: + /// **'Used'** + String get kycUsed; + + /// No description provided for @kycReserved. + /// + /// In en, this message translates to: + /// **'Reserved'** + String get kycReserved; + + /// No description provided for @kycUsageHelp. + /// + /// In en, this message translates to: + /// **'Used is completed activity. Reserved is held for pending transactions. Both count toward your limit.'** + String get kycUsageHelp; + + /// No description provided for @kycOccupied. + /// + /// In en, this message translates to: + /// **'Used + reserved'** + String get kycOccupied; + + /// No description provided for @kycNoCapacity. + /// + /// 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 8972729..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,29 +205,11 @@ class AppLocalizationsEn extends AppLocalizations { 'No email? Check your spam folder or request another below.'; @override - String get verificationDemo => - 'DEMO · No real email is sent and no account is verified.'; - - @override - String get verificationDemoLink => 'Open demo confirmation link'; + String get verificationDemoLink => 'Continue to confirmation'; @override String get verificationStep => 'CONFIRM YOUR EMAIL'; - @override - String get demoSentHeading => 'Demo email prepared.'; - - @override - String get demoSentBody => - 'No email was sent. Use the demo confirmation below to preview the next step.'; - - @override - String get demoVerifiedHeading => 'Demo verification\ncomplete.'; - - @override - String get demoVerifiedBody => - 'You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.'; - @override String get verificationRecipient => 'Recipient'; @@ -241,5 +217,545 @@ class AppLocalizationsEn extends AppLocalizations { String get verificationChangeEmail => 'Change email'; @override - String get demoResend => 'Prepare demo email again'; + String get kycTitle => 'Verification & limits'; + + @override + String get kycIntro => + 'Your account, at your pace. Verify your identity when you need higher limits.'; + + @override + String get kycUnverified => 'Not verified'; + + @override + String get kycPending => 'In review'; + + @override + String get kycApproved => 'Identity verified'; + + @override + String get kycChanges => 'New photos needed'; + + @override + String get kycDenied => 'Verification declined'; + + @override + String get kycUnverifiedBody => + 'You can keep using your account with basic limits. Submit a passport photo and a selfie holding it to request an upgrade.'; + + @override + String get kycPendingBody => + 'Your submission is awaiting a human review. Basic limits remain active until approval. You do not need to submit again.'; + + @override + String get kycApprovedBody => + 'Your review is complete. Check your current limits below.'; + + @override + String get kycChangesBody => + 'Your account remains available with basic limits. Replace the photos and submit a new review request.'; + + @override + String get kycDeniedBody => + 'Basic limits remain available. If you believe this decision is incorrect, contact support for a review.'; + + @override + String get kycPassportReason => + 'The passport details could not be read. Retake the photo in good light, with every edge visible.'; + + @override + String get kycSelfieReason => + 'Your face or passport was not clear in the selfie. Keep both visible and in focus.'; + + @override + String get kycExpiredReason => + 'The passport has expired. Use a valid passport and take both photos again.'; + + @override + String get kycDeniedReason => + 'We could not verify your identity from this submission.'; + + @override + String get kycStart => 'Verify identity'; + + @override + String get kycResubmit => 'Replace photos'; + + @override + String get kycRefresh => 'Refresh status & limits'; + + @override + String get kycSupport => 'Contact support'; + + @override + String get kycSupportBody => + 'Support is currently unavailable. Please try again later.'; + + @override + String get kycBasic => 'Basic'; + + @override + String get kycVerifiedTier => 'Verified'; + + @override + String get kycLimits => 'Your limits'; + + @override + String get kycDay => 'Daily volume'; + + @override + String get kycMonth => 'Monthly volume'; + + @override + String get kycCurrent => 'Current cap'; + + @override + String get kycRemaining => 'Available'; + + @override + String get kycAfter => 'After approval'; + + @override + String get kycLimitsError => + 'Limits could not be refreshed. Any values below are out of date. Retry to see your current access.'; + + @override + String get kycLimitsEmpty => + 'No limits were returned. Refresh or check with support before making a transaction.'; + + @override + String get kycLoading => 'Loading your status…'; + + @override + String get kycPassport => 'Passport photo'; + + @override + String get kycSelfie => 'Selfie with passport'; + + @override + String get kycReview => 'Review your photos'; + + @override + String get kycPassportHelp => + 'Open your valid passport to the photo page. Show all four edges and keep text sharp. Avoid glare, blur and covered details.'; + + @override + String get kycSelfieHelp => + 'Hold the open passport beside your face. Show your whole face and the photo page in the same image. Keep both sharp and uncovered.'; + + @override + String get kycFileRules => + 'JPEG or PNG · up to 10 MB · at least 600 × 600 pixels. Take a photo with your camera, then choose it here.'; + + @override + String get kycChoose => 'Choose photo'; + + @override + String get kycReplace => 'Choose a different photo'; + + @override + String get kycRemove => 'Remove photo'; + + @override + String get kycNext => 'Continue'; + + @override + String get kycBack => 'Back'; + + @override + String get kycSkip => 'Keep basic limits'; + + @override + String get kycClose => 'Back to account'; + + @override + String get kycReviewHelp => + 'Check that both photos are readable and show the same passport. A staff member will review them; submission does not immediately raise your limits.'; + + @override + String get kycConfirm => 'These photos show me and my own valid passport.'; + + @override + String get kycSubmit => 'Submit for review'; + + @override + String get kycWorking => 'Processing… Please keep this page open.'; + + @override + String get kycDiscardTitle => 'Discard these photos?'; + + @override + String get kycDiscardBody => + 'Your draft is only in memory. Leaving will remove the selected photos; you can start again later.'; + + @override + String get kycStay => 'Keep editing'; + + @override + String get kycDiscard => 'Discard & leave'; + + @override + String get kycErrorUnavailable => + 'The service is unavailable. Your selected photos are still here. Try again.'; + + @override + String get kycErrorInternal => + 'Something went wrong. Try again; your selected photos have been kept.'; + + @override + String get kycErrorPermission => + 'We could not open that photo. Check file access or choose another image.'; + + @override + String get kycErrorFormat => + 'Choose a JPEG or PNG image. Other formats are not supported.'; + + @override + String get kycErrorLarge => + 'This image is too large. Choose one under 10 MB and 40 megapixels.'; + + @override + String get kycErrorUnreadable => + 'This image is damaged or cannot be read. Choose another photo.'; + + @override + String get kycErrorSmall => + 'The photo is too small. Use an image at least 600 pixels wide and high.'; + + @override + String get kycErrorIncomplete => + 'Choose both photos and confirm they belong to you before submitting.'; + + @override + String get kycErrorSession => + 'Your session has expired. Return to login, then check your verification status.'; + + @override + String get kycErrorRate => + 'Too many requests. Wait a moment before trying again.'; + + @override + String get kycErrorConflict => + 'Your review status has changed. Refresh it before continuing.'; + + @override + String get kycDemoControls => 'Review scenarios'; + + @override + String get kycDemoApprove => 'Approve'; + + @override + String get kycDemoCorrect => 'Request clearer passport'; + + @override + String get kycDemoSelfie => 'Request clearer selfie'; + + @override + String get kycDemoExpired => 'Passport expired'; + + @override + String get kycDemoDeny => 'Decline'; + + @override + String get kycPreview => 'Explore verification & limits'; + + @override + String get kycChooseFirst => 'Choose a photo to continue.'; + + @override + String get kycStatusStale => + 'Status could not be refreshed. The status shown is the last known result.'; + + @override + String get kycUsed => 'Used'; + + @override + String get kycReserved => 'Reserved'; + + @override + String get kycUsageHelp => + 'Used is completed activity. Reserved is held for pending transactions. Both count toward your limit.'; + + @override + String get kycOccupied => 'Used + reserved'; + + @override + String get kycNoCapacity => 'No capacity available'; + + @override + String get wAccounts => 'Accounts'; + + @override + String get wActivity => 'Activity'; + + @override + String get wLimits => 'Limits'; + + @override + String get wSettings => 'Settings'; + + @override + String get wHeadline => 'Your money.\nYour next move.'; + + @override + String get wSubheading => 'Top up. Exchange. Go further.'; + + @override + String get wAvailable => 'Available to you'; + + @override + String get wTopUp => 'Top up'; + + @override + String get wExchange => 'Exchange'; + + @override + String get wPayout => 'Pay out'; + + @override + String get wRecent => 'Recent activity'; + + @override + String get wSeeAll => 'See all'; + + @override + String get wEmptyActivity => + 'Your next move starts here. Your transactions will appear below.'; + + @override + String get wEmptyAccounts => + 'Your accounts are not ready yet. Try refreshing.'; + + @override + String get wRefresh => 'Refresh'; + + @override + String get wSource => 'From account'; + + @override + String get wDestination => 'To account'; + + @override + String get wReceiveAccount => 'Receiving account'; + + @override + String get wAmount => 'Amount'; + + @override + String get wRecipient => 'Recipient'; + + @override + String get wRecipientHint => 'Recipient name or reference'; + + @override + String get wReview => 'Review'; + + @override + String get wReviewTitle => 'One last look.'; + + @override + String get wFee => 'Fee'; + + @override + String get wTotalDebit => 'Leaves your account'; + + @override + String get wCredit => 'Arrives in your account'; + + @override + String get wRecipientGets => 'Recipient receives'; + + @override + String get wYouPay => 'You pay'; + + @override + String get wRate => 'Exchange rate'; + + @override + String get wQuoteNotice => + 'These terms are valid for 60 seconds. Confirm them before continuing.'; + + @override + String get wExpired => + 'This quote expired. Refresh it and review the new terms.'; + + @override + String get wNewQuote => 'Refresh quote'; + + @override + String get wConfirm => 'Confirm transaction'; + + @override + String get wEdit => 'Edit details'; + + @override + String get wClose => 'Close'; + + @override + String get wWorking => 'Working on it…'; + + @override + String get wPending => 'Processing'; + + @override + String get wCompleted => 'Completed'; + + @override + String get wFailed => 'Not completed'; + + @override + String get wPendingTopUp => + 'Your top-up is processing. Your available balance has not changed yet.'; + + @override + String get wPendingExchange => + 'The amount and fee have left your available balance. The exchanged amount will appear in the receiving account when completed.'; + + @override + String get wPendingPayout => + 'The amount and fee have left your available balance. The payout is still processing.'; + + @override + String get wCompleteTopUp => + 'Your account has been credited. The money is available to use.'; + + @override + String get wCompleteExchange => + 'Your exchange is complete. The money is available in the receiving account.'; + + @override + String get wCompletePayout => + 'Your payout is complete. The amount and fee have been deducted from your account.'; + + @override + String get wFailedTopUp => + 'The top-up did not complete. No money was added to your account.'; + + @override + String get wFailedOutgoing => + 'The transaction did not complete. The amount and fee are available in your account again.'; + + @override + String get wUnavailable => + 'Could not refresh. The displayed information may be out of date. Try again.'; + + @override + String get wInvalidAmount => + 'Enter an amount greater than zero, using the currency’s decimal precision.'; + + @override + String get wInsufficient => + 'Not enough available money for this amount and fee. Top up or enter a smaller amount.'; + + @override + String get wLimitError => + 'This amount exceeds your available limit. Check your limits before continuing.'; + + @override + String get wRecipientError => + 'Enter a sample recipient between 6 and 140 characters.'; + + @override + String get wUnsupported => + 'This combination is unavailable. Choose another account.'; + + @override + String get wNotFound => + 'This item could not be found. Return to your accounts.'; + + @override + String get wUncertain => + 'We could not confirm the outcome. Check it before trying another transaction.'; + + @override + String get wCheckOutcome => 'Check outcome'; + + @override + String get wNoSubmission => + 'No transaction was found. You can review and confirm again.'; + + @override + String get wDiscardTitle => 'Leave this transaction?'; + + @override + String get wDiscardBody => + 'Your entered details will be cleared. No new transaction will be submitted.'; + + @override + String get wStay => 'Keep editing'; + + @override + String get wAll => 'All'; + + @override + String get wSearch => 'Search by reference'; + + @override + String get wNoMatches => 'No matching transactions. Try another filter.'; + + @override + String get wClearFilters => 'Clear filters'; + + @override + String get wReference => 'Reference'; + + @override + String get wWhen => 'Created'; + + @override + String get wViewAccount => 'View account'; + + @override + String get wPreferences => 'Make it yours.'; + + @override + String get wLanguage => 'Account language'; + + @override + String get wLanguageHelp => 'Used throughout your account.'; + + @override + String get wLanguageSaved => 'Saved to your account.'; + + @override + String get wLanguageFailed => + 'Changed for this session, but not saved to your account. Retry below.'; + + @override + String get wProfileError => + 'Account preferences could not be loaded. Try again.'; + + @override + String get wSecurity => 'Security'; + + @override + String get wSecurityBody => + 'Password and security settings are not available yet.'; + + @override + String get wIdentity => 'Identity verification'; + + @override + String get wIdentityHelp => 'Optional verification for higher limits.'; + + @override + String get wBackLogin => 'Back to login'; + + @override + String get wEnter => 'Explore your accounts'; + + @override + String get wUsage => 'Used this period'; + + @override + String get wLimitsScope => 'Outgoing activity · EUR equivalent'; + + @override + String get wPendingHint => + 'Refresh to check the latest status. You can keep using your other accounts.'; + + @override + String get wFundsHeading => 'Where is my money?'; + + @override + String get wAnother => 'Make another transaction'; } diff --git a/frontend/app/lib/generated/i18n/app_localizations_ru.dart b/frontend/app/lib/generated/i18n/app_localizations_ru.dart index 9c32b10..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,29 +206,11 @@ class AppLocalizationsRu extends AppLocalizations { 'Нет письма? Проверьте папку «Спам» или запросите новое ниже.'; @override - String get verificationDemo => - 'ДЕМО · Письмо не отправляется, реальный аккаунт не подтверждается.'; - - @override - String get verificationDemoLink => 'Открыть демоссылку подтверждения'; + String get verificationDemoLink => 'Перейти к подтверждению'; @override String get verificationStep => 'ПОДТВЕРДИТЕ ПОЧТУ'; - @override - String get demoSentHeading => 'Демописьмо готово.'; - - @override - String get demoSentBody => - 'Письмо не отправлено. Используйте демоссылку ниже, чтобы посмотреть следующий шаг.'; - - @override - String get demoVerifiedHeading => 'Демоподтверждение\nзавершено.'; - - @override - String get demoVerifiedBody => - 'Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.'; - @override String get verificationRecipient => 'Получатель'; @@ -242,5 +218,541 @@ class AppLocalizationsRu extends AppLocalizations { String get verificationChangeEmail => 'Изменить почту'; @override - String get demoResend => 'Подготовить демописьмо ещё раз'; + String get kycTitle => 'Верификация и лимиты'; + + @override + String get kycIntro => + 'Ваш аккаунт — ваш темп. Подтвердите личность, когда понадобятся более высокие лимиты.'; + + @override + String get kycUnverified => 'Личность не подтверждена'; + + @override + String get kycPending => 'На проверке'; + + @override + String get kycApproved => 'Личность подтверждена'; + + @override + String get kycChanges => 'Нужны новые фото'; + + @override + String get kycDenied => 'Верификация отклонена'; + + @override + String get kycUnverifiedBody => + 'Вы можете пользоваться аккаунтом с базовыми лимитами. Для повышения отправьте фото паспорта и селфи с ним.'; + + @override + String get kycPendingBody => + 'Заявка ожидает проверки сотрудником. До одобрения действуют базовые лимиты. Повторная отправка не нужна.'; + + @override + String get kycApprovedBody => + 'Проверка завершена. Актуальные лимиты указаны ниже.'; + + @override + String get kycChangesBody => + 'Аккаунт доступен с базовыми лимитами. Замените фото и отправьте новую заявку.'; + + @override + String get kycDeniedBody => + 'Базовые лимиты остаются доступными. Если вы не согласны с решением, обратитесь в поддержку для пересмотра.'; + + @override + String get kycPassportReason => + 'Данные паспорта не удалось прочитать. Переснимите при хорошем освещении, чтобы все края были видны.'; + + @override + String get kycSelfieReason => + 'На селфи плохо видно лицо или паспорт. Оба должны быть в кадре и в фокусе.'; + + @override + String get kycExpiredReason => + 'Срок действия паспорта истёк. Используйте действующий паспорт и сделайте оба фото заново.'; + + @override + String get kycDeniedReason => + 'По этой заявке не удалось подтвердить вашу личность.'; + + @override + String get kycStart => 'Подтвердить личность'; + + @override + String get kycResubmit => 'Заменить фото'; + + @override + String get kycRefresh => 'Обновить статус и лимиты'; + + @override + String get kycSupport => 'Обратиться в поддержку'; + + @override + String get kycSupportBody => 'Поддержка сейчас недоступна. Попробуйте позже.'; + + @override + String get kycBasic => 'Базовый'; + + @override + String get kycVerifiedTier => 'Подтверждённый'; + + @override + String get kycLimits => 'Ваши лимиты'; + + @override + String get kycDay => 'Объём за день'; + + @override + String get kycMonth => 'Объём за месяц'; + + @override + String get kycCurrent => 'Текущий лимит'; + + @override + String get kycRemaining => 'Доступно'; + + @override + String get kycAfter => 'После одобрения'; + + @override + String get kycLimitsError => + 'Не удалось обновить лимиты. Значения ниже могут быть устаревшими. Повторите запрос.'; + + @override + String get kycLimitsEmpty => + 'Сервис не вернул лимиты. Обновите данные или уточните их в поддержке перед операцией.'; + + @override + String get kycLoading => 'Загружаем ваш статус…'; + + @override + String get kycPassport => 'Фото паспорта'; + + @override + String get kycSelfie => 'Селфи с паспортом'; + + @override + String get kycReview => 'Проверьте фото'; + + @override + String get kycPassportHelp => + 'Откройте действующий паспорт на странице с фото. Все четыре края и текст должны быть чётко видны. Избегайте бликов и размытия, не закрывайте данные.'; + + @override + String get kycSelfieHelp => + 'Держите открытый паспорт рядом с лицом. Лицо целиком и страница с фото должны быть в одном кадре, чёткими и ничем не закрытыми.'; + + @override + String get kycFileRules => + 'JPEG или PNG · до 10 МБ · не менее 600 × 600 пикселей. Сделайте фото камерой, затем выберите его здесь.'; + + @override + String get kycChoose => 'Выбрать фото'; + + @override + String get kycReplace => 'Выбрать другое фото'; + + @override + String get kycRemove => 'Удалить фото'; + + @override + String get kycNext => 'Продолжить'; + + @override + String get kycBack => 'Назад'; + + @override + String get kycSkip => 'Оставить базовые лимиты'; + + @override + String get kycClose => 'К аккаунту'; + + @override + String get kycReviewHelp => + 'Убедитесь, что оба фото читаемы и на них один паспорт. Их проверит сотрудник. Отправка не повышает лимиты сразу.'; + + @override + String get kycConfirm => 'На фото я и мой действующий паспорт.'; + + @override + String get kycSubmit => 'Отправить на проверку'; + + @override + String get kycWorking => 'Обработка… Не закрывайте страницу.'; + + @override + String get kycDiscardTitle => 'Удалить выбранные фото?'; + + @override + String get kycDiscardBody => + 'Черновик хранится только в памяти. При выходе выбранные фото будут удалены. Позже можно начать заново.'; + + @override + String get kycStay => 'Продолжить'; + + @override + String get kycDiscard => 'Удалить и выйти'; + + @override + String get kycErrorUnavailable => + 'Сервис недоступен. Выбранные фото сохранены в памяти. Повторите попытку.'; + + @override + String get kycErrorInternal => + 'Произошла ошибка. Повторите попытку; выбранные фото сохранены.'; + + @override + String get kycErrorPermission => + 'Не удалось открыть фото. Проверьте доступ к файлам или выберите другое изображение.'; + + @override + String get kycErrorFormat => + 'Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.'; + + @override + String get kycErrorLarge => + 'Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.'; + + @override + String get kycErrorUnreadable => + 'Изображение повреждено или не читается. Выберите другое фото.'; + + @override + String get kycErrorSmall => + 'Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.'; + + @override + String get kycErrorIncomplete => + 'Выберите оба фото и подтвердите, что на них вы и ваш паспорт.'; + + @override + String get kycErrorSession => + 'Сессия истекла. Войдите снова и проверьте статус верификации.'; + + @override + String get kycErrorRate => + 'Слишком много запросов. Немного подождите и повторите попытку.'; + + @override + String get kycErrorConflict => + 'Статус проверки изменился. Обновите его, прежде чем продолжить.'; + + @override + String get kycDemoControls => 'Сценарии проверки'; + + @override + String get kycDemoApprove => 'Одобрить'; + + @override + String get kycDemoCorrect => 'Запросить чёткое фото паспорта'; + + @override + String get kycDemoSelfie => 'Запросить чёткое селфи'; + + @override + String get kycDemoExpired => 'Паспорт просрочен'; + + @override + String get kycDemoDeny => 'Отклонить'; + + @override + String get kycPreview => 'Посмотреть верификацию и лимиты'; + + @override + String get kycChooseFirst => 'Выберите фото для продолжения.'; + + @override + String get kycStatusStale => + 'Не удалось обновить статус. Показан последний известный результат.'; + + @override + String get kycUsed => 'Использовано'; + + @override + String get kycReserved => 'Зарезервировано'; + + @override + String get kycUsageHelp => + 'Использовано — завершённые операции. Зарезервировано — суммы, удержанные для ожидающих операций. Обе суммы учитываются в лимите.'; + + @override + String get kycOccupied => 'Использовано + зарезервировано'; + + @override + String get kycNoCapacity => 'Доступный объём исчерпан'; + + @override + String get wAccounts => 'Счета'; + + @override + String get wActivity => 'История'; + + @override + String get wLimits => 'Лимиты'; + + @override + String get wSettings => 'Настройки'; + + @override + String get wHeadline => 'Ваши деньги.\nВаш следующий шаг.'; + + @override + String get wSubheading => 'Пополняйте. Обменивайте. Двигайтесь дальше.'; + + @override + String get wAvailable => 'Вам доступно'; + + @override + String get wTopUp => 'Пополнить'; + + @override + String get wExchange => 'Обменять'; + + @override + String get wPayout => 'Вывести'; + + @override + String get wRecent => 'Последние операции'; + + @override + String get wSeeAll => 'Смотреть все'; + + @override + String get wEmptyActivity => + 'Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.'; + + @override + String get wEmptyAccounts => + 'Ваши счета ещё не готовы. Попробуйте обновить страницу.'; + + @override + String get wRefresh => 'Обновить'; + + @override + String get wSource => 'Со счёта'; + + @override + String get wDestination => 'На счёт'; + + @override + String get wReceiveAccount => 'Счёт для зачисления'; + + @override + String get wAmount => 'Сумма'; + + @override + String get wRecipient => 'Получатель'; + + @override + String get wRecipientHint => 'Имя или реквизиты получателя'; + + @override + String get wReview => 'Проверить'; + + @override + String get wReviewTitle => 'Проверьте детали.'; + + @override + String get wFee => 'Комиссия'; + + @override + String get wTotalDebit => 'Спишется со счёта'; + + @override + String get wCredit => 'Поступит на счёт'; + + @override + String get wRecipientGets => 'Получатель получит'; + + @override + String get wYouPay => 'Вы платите'; + + @override + String get wRate => 'Курс обмена'; + + @override + String get wQuoteNotice => + 'Эти условия действуют 60 секунд. Подтвердите их, прежде чем продолжить.'; + + @override + String get wExpired => + 'Срок действия расчёта истёк. Обновите его и проверьте новые условия.'; + + @override + String get wNewQuote => 'Обновить расчёт'; + + @override + String get wConfirm => 'Подтвердить операцию'; + + @override + String get wEdit => 'Изменить данные'; + + @override + String get wClose => 'Закрыть'; + + @override + String get wWorking => 'Обрабатываем…'; + + @override + String get wPending => 'В обработке'; + + @override + String get wCompleted => 'Завершено'; + + @override + String get wFailed => 'Не выполнено'; + + @override + String get wPendingTopUp => + 'Пополнение обрабатывается. Доступный баланс пока не изменился.'; + + @override + String get wPendingExchange => + 'Сумма и комиссия уже вычтены из доступного баланса. После завершения обмена деньги появятся на счёте зачисления.'; + + @override + String get wPendingPayout => + 'Сумма и комиссия уже вычтены из доступного баланса. Вывод ещё обрабатывается.'; + + @override + String get wCompleteTopUp => 'Счёт пополнен. Деньги доступны для операций.'; + + @override + String get wCompleteExchange => + 'Обмен завершён. Деньги доступны на счёте зачисления.'; + + @override + String get wCompletePayout => + 'Вывод завершён. Сумма и комиссия списаны с вашего счёта.'; + + @override + String get wFailedTopUp => + 'Пополнение не выполнено. На счёт ничего не зачислено.'; + + @override + String get wFailedOutgoing => + 'Операция не выполнена. Сумма и комиссия снова доступны на вашем счёте.'; + + @override + String get wUnavailable => + 'Не удалось обновить данные. Информация может быть устаревшей. Повторите попытку.'; + + @override + String get wInvalidAmount => + 'Введите сумму больше нуля с допустимым для валюты числом знаков после запятой.'; + + @override + String get wInsufficient => + 'Для этой суммы и комиссии недостаточно доступных средств. Пополните счёт или уменьшите сумму.'; + + @override + String get wLimitError => + 'Сумма превышает доступный лимит. Проверьте лимиты перед продолжением.'; + + @override + String get wRecipientError => + 'Введите тестового получателя длиной от 6 до 140 символов.'; + + @override + String get wUnsupported => 'Эта комбинация недоступна. Выберите другой счёт.'; + + @override + String get wNotFound => 'Не удалось найти запись. Вернитесь к счетам.'; + + @override + String get wUncertain => + 'Не удалось подтвердить результат. Проверьте его, прежде чем создавать другую операцию.'; + + @override + String get wCheckOutcome => 'Проверить результат'; + + @override + String get wNoSubmission => + 'Операция не найдена. Вы можете снова проверить и подтвердить её.'; + + @override + String get wDiscardTitle => 'Выйти из операции?'; + + @override + String get wDiscardBody => + 'Введённые данные будут удалены. Новая операция не будет отправлена.'; + + @override + String get wStay => 'Продолжить'; + + @override + String get wAll => 'Все'; + + @override + String get wSearch => 'Поиск по номеру'; + + @override + String get wNoMatches => 'Операции не найдены. Измените фильтр.'; + + @override + String get wClearFilters => 'Сбросить фильтры'; + + @override + String get wReference => 'Номер операции'; + + @override + String get wWhen => 'Создано'; + + @override + String get wViewAccount => 'Открыть счёт'; + + @override + String get wPreferences => 'Настройте под себя.'; + + @override + String get wLanguage => 'Язык аккаунта'; + + @override + String get wLanguageHelp => 'Используется во всём аккаунте.'; + + @override + String get wLanguageSaved => 'Сохранено в аккаунте.'; + + @override + String get wLanguageFailed => + 'Изменено для текущей сессии, но не сохранено в аккаунте. Повторите попытку ниже.'; + + @override + String get wProfileError => + 'Не удалось загрузить настройки аккаунта. Повторите попытку.'; + + @override + String get wSecurity => 'Безопасность'; + + @override + String get wSecurityBody => + 'Настройки пароля и безопасности пока недоступны.'; + + @override + String get wIdentity => 'Подтверждение личности'; + + @override + String get wIdentityHelp => 'Необязательная проверка для повышения лимитов.'; + + @override + String get wBackLogin => 'Вернуться ко входу'; + + @override + String get wEnter => 'Открыть свои счета'; + + @override + String get wUsage => 'Использовано за период'; + + @override + String get wLimitsScope => 'Исходящие операции · эквивалент в EUR'; + + @override + String get wPendingHint => + 'Обновите, чтобы узнать текущий статус. Остальными счетами можно пользоваться.'; + + @override + String get wFundsHeading => 'Где мои деньги?'; + + @override + String get wAnother => 'Создать новую операцию'; } diff --git a/frontend/app/lib/kyc/copy.dart b/frontend/app/lib/kyc/copy.dart new file mode 100644 index 0000000..6cf18a9 --- /dev/null +++ b/frontend/app/lib/kyc/copy.dart @@ -0,0 +1,37 @@ +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +import '../generated/i18n/app_localizations.dart'; + +String statusTitle(AppLocalizations l, IdentityStatus s) => switch (s) { + IdentityStatus.unverified => l.kycUnverified, + IdentityStatus.pending => l.kycPending, + IdentityStatus.approved => l.kycApproved, + IdentityStatus.changesRequested => l.kycChanges, + IdentityStatus.denied => l.kycDenied, +}; +String statusBody(AppLocalizations l, IdentityStatus s) => switch (s) { + IdentityStatus.unverified => l.kycUnverifiedBody, + IdentityStatus.pending => l.kycPendingBody, + IdentityStatus.approved => l.kycApprovedBody, + IdentityStatus.changesRequested => l.kycChangesBody, + IdentityStatus.denied => l.kycDeniedBody, +}; +String reasonText(AppLocalizations l, ReviewReason r) => switch (r) { + ReviewReason.passportUnreadable => l.kycPassportReason, + ReviewReason.selfieUnreadable => l.kycSelfieReason, + ReviewReason.passportExpired => l.kycExpiredReason, + ReviewReason.unableToVerify => l.kycDeniedReason, +}; +String errorText(AppLocalizations l, KycError e) => switch (e) { + KycError.unavailable => l.kycErrorUnavailable, + KycError.internal => l.kycErrorInternal, + KycError.permission => l.kycErrorPermission, + KycError.format => l.kycErrorFormat, + KycError.tooLarge => l.kycErrorLarge, + KycError.unreadable => l.kycErrorUnreadable, + KycError.tooSmall => l.kycErrorSmall, + KycError.incomplete => l.kycErrorIncomplete, + KycError.sessionExpired => l.kycErrorSession, + KycError.rateLimited => l.kycErrorRate, + KycError.conflict => l.kycErrorConflict, +}; diff --git a/frontend/app/lib/kyc/form.dart b/frontend/app/lib/kyc/form.dart new file mode 100644 index 0000000..e482f2d --- /dev/null +++ b/frontend/app/lib/kyc/form.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; + +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +import 'photo.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class IdentityForm extends StatelessWidget { + const IdentityForm({super.key, required this.provider}); + final IdentityProvider provider; + @override + Widget build(BuildContext context) { + final p = provider; + final l = AppLocalizations.of(context)!; + final titles = [l.kycPassport, l.kycSelfie, l.kycReview]; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + header: true, + liveRegion: true, + child: Text( + '${p.step + 1} / 3 · ${titles[p.step]}', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + const SizedBox(height: 16), + LinearProgressIndicator( + value: (p.step + 1) / 3, + semanticsLabel: titles[p.step], + ), + const SizedBox(height: 16), + if (p.step < 2) + PhotoPanel( + kind: p.step == 0 ? PhotoKind.passport : PhotoKind.selfie, + photo: p.step == 0 ? p.passport : p.selfie, + onPick: p.busy + ? null + : () => p.pick( + p.step == 0 ? PhotoKind.passport : PhotoKind.selfie, + ), + onRemove: p.busy + ? null + : () => p.remove( + p.step == 0 ? PhotoKind.passport : PhotoKind.selfie, + ), + ), + if (p.step == 2) ...[ + Text(l.kycReviewHelp), + const SizedBox(height: 16), + PhotoPanel(kind: PhotoKind.passport, photo: p.passport, review: true), + PhotoPanel(kind: PhotoKind.selfie, photo: p.selfie, review: true), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + value: p.confirmed, + onChanged: p.busy ? null : (v) => p.confirm(v ?? false), + title: Text(l.kycConfirm), + ), + ], + const SizedBox(height: 16), + if (p.step < 2) ...[ + if ((p.step == 0 ? p.passport : p.selfie) == null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text(l.kycChooseFirst), + ), + FilledButton( + key: const ValueKey('kyc-next'), + onPressed: p.busy || (p.step == 0 ? p.passport : p.selfie) == null + ? null + : p.next, + child: Text(l.kycNext), + ), + ] else + FilledButton( + key: const ValueKey('kyc-submit'), + onPressed: p.canSubmit ? p.submit : null, + child: Text(l.kycSubmit), + ), + if (p.step > 0) + TextButton(onPressed: p.busy ? null : p.back, child: Text(l.kycBack)), + ], + ); + } +} diff --git a/frontend/app/lib/kyc/limits.dart b/frontend/app/lib/kyc/limits.dart new file mode 100644 index 0000000..54551c4 --- /dev/null +++ b/frontend/app/lib/kyc/limits.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; + +import 'package:intl/intl.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class LimitsPanel extends StatelessWidget { + const LimitsPanel({super.key}); + @override + Widget build(BuildContext context) { + final p = context.watch(); + final l = AppLocalizations.of(context)!; + final t = Theme.of(context); + final data = p.data; + return Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l.kycLimits, style: t.textTheme.headlineSmall), + if (p.loading) ...[ + const SizedBox(height: 16), + const LinearProgressIndicator(), + ], + if (p.failed) ...[ + const SizedBox(height: 12), + Semantics( + liveRegion: true, + child: Text( + l.kycLimitsError, + style: TextStyle(color: t.colorScheme.error), + ), + ), + TextButton( + onPressed: p.loading ? null : p.refresh, + child: Text(l.kycRefresh), + ), + ], + if (data != null) ...[ + const SizedBox(height: 12), + Text( + data.tier == AccountTier.basic ? l.kycBasic : l.kycVerifiedTier, + style: t.textTheme.titleLarge, + ), + const SizedBox(height: 12), + if (data.rows.isEmpty) Text(l.kycLimitsEmpty), + for (final row in data.rows) ...[ + const Divider(height: 32), + Text( + row.period == LimitPeriod.day ? l.kycDay : l.kycMonth, + style: t.textTheme.titleMedium, + ), + const SizedBox(height: 12), + Wrap( + spacing: 28, + runSpacing: 16, + children: [ + _amount( + context, + l.kycCurrent, + row.currentMinor, + row.currency, + ), + _amount( + context, + l.wUsage, + row.usedMinor + row.reservedMinor, + row.currency, + ), + _amount( + context, + l.kycRemaining, + row.remainingMinor, + row.currency, + ), + if (data.tier == AccountTier.basic) + _amount( + context, + l.kycAfter, + row.verifiedMinor, + row.currency, + ), + ], + ), + const SizedBox(height: 16), + LinearProgressIndicator( + key: ValueKey('usage-${row.period.name}'), + value: row.occupiedFraction, + minHeight: 8, + borderRadius: BorderRadius.circular(4), + semanticsLabel: + '${row.period == LimitPeriod.day ? l.kycDay : l.kycMonth}: ${l.wUsage}', + ), + if (row.remainingMinor <= 0) ...[ + const SizedBox(height: 8), + Text( + l.kycNoCapacity, + style: TextStyle(color: t.colorScheme.error), + ), + ], + ], + ], + ], + ), + ), + ); + } + + Widget _amount( + BuildContext context, + String label, + int minor, + String currency, + ) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label), + Text( + NumberFormat.simpleCurrency( + locale: Localizations.localeOf(context).toLanguageTag(), + name: currency, + ).format(minor / 100), + style: Theme.of(context).textTheme.titleLarge, + ), + ], + ); +} diff --git a/frontend/app/lib/kyc/page.dart b/frontend/app/lib/kyc/page.dart new file mode 100644 index 0000000..ac8efed --- /dev/null +++ b/frontend/app/lib/kyc/page.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_analytics/sxchnge_analytics.dart'; + +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +import 'copy.dart'; +import 'form.dart'; +import 'limits.dart'; +import 'status.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class IdentityPage extends StatefulWidget { + const IdentityPage({super.key}); + @override + State createState() => IdentityPageState(); +} + +class IdentityPageState extends State { + late final _limits = LimitsProvider(context.read()); + late final _identity = IdentityProvider( + context.read(), + context.read(), + JourneyRecorder(context.read()), + onStatusChanged: _limits.refresh, + ); + final _contentKey = GlobalKey(); + final _feedbackKey = GlobalKey(); + final _focus = FocusNode(skipTraversal: true); + String? _lastView; + void _transition() { + final p = _identity; + if (p.busy) return; + final view = '${p.editing}:${p.step}:${p.record?.status}:${p.error}'; + if (_lastView == null) { + _lastView = view; + return; + } + if (view == _lastView) return; + _lastView = view; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final target = p.error != null + ? _feedbackKey.currentContext + : _contentKey.currentContext; + if (target != null) { + Scrollable.ensureVisible( + target, + alignment: 0.05, + duration: const Duration(milliseconds: 180), + ); + } + if (p.error == null) _focus.requestFocus(); + }); + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _identity.addListener(_transition); + _identity.refresh(); + } + }); + } + + Future allowExit() async { + if (_identity.busy) return false; + if (!_identity.hasDraft) return true; + final l = AppLocalizations.of(context)!; + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l.kycDiscardTitle), + content: Text(l.kycDiscardBody), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(l.kycStay), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l.kycDiscard), + ), + ], + ), + ) ?? + false; + } + + Future _leave({bool account = false}) async { + if (!await allowExit() || !mounted) return; + _identity.exit(); + if (account) { + if (context.canPop()) { + context.pop(); + } else { + context.go('/limits'); + } + } + } + + @override + void dispose() { + _identity.removeListener(_transition); + _identity.dispose(); + _focus.dispose(); + _limits.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ChangeNotifierProvider.value( + value: _limits, + child: ListenableBuilder( + listenable: _identity, + builder: (context, _) { + final p = _identity; + final l = AppLocalizations.of(context)!; + final t = Theme.of(context); + return PopScope( + canPop: !p.hasDraft && !p.busy, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _leave(account: true); + }, + child: Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 36), + Semantics( + header: true, + child: Text( + l.kycTitle, + style: t.textTheme.displaySmall, + ), + ), + const SizedBox(height: 12), + Text(l.kycIntro, style: t.textTheme.bodyLarge), + const SizedBox(height: 24), + if (p.busy) ...[ + const LinearProgressIndicator(), + const SizedBox(height: 12), + Semantics( + liveRegion: true, + child: Text( + p.record == null ? l.kycLoading : l.kycWorking, + ), + ), + const SizedBox(height: 16), + ], + if (p.error != null) ...[ + Semantics( + key: _feedbackKey, + liveRegion: true, + child: Text( + errorText(l, p.error!), + key: const ValueKey('kyc-error'), + style: TextStyle(color: t.colorScheme.error), + ), + ), + const SizedBox(height: 16), + ], + if (p.stale && p.record != null) + Text( + l.kycStatusStale, + style: TextStyle(color: t.colorScheme.error), + ), + Focus( + includeSemantics: false, + key: _contentKey, + focusNode: _focus, + child: p.editing + ? IdentityForm(provider: p) + : p.record != null + ? StatusPanel(provider: p) + : const SizedBox.shrink(), + ), + if (!p.editing || p.stale) + TextButton( + onPressed: p.busy ? null : p.refresh, + child: Text(l.kycRefresh), + ), + if (!p.editing) ...[ + const SizedBox(height: 16), + const LimitsPanel(), + ], + const SizedBox(height: 16), + if (p.editing) + TextButton( + onPressed: p.busy ? null : _leave, + child: Text(l.kycSkip), + ), + TextButton( + onPressed: p.busy + ? null + : () => _leave(account: true), + child: Text(l.wLimits), + ), + ], + ), + ), + ), + ), + ), + ), + ); + }, + ), + ); +} diff --git a/frontend/app/lib/kyc/photo.dart b/frontend/app/lib/kyc/photo.dart new file mode 100644 index 0000000..04f764c --- /dev/null +++ b/frontend/app/lib/kyc/photo.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +import 'preview.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class PhotoPanel extends StatelessWidget { + const PhotoPanel({ + super.key, + required this.kind, + required this.photo, + this.onPick, + this.onRemove, + this.review = false, + }); + final PhotoKind kind; + final Photo? photo; + final VoidCallback? onPick; + final VoidCallback? onRemove; + final bool review; + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final t = Theme.of(context); + final passport = kind == PhotoKind.passport; + final title = passport ? l.kycPassport : l.kycSelfie; + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(title, style: t.textTheme.titleLarge), + const SizedBox(height: 16), + if (photo != null) + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: PhotoPreview(photo: photo!, label: title), + ) + else + Container( + height: 140, + decoration: BoxDecoration( + color: t.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + passport ? Icons.badge_outlined : Icons.portrait_outlined, + size: 64, + color: t.colorScheme.primary, + ), + ), + if (!review) ...[ + const SizedBox(height: 16), + Text(passport ? l.kycPassportHelp : l.kycSelfieHelp), + const SizedBox(height: 12), + Text(l.kycFileRules, style: t.textTheme.bodySmall), + const SizedBox(height: 16), + OutlinedButton.icon( + key: ValueKey('pick-${kind.name}'), + onPressed: onPick, + icon: const Icon(Icons.add_photo_alternate_outlined), + label: Text( + photo == null ? l.kycChoose : l.kycReplace, + textAlign: TextAlign.center, + ), + ), + if (photo != null) + TextButton(onPressed: onRemove, child: Text(l.kycRemove)), + ], + ], + ), + ), + ); + } +} diff --git a/frontend/app/lib/kyc/preview.dart b/frontend/app/lib/kyc/preview.dart new file mode 100644 index 0000000..89f527f --- /dev/null +++ b/frontend/app/lib/kyc/preview.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +import '../generated/i18n/app_localizations.dart'; + +/// Evict this sensitive preview's decoded cache entry on replacement or exit. +class PhotoPreview extends StatefulWidget { + const PhotoPreview({super.key, required this.photo, required this.label}); + final Photo photo; + final String label; + @override + State createState() => _PhotoPreviewState(); +} + +class _PhotoPreviewState extends State { + late ImageProvider _image = _provider(); + ImageProvider _provider() => + ResizeImage.resizeIfNeeded(1000, null, MemoryImage(widget.photo.bytes)); + @override + void didUpdateWidget(covariant PhotoPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.photo, widget.photo)) { + _image.evict(); + _image = _provider(); + } + } + + @override + void dispose() { + _image.evict(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Image( + image: _image, + height: 220, + fit: BoxFit.contain, + semanticLabel: widget.label, + errorBuilder: (_, _, _) => + Text(AppLocalizations.of(context)!.kycErrorUnreadable), + ); +} diff --git a/frontend/app/lib/kyc/status.dart b/frontend/app/lib/kyc/status.dart new file mode 100644 index 0000000..09d3703 --- /dev/null +++ b/frontend/app/lib/kyc/status.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; + +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +import 'copy.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class StatusPanel extends StatelessWidget { + const StatusPanel({super.key, required this.provider}); + final IdentityProvider provider; + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final t = Theme.of(context); + final p = provider; + final record = p.record!; + final status = record.status; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: t.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(24), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + switch (status) { + IdentityStatus.approved => Icons.verified_outlined, + IdentityStatus.pending => Icons.hourglass_top, + IdentityStatus.changesRequested || + IdentityStatus.denied => Icons.info_outline, + _ => Icons.shield_outlined, + }, + color: t.colorScheme.onPrimaryContainer, + size: 36, + ), + const SizedBox(height: 16), + Semantics( + header: true, + liveRegion: true, + child: Text( + statusTitle(l, status), + key: ValueKey('kyc-${status.name}'), + style: t.textTheme.headlineMedium?.copyWith( + color: t.colorScheme.onPrimaryContainer, + ), + ), + ), + const SizedBox(height: 12), + Text( + statusBody(l, status), + style: TextStyle(color: t.colorScheme.onPrimaryContainer), + ), + if (record.reason != null) ...[ + const SizedBox(height: 12), + Text( + reasonText(l, record.reason!), + style: TextStyle( + color: t.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.bold, + ), + ), + ], + ], + ), + ), + const SizedBox(height: 20), + if (record.canSubmit) + FilledButton( + key: const ValueKey('kyc-start'), + onPressed: p.busy || p.stale ? null : p.start, + child: Text( + status == IdentityStatus.changesRequested + ? l.kycResubmit + : l.kycStart, + ), + ), + if (status == IdentityStatus.denied) + OutlinedButton( + onPressed: () => showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l.kycSupport), + content: Text(l.kycSupportBody), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l.kycBack), + ), + ], + ), + ), + child: Text(l.kycSupport), + ), + if (status == IdentityStatus.pending && + p.service is DummyIdentityService) + ExpansionTile( + title: Text(l.kycDemoControls), + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + TextButton( + onPressed: p.busy + ? null + : () => p.demoReview(IdentityStatus.approved), + child: Text(l.kycDemoApprove), + ), + TextButton( + onPressed: p.busy + ? null + : () => p.demoReview( + IdentityStatus.changesRequested, + reason: ReviewReason.passportUnreadable, + ), + child: Text(l.kycDemoCorrect), + ), + TextButton( + onPressed: p.busy + ? null + : () => p.demoReview( + IdentityStatus.changesRequested, + reason: ReviewReason.selfieUnreadable, + ), + child: Text(l.kycDemoSelfie), + ), + TextButton( + onPressed: p.busy + ? null + : () => p.demoReview( + IdentityStatus.changesRequested, + reason: ReviewReason.passportExpired, + ), + child: Text(l.kycDemoExpired), + ), + TextButton( + onPressed: p.busy + ? null + : () => p.demoReview( + IdentityStatus.denied, + reason: ReviewReason.unableToVerify, + ), + child: Text(l.kycDemoDeny), + ), + ], + ), + ], + ), + ], + ); + } +} diff --git a/frontend/app/lib/l10n/en.arb b/frontend/app/lib/l10n/en.arb index 56fa545..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,14 +62,170 @@ "verificationRetry": "Retry verification", "verificationBack": "Back to login", "verificationHint": "No email? Check your spam folder or request another below.", - "verificationDemo": "DEMO · No real email is sent and no account is verified.", - "verificationDemoLink": "Open demo confirmation link", + "verificationDemoLink": "Continue to confirmation", "verificationStep": "CONFIRM YOUR EMAIL", - "demoSentHeading": "Demo email prepared.", - "demoSentBody": "No email was sent. Use the demo confirmation below to preview the next step.", - "demoVerifiedHeading": "Demo verification\ncomplete.", - "demoVerifiedBody": "You’ve previewed email confirmation. No account was verified. Return to login to explore the demo.", "verificationRecipient": "Recipient", "verificationChangeEmail": "Change email", - "demoResend": "Prepare demo email again" + "kycTitle": "Verification & limits", + "kycIntro": "Your account, at your pace. Verify your identity when you need higher limits.", + "kycUnverified": "Not verified", + "kycPending": "In review", + "kycApproved": "Identity verified", + "kycChanges": "New photos needed", + "kycDenied": "Verification declined", + "kycUnverifiedBody": "You can keep using your account with basic limits. Submit a passport photo and a selfie holding it to request an upgrade.", + "kycPendingBody": "Your submission is awaiting a human review. Basic limits remain active until approval. You do not need to submit again.", + "kycApprovedBody": "Your review is complete. Check your current limits below.", + "kycChangesBody": "Your account remains available with basic limits. Replace the photos and submit a new review request.", + "kycDeniedBody": "Basic limits remain available. If you believe this decision is incorrect, contact support for a review.", + "kycPassportReason": "The passport details could not be read. Retake the photo in good light, with every edge visible.", + "kycSelfieReason": "Your face or passport was not clear in the selfie. Keep both visible and in focus.", + "kycExpiredReason": "The passport has expired. Use a valid passport and take both photos again.", + "kycDeniedReason": "We could not verify your identity from this submission.", + "kycStart": "Verify identity", + "kycResubmit": "Replace photos", + "kycRefresh": "Refresh status & limits", + "kycSupport": "Contact support", + "kycSupportBody": "Support is currently unavailable. Please try again later.", + "kycBasic": "Basic", + "kycVerifiedTier": "Verified", + "kycLimits": "Your limits", + "kycDay": "Daily volume", + "kycMonth": "Monthly volume", + "kycCurrent": "Current cap", + "kycRemaining": "Available", + "kycAfter": "After approval", + "kycLimitsError": "Limits could not be refreshed. Any values below are out of date. Retry to see your current access.", + "kycLimitsEmpty": "No limits were returned. Refresh or check with support before making a transaction.", + "kycLoading": "Loading your status…", + "kycPassport": "Passport photo", + "kycSelfie": "Selfie with passport", + "kycReview": "Review your photos", + "kycPassportHelp": "Open your valid passport to the photo page. Show all four edges and keep text sharp. Avoid glare, blur and covered details.", + "kycSelfieHelp": "Hold the open passport beside your face. Show your whole face and the photo page in the same image. Keep both sharp and uncovered.", + "kycFileRules": "JPEG or PNG · up to 10 MB · at least 600 × 600 pixels. Take a photo with your camera, then choose it here.", + "kycChoose": "Choose photo", + "kycReplace": "Choose a different photo", + "kycRemove": "Remove photo", + "kycNext": "Continue", + "kycBack": "Back", + "kycSkip": "Keep basic limits", + "kycClose": "Back to account", + "kycReviewHelp": "Check that both photos are readable and show the same passport. A staff member will review them; submission does not immediately raise your limits.", + "kycConfirm": "These photos show me and my own valid passport.", + "kycSubmit": "Submit for review", + "kycWorking": "Processing… Please keep this page open.", + "kycDiscardTitle": "Discard these photos?", + "kycDiscardBody": "Your draft is only in memory. Leaving will remove the selected photos; you can start again later.", + "kycStay": "Keep editing", + "kycDiscard": "Discard & leave", + "kycErrorUnavailable": "The service is unavailable. Your selected photos are still here. Try again.", + "kycErrorInternal": "Something went wrong. Try again; your selected photos have been kept.", + "kycErrorPermission": "We could not open that photo. Check file access or choose another image.", + "kycErrorFormat": "Choose a JPEG or PNG image. Other formats are not supported.", + "kycErrorLarge": "This image is too large. Choose one under 10 MB and 40 megapixels.", + "kycErrorUnreadable": "This image is damaged or cannot be read. Choose another photo.", + "kycErrorSmall": "The photo is too small. Use an image at least 600 pixels wide and high.", + "kycErrorIncomplete": "Choose both photos and confirm they belong to you before submitting.", + "kycErrorSession": "Your session has expired. Return to login, then check your verification status.", + "kycErrorRate": "Too many requests. Wait a moment before trying again.", + "kycErrorConflict": "Your review status has changed. Refresh it before continuing.", + "kycDemoControls": "Review scenarios", + "kycDemoApprove": "Approve", + "kycDemoCorrect": "Request clearer passport", + "kycDemoSelfie": "Request clearer selfie", + "kycDemoExpired": "Passport expired", + "kycDemoDeny": "Decline", + "kycPreview": "Explore verification & limits", + "kycChooseFirst": "Choose a photo to continue.", + "kycStatusStale": "Status could not be refreshed. The status shown is the last known result.", + "kycUsed": "Used", + "kycReserved": "Reserved", + "kycUsageHelp": "Used is completed activity. Reserved is held for pending transactions. Both count toward your limit.", + "kycOccupied": "Used + reserved", + "kycNoCapacity": "No capacity available", + "wAccounts": "Accounts", + "wActivity": "Activity", + "wLimits": "Limits", + "wSettings": "Settings", + "wHeadline": "Your money.\nYour next move.", + "wSubheading": "Top up. Exchange. Go further.", + "wAvailable": "Available to you", + "wTopUp": "Top up", + "wExchange": "Exchange", + "wPayout": "Pay out", + "wRecent": "Recent activity", + "wSeeAll": "See all", + "wEmptyActivity": "Your next move starts here. Your transactions will appear below.", + "wEmptyAccounts": "Your accounts are not ready yet. Try refreshing.", + "wRefresh": "Refresh", + "wSource": "From account", + "wDestination": "To account", + "wReceiveAccount": "Receiving account", + "wAmount": "Amount", + "wRecipient": "Recipient", + "wRecipientHint": "Recipient name or reference", + "wReview": "Review", + "wReviewTitle": "One last look.", + "wFee": "Fee", + "wTotalDebit": "Leaves your account", + "wCredit": "Arrives in your account", + "wRecipientGets": "Recipient receives", + "wYouPay": "You pay", + "wRate": "Exchange rate", + "wQuoteNotice": "These terms are valid for 60 seconds. Confirm them before continuing.", + "wExpired": "This quote expired. Refresh it and review the new terms.", + "wNewQuote": "Refresh quote", + "wConfirm": "Confirm transaction", + "wEdit": "Edit details", + "wClose": "Close", + "wWorking": "Working on it…", + "wPending": "Processing", + "wCompleted": "Completed", + "wFailed": "Not completed", + "wPendingTopUp": "Your top-up is processing. Your available balance has not changed yet.", + "wPendingExchange": "The amount and fee have left your available balance. The exchanged amount will appear in the receiving account when completed.", + "wPendingPayout": "The amount and fee have left your available balance. The payout is still processing.", + "wCompleteTopUp": "Your account has been credited. The money is available to use.", + "wCompleteExchange": "Your exchange is complete. The money is available in the receiving account.", + "wCompletePayout": "Your payout is complete. The amount and fee have been deducted from your account.", + "wFailedTopUp": "The top-up did not complete. No money was added to your account.", + "wFailedOutgoing": "The transaction did not complete. The amount and fee are available in your account again.", + "wUnavailable": "Could not refresh. The displayed information may be out of date. Try again.", + "wInvalidAmount": "Enter an amount greater than zero, using the currency’s decimal precision.", + "wInsufficient": "Not enough available money for this amount and fee. Top up or enter a smaller amount.", + "wLimitError": "This amount exceeds your available limit. Check your limits before continuing.", + "wRecipientError": "Enter a sample recipient between 6 and 140 characters.", + "wUnsupported": "This combination is unavailable. Choose another account.", + "wNotFound": "This item could not be found. Return to your accounts.", + "wUncertain": "We could not confirm the outcome. Check it before trying another transaction.", + "wCheckOutcome": "Check outcome", + "wNoSubmission": "No transaction was found. You can review and confirm again.", + "wDiscardTitle": "Leave this transaction?", + "wDiscardBody": "Your entered details will be cleared. No new transaction will be submitted.", + "wStay": "Keep editing", + "wAll": "All", + "wSearch": "Search by reference", + "wNoMatches": "No matching transactions. Try another filter.", + "wClearFilters": "Clear filters", + "wReference": "Reference", + "wWhen": "Created", + "wViewAccount": "View account", + "wPreferences": "Make it yours.", + "wLanguage": "Account language", + "wLanguageHelp": "Used throughout your account.", + "wLanguageSaved": "Saved to your account.", + "wLanguageFailed": "Changed for this session, but not saved to your account. Retry below.", + "wProfileError": "Account preferences could not be loaded. Try again.", + "wSecurity": "Security", + "wSecurityBody": "Password and security settings are not available yet.", + "wIdentity": "Identity verification", + "wIdentityHelp": "Optional verification for higher limits.", + "wBackLogin": "Back to login", + "wEnter": "Explore your accounts", + "wUsage": "Used this period", + "wLimitsScope": "Outgoing activity · EUR equivalent", + "wPendingHint": "Refresh to check the latest status. You can keep using your other accounts.", + "wFundsHeading": "Where is my money?", + "wAnother": "Make another transaction" } diff --git a/frontend/app/lib/l10n/ru.arb b/frontend/app/lib/l10n/ru.arb index 02bf9e9..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,14 +62,170 @@ "verificationRetry": "Повторить подтверждение", "verificationBack": "Вернуться ко входу", "verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.", - "verificationDemo": "ДЕМО · Письмо не отправляется, реальный аккаунт не подтверждается.", - "verificationDemoLink": "Открыть демоссылку подтверждения", + "verificationDemoLink": "Перейти к подтверждению", "verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ", - "demoSentHeading": "Демописьмо готово.", - "demoSentBody": "Письмо не отправлено. Используйте демоссылку ниже, чтобы посмотреть следующий шаг.", - "demoVerifiedHeading": "Демоподтверждение\nзавершено.", - "demoVerifiedBody": "Вы посмотрели подтверждение почты. Реальный аккаунт не подтверждён. Вернитесь ко входу, чтобы продолжить знакомство с демоверсией.", "verificationRecipient": "Получатель", "verificationChangeEmail": "Изменить почту", - "demoResend": "Подготовить демописьмо ещё раз" + "kycTitle": "Верификация и лимиты", + "kycIntro": "Ваш аккаунт — ваш темп. Подтвердите личность, когда понадобятся более высокие лимиты.", + "kycUnverified": "Личность не подтверждена", + "kycPending": "На проверке", + "kycApproved": "Личность подтверждена", + "kycChanges": "Нужны новые фото", + "kycDenied": "Верификация отклонена", + "kycUnverifiedBody": "Вы можете пользоваться аккаунтом с базовыми лимитами. Для повышения отправьте фото паспорта и селфи с ним.", + "kycPendingBody": "Заявка ожидает проверки сотрудником. До одобрения действуют базовые лимиты. Повторная отправка не нужна.", + "kycApprovedBody": "Проверка завершена. Актуальные лимиты указаны ниже.", + "kycChangesBody": "Аккаунт доступен с базовыми лимитами. Замените фото и отправьте новую заявку.", + "kycDeniedBody": "Базовые лимиты остаются доступными. Если вы не согласны с решением, обратитесь в поддержку для пересмотра.", + "kycPassportReason": "Данные паспорта не удалось прочитать. Переснимите при хорошем освещении, чтобы все края были видны.", + "kycSelfieReason": "На селфи плохо видно лицо или паспорт. Оба должны быть в кадре и в фокусе.", + "kycExpiredReason": "Срок действия паспорта истёк. Используйте действующий паспорт и сделайте оба фото заново.", + "kycDeniedReason": "По этой заявке не удалось подтвердить вашу личность.", + "kycStart": "Подтвердить личность", + "kycResubmit": "Заменить фото", + "kycRefresh": "Обновить статус и лимиты", + "kycSupport": "Обратиться в поддержку", + "kycSupportBody": "Поддержка сейчас недоступна. Попробуйте позже.", + "kycBasic": "Базовый", + "kycVerifiedTier": "Подтверждённый", + "kycLimits": "Ваши лимиты", + "kycDay": "Объём за день", + "kycMonth": "Объём за месяц", + "kycCurrent": "Текущий лимит", + "kycRemaining": "Доступно", + "kycAfter": "После одобрения", + "kycLimitsError": "Не удалось обновить лимиты. Значения ниже могут быть устаревшими. Повторите запрос.", + "kycLimitsEmpty": "Сервис не вернул лимиты. Обновите данные или уточните их в поддержке перед операцией.", + "kycLoading": "Загружаем ваш статус…", + "kycPassport": "Фото паспорта", + "kycSelfie": "Селфи с паспортом", + "kycReview": "Проверьте фото", + "kycPassportHelp": "Откройте действующий паспорт на странице с фото. Все четыре края и текст должны быть чётко видны. Избегайте бликов и размытия, не закрывайте данные.", + "kycSelfieHelp": "Держите открытый паспорт рядом с лицом. Лицо целиком и страница с фото должны быть в одном кадре, чёткими и ничем не закрытыми.", + "kycFileRules": "JPEG или PNG · до 10 МБ · не менее 600 × 600 пикселей. Сделайте фото камерой, затем выберите его здесь.", + "kycChoose": "Выбрать фото", + "kycReplace": "Выбрать другое фото", + "kycRemove": "Удалить фото", + "kycNext": "Продолжить", + "kycBack": "Назад", + "kycSkip": "Оставить базовые лимиты", + "kycClose": "К аккаунту", + "kycReviewHelp": "Убедитесь, что оба фото читаемы и на них один паспорт. Их проверит сотрудник. Отправка не повышает лимиты сразу.", + "kycConfirm": "На фото я и мой действующий паспорт.", + "kycSubmit": "Отправить на проверку", + "kycWorking": "Обработка… Не закрывайте страницу.", + "kycDiscardTitle": "Удалить выбранные фото?", + "kycDiscardBody": "Черновик хранится только в памяти. При выходе выбранные фото будут удалены. Позже можно начать заново.", + "kycStay": "Продолжить", + "kycDiscard": "Удалить и выйти", + "kycErrorUnavailable": "Сервис недоступен. Выбранные фото сохранены в памяти. Повторите попытку.", + "kycErrorInternal": "Произошла ошибка. Повторите попытку; выбранные фото сохранены.", + "kycErrorPermission": "Не удалось открыть фото. Проверьте доступ к файлам или выберите другое изображение.", + "kycErrorFormat": "Выберите изображение JPEG или PNG. Другие форматы не поддерживаются.", + "kycErrorLarge": "Изображение слишком большое. Выберите файл до 10 МБ и 40 мегапикселей.", + "kycErrorUnreadable": "Изображение повреждено или не читается. Выберите другое фото.", + "kycErrorSmall": "Фото слишком маленькое. Ширина и высота должны быть не менее 600 пикселей.", + "kycErrorIncomplete": "Выберите оба фото и подтвердите, что на них вы и ваш паспорт.", + "kycErrorSession": "Сессия истекла. Войдите снова и проверьте статус верификации.", + "kycErrorRate": "Слишком много запросов. Немного подождите и повторите попытку.", + "kycErrorConflict": "Статус проверки изменился. Обновите его, прежде чем продолжить.", + "kycDemoControls": "Сценарии проверки", + "kycDemoApprove": "Одобрить", + "kycDemoCorrect": "Запросить чёткое фото паспорта", + "kycDemoSelfie": "Запросить чёткое селфи", + "kycDemoExpired": "Паспорт просрочен", + "kycDemoDeny": "Отклонить", + "kycPreview": "Посмотреть верификацию и лимиты", + "kycChooseFirst": "Выберите фото для продолжения.", + "kycStatusStale": "Не удалось обновить статус. Показан последний известный результат.", + "kycUsed": "Использовано", + "kycReserved": "Зарезервировано", + "kycUsageHelp": "Использовано — завершённые операции. Зарезервировано — суммы, удержанные для ожидающих операций. Обе суммы учитываются в лимите.", + "kycOccupied": "Использовано + зарезервировано", + "kycNoCapacity": "Доступный объём исчерпан", + "wAccounts": "Счета", + "wActivity": "История", + "wLimits": "Лимиты", + "wSettings": "Настройки", + "wHeadline": "Ваши деньги.\nВаш следующий шаг.", + "wSubheading": "Пополняйте. Обменивайте. Двигайтесь дальше.", + "wAvailable": "Вам доступно", + "wTopUp": "Пополнить", + "wExchange": "Обменять", + "wPayout": "Вывести", + "wRecent": "Последние операции", + "wSeeAll": "Смотреть все", + "wEmptyActivity": "Ваш следующий шаг начинается здесь. Здесь появятся ваши операции.", + "wEmptyAccounts": "Ваши счета ещё не готовы. Попробуйте обновить страницу.", + "wRefresh": "Обновить", + "wSource": "Со счёта", + "wDestination": "На счёт", + "wReceiveAccount": "Счёт для зачисления", + "wAmount": "Сумма", + "wRecipient": "Получатель", + "wRecipientHint": "Имя или реквизиты получателя", + "wReview": "Проверить", + "wReviewTitle": "Проверьте детали.", + "wFee": "Комиссия", + "wTotalDebit": "Спишется со счёта", + "wCredit": "Поступит на счёт", + "wRecipientGets": "Получатель получит", + "wYouPay": "Вы платите", + "wRate": "Курс обмена", + "wQuoteNotice": "Эти условия действуют 60 секунд. Подтвердите их, прежде чем продолжить.", + "wExpired": "Срок действия расчёта истёк. Обновите его и проверьте новые условия.", + "wNewQuote": "Обновить расчёт", + "wConfirm": "Подтвердить операцию", + "wEdit": "Изменить данные", + "wClose": "Закрыть", + "wWorking": "Обрабатываем…", + "wPending": "В обработке", + "wCompleted": "Завершено", + "wFailed": "Не выполнено", + "wPendingTopUp": "Пополнение обрабатывается. Доступный баланс пока не изменился.", + "wPendingExchange": "Сумма и комиссия уже вычтены из доступного баланса. После завершения обмена деньги появятся на счёте зачисления.", + "wPendingPayout": "Сумма и комиссия уже вычтены из доступного баланса. Вывод ещё обрабатывается.", + "wCompleteTopUp": "Счёт пополнен. Деньги доступны для операций.", + "wCompleteExchange": "Обмен завершён. Деньги доступны на счёте зачисления.", + "wCompletePayout": "Вывод завершён. Сумма и комиссия списаны с вашего счёта.", + "wFailedTopUp": "Пополнение не выполнено. На счёт ничего не зачислено.", + "wFailedOutgoing": "Операция не выполнена. Сумма и комиссия снова доступны на вашем счёте.", + "wUnavailable": "Не удалось обновить данные. Информация может быть устаревшей. Повторите попытку.", + "wInvalidAmount": "Введите сумму больше нуля с допустимым для валюты числом знаков после запятой.", + "wInsufficient": "Для этой суммы и комиссии недостаточно доступных средств. Пополните счёт или уменьшите сумму.", + "wLimitError": "Сумма превышает доступный лимит. Проверьте лимиты перед продолжением.", + "wRecipientError": "Введите тестового получателя длиной от 6 до 140 символов.", + "wUnsupported": "Эта комбинация недоступна. Выберите другой счёт.", + "wNotFound": "Не удалось найти запись. Вернитесь к счетам.", + "wUncertain": "Не удалось подтвердить результат. Проверьте его, прежде чем создавать другую операцию.", + "wCheckOutcome": "Проверить результат", + "wNoSubmission": "Операция не найдена. Вы можете снова проверить и подтвердить её.", + "wDiscardTitle": "Выйти из операции?", + "wDiscardBody": "Введённые данные будут удалены. Новая операция не будет отправлена.", + "wStay": "Продолжить", + "wAll": "Все", + "wSearch": "Поиск по номеру", + "wNoMatches": "Операции не найдены. Измените фильтр.", + "wClearFilters": "Сбросить фильтры", + "wReference": "Номер операции", + "wWhen": "Создано", + "wViewAccount": "Открыть счёт", + "wPreferences": "Настройте под себя.", + "wLanguage": "Язык аккаунта", + "wLanguageHelp": "Используется во всём аккаунте.", + "wLanguageSaved": "Сохранено в аккаунте.", + "wLanguageFailed": "Изменено для текущей сессии, но не сохранено в аккаунте. Повторите попытку ниже.", + "wProfileError": "Не удалось загрузить настройки аккаунта. Повторите попытку.", + "wSecurity": "Безопасность", + "wSecurityBody": "Настройки пароля и безопасности пока недоступны.", + "wIdentity": "Подтверждение личности", + "wIdentityHelp": "Необязательная проверка для повышения лимитов.", + "wBackLogin": "Вернуться ко входу", + "wEnter": "Открыть свои счета", + "wUsage": "Использовано за период", + "wLimitsScope": "Исходящие операции · эквивалент в EUR", + "wPendingHint": "Обновите, чтобы узнать текущий статус. Остальными счетами можно пользоваться.", + "wFundsHeading": "Где мои деньги?", + "wAnother": "Создать новую операцию" } diff --git a/frontend/app/lib/main.dart b/frontend/app/lib/main.dart index 032cb6f..45e7217 100644 --- a/frontend/app/lib/main.dart +++ b/frontend/app/lib/main.dart @@ -6,8 +6,12 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart'; import 'package:sxchnge_auth/sxchnge_auth.dart'; +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + import 'package:sxchnge_theme/sxchnge_theme.dart'; +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + import 'providers/locale.dart'; import 'routing/router.dart'; @@ -24,12 +28,20 @@ class SxchngeApp extends StatefulWidget { super.key, this.localeProvider, this.analyticsService, + this.identityService, + this.limitsService, + this.walletService, + this.photoService = const FilePhotoService(), this.loginService = const DummyLoginService(), this.signupService = const DummySignupService(), this.verificationService = const DummyVerificationService(), }); final LocaleProvider? localeProvider; final AnalyticsService? analyticsService; + final IdentityService? identityService; + final LimitsService? limitsService; + final WalletService? walletService; + final PhotoService photoService; final LoginService loginService; final SignupService signupService; final VerificationService verificationService; @@ -41,9 +53,18 @@ class SxchngeApp extends StatefulWidget { class _SxchngeAppState extends State { late final _locale = widget.localeProvider ?? LocaleProvider(); - late final _journeys = JourneyRecorder( - widget.analyticsService ?? DummyAnalyticsService(), - ); + late final _analytics = widget.analyticsService ?? DummyAnalyticsService(); + late final _identity = widget.identityService ?? DummyIdentityService(); + late final _walletService = + widget.walletService ?? + DummyWalletService(_identity, initialLocale: _locale.locale.languageCode); + late final _wallet = WalletProvider(_walletService, _analytics); + late final _limits = + widget.limitsService ?? + (_walletService is DummyWalletService + ? WalletLimitsService(_walletService) + : DummyLimitsService(_identity)); + late final _journeys = JourneyRecorder(_analytics); late final _services = InstrumentedAuthServices( recorder: _journeys, loginService: widget.loginService, @@ -73,6 +94,7 @@ class _SxchngeAppState extends State { _router.routeInformationProvider.removeListener(_routeChanged); _router.dispose(); _journeys.dispose(); + _wallet.dispose(); _locale.dispose(); super.dispose(); } @@ -80,7 +102,12 @@ 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), + Provider.value(value: _limits), + Provider.value(value: widget.photoService), ChangeNotifierProvider.value(value: _locale), Provider.value(value: _services), ChangeNotifierProvider(create: (_) => LoginProvider(_services)), 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 0cfd021..74fc17c 100644 --- a/frontend/app/lib/routing/router.dart +++ b/frontend/app/lib/routing/router.dart @@ -1,37 +1,128 @@ import 'dart:convert'; +import 'package:flutter/widgets.dart'; + import 'package:go_router/go_router.dart'; +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + import '../auth/page.dart'; +import '../kyc/page.dart'; import '../verification/page.dart'; +import '../workspace/accounts.dart'; +import '../workspace/activity.dart'; +import '../workspace/limits.dart'; +import '../workspace/operation.dart'; +import '../workspace/settings.dart'; +import '../workspace/shell.dart'; +import '../workspace/transfer.dart'; import 'routes.dart'; -GoRouter createAppRouter() => GoRouter( - // Signup email is an in-memory navigation extra, never browser-history data. - extraCodec: const _TransientExtras(), - routes: [ - GoRoute( - path: '/', - name: AppRoutes.login, - builder: (context, state) => const AuthPage(), - ), - GoRoute( - path: '/verification-sent', - name: AppRoutes.verificationSent, - builder: (context, state) => VerificationPage( - email: state.extra is String ? state.extra as String : null, +GoRouter createAppRouter() { + final topUpKey = GlobalKey(); + 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: [ + ShellRoute( + builder: (context, state, child) => + WorkspaceShell(path: state.uri.path, child: child), + routes: [ + GoRoute( + path: '/accounts', + builder: (context, state) => const AccountsPage(), + routes: [ + GoRoute( + path: ':accountId', + builder: (context, state) => + AccountsPage(accountId: state.pathParameters['accountId']), + ), + ], + ), + GoRoute( + path: '/activity', + builder: (context, state) => const ActivityPage(), + routes: [ + GoRoute( + path: ':operationId', + builder: (context, state) => + OperationPage(id: state.pathParameters['operationId']!), + ), + ], + ), + GoRoute( + path: '/limits', + builder: (context, state) => const LimitsPage(), + ), + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsPage(), + ), + GoRoute( + path: '/top-up', + builder: (context, state) => TransferPage( + key: topUpKey, + kind: TransferKind.topUp, + source: state.extra is String ? state.extra as String : null, + ), + onExit: (context, state) async => + await topUpKey.currentState?.allowExit() ?? true, + ), + GoRoute( + path: '/exchange', + builder: (context, state) => TransferPage( + key: exchangeKey, + kind: TransferKind.exchange, + source: state.extra is String ? state.extra as String : null, + ), + onExit: (context, state) async => + await exchangeKey.currentState?.allowExit() ?? true, + ), + GoRoute( + path: '/payout', + builder: (context, state) => TransferPage( + key: payoutKey, + kind: TransferKind.payout, + source: state.extra is String ? state.extra as String : null, + ), + onExit: (context, state) async => + await payoutKey.currentState?.allowExit() ?? true, + ), + GoRoute( + path: '/identity', + name: AppRoutes.identity, + builder: (context, state) => IdentityPage(key: identityKey), + onExit: (context, state) async => + await identityKey.currentState?.allowExit() ?? true, + ), + ], ), - ), - GoRoute( - path: '/verify-email', - name: AppRoutes.verifyEmail, - builder: (context, state) => - VerificationPage(token: state.uri.queryParameters['token'] ?? ''), - ), - ], - // Preserve the existing fallback, and keep the visible URL canonical. - onException: (context, state, router) => router.goNamed(AppRoutes.login), -); + GoRoute( + path: '/', + name: AppRoutes.login, + builder: (context, state) => const AuthPage(), + ), + GoRoute( + path: '/verification-sent', + name: AppRoutes.verificationSent, + builder: (context, state) => VerificationPage( + email: state.extra is String ? state.extra as String : null, + ), + ), + GoRoute( + path: '/verify-email', + name: AppRoutes.verifyEmail, + builder: (context, state) => + VerificationPage(token: state.uri.queryParameters['token'] ?? ''), + ), + ], + // Preserve the existing fallback, and keep the visible URL canonical. + onException: (context, state, router) => router.goNamed(AppRoutes.login), + ); +} class _TransientExtras extends Codec { const _TransientExtras(); diff --git a/frontend/app/lib/routing/routes.dart b/frontend/app/lib/routing/routes.dart index c97a4d4..ed32787 100644 --- a/frontend/app/lib/routing/routes.dart +++ b/frontend/app/lib/routing/routes.dart @@ -1,4 +1,5 @@ abstract final class AppRoutes { + static const identity = 'identity'; static const login = 'login'; static const verificationSent = 'verification-sent'; static const verifyEmail = 'verify-email'; diff --git a/frontend/app/lib/verification/page.dart b/frontend/app/lib/verification/page.dart index 718f936..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, ), ), @@ -325,6 +311,13 @@ class _VerificationPageState extends State { child: Text(l.verificationRequest), ), ], + if (verified) ...[ + const SizedBox(height: 16), + FilledButton( + onPressed: () => context.go('/accounts'), + child: Text(l.wEnter), + ), + ], const SizedBox(height: 16), TextButton( onPressed: () => context.goNamed(AppRoutes.login), 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 859f421..7b13355 100644 --- a/frontend/app/pubspec.lock +++ b/frontend/app/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 + url: "https://pub.dev" + source: hosted + version: "4.2.0" async: dependency: transitive description: @@ -41,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" cupertino_icons: dependency: "direct main" description: @@ -81,6 +97,70 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector: + dependency: transitive + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c + url: "https://pub.dev" + source: hosted + version: "0.5.2+11" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c" + url: "https://pub.dev" + source: hosted + version: "0.5.3+6" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 + url: "https://pub.dev" + source: hosted + version: "0.9.5+1" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" flutter: dependency: "direct main" description: flutter @@ -117,6 +197,30 @@ packages: url: "https://pub.dev" source: hosted version: "18.0.1" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" + url: "https://pub.dev" + source: hosted + version: "4.9.2" intl: dependency: "direct main" description: @@ -253,6 +357,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" provider: dependency: "direct main" description: @@ -368,6 +480,13 @@ packages: relative: true source: path version: "0.1.0" + sxchnge_kyc: + dependency: "direct main" + description: + path: "../packages/sxchnge_kyc" + relative: true + source: path + version: "0.1.0" sxchnge_theme: dependency: "direct main" description: @@ -375,6 +494,13 @@ packages: relative: true source: path version: "0.1.0" + sxchnge_wallet: + dependency: "direct main" + description: + path: "../packages/sxchnge_wallet" + relative: true + source: path + version: "0.1.0" term_glyph: dependency: transitive description: @@ -391,6 +517,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: diff --git a/frontend/app/pubspec.yaml b/frontend/app/pubspec.yaml index d02e0ea..f659714 100644 --- a/frontend/app/pubspec.yaml +++ b/frontend/app/pubspec.yaml @@ -6,6 +6,10 @@ environment: sdk: '>=3.13.0 <4.0.0' flutter: '>=3.47.0' dependencies: + sxchnge_wallet: + path: ../packages/sxchnge_wallet + sxchnge_kyc: + path: ../packages/sxchnge_kyc sxchnge_analytics: path: ../packages/sxchnge_analytics flutter: diff --git a/frontend/app/test/kyc_test.dart b/frontend/app/test/kyc_test.dart new file mode 100644 index 0000000..6e2ae9f --- /dev/null +++ b/frontend/app/test/kyc_test.dart @@ -0,0 +1,199 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:sxchnge_analytics/sxchnge_analytics.dart'; + +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +import 'package:sxchnge_app/main.dart'; +import 'package:sxchnge_app/providers/locale.dart'; + +class Photos implements PhotoService { + Photos(this.bytes); + final Uint8List bytes; + KycError? error; + @override + Future pick() async { + if (error != null) throw KycException(error!); + return Photo(bytes); + } +} + +Future tap(WidgetTester t, Finder finder) async { + await t.ensureVisible(finder); + await t.tap(finder); + await t.pumpAndSettle(); +} + +void main() { + late Uint8List bytes; + setUpAll(() async { + final r = ui.PictureRecorder(); + final c = ui.Canvas(r); + c.drawRect(const Rect.fromLTWH(0, 0, 600, 600), Paint()); + final pic = r.endRecording(); + final image = await pic.toImage(600, 600); + bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!.buffer + .asUint8List(); + image.dispose(); + pic.dispose(); + }); + Future open( + WidgetTester t, { + Photos? photos, + Locale? locale, + }) async { + t.platformDispatcher.defaultRouteNameTestValue = '/identity'; + addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue); + final service = DummyIdentityService(delay: Duration.zero); + final provider = LocaleProvider(); + if (locale != null) await provider.setLocale(locale); + await t.pumpWidget( + SxchngeApp( + identityService: service, + photoService: photos ?? Photos(bytes), + localeProvider: provider, + ), + ); + await t.pumpAndSettle(); + return service; + } + + Future submit(WidgetTester t) async { + await tap(t, find.byKey(const ValueKey('kyc-start'))); + await tap(t, find.byKey(const ValueKey('pick-passport'))); + await tap(t, find.byKey(const ValueKey('kyc-next'))); + await tap(t, find.byKey(const ValueKey('pick-selfie'))); + await tap(t, find.byKey(const ValueKey('kyc-next'))); + await tap(t, find.byType(CheckboxListTile)); + await tap(t, find.byKey(const ValueKey('kyc-submit'))); + } + + testWidgets('two photos, review, pending and approval with service limits', ( + t, + ) async { + await open(t); + expect(find.byKey(const ValueKey('kyc-unverified')), findsOneWidget); + await submit(t); + expect(find.byKey(const ValueKey('kyc-pending')), findsOneWidget); + await tap(t, find.byType(ExpansionTile)); + await tap(t, find.text('Approve')); + expect(find.byKey(const ValueKey('kyc-approved')), findsOneWidget); + expect(find.text('Verified'), findsOneWidget); + expect(t.takeException(), null); + }); + testWidgets('denial provides explanation and support, no resubmit', ( + t, + ) async { + await open(t); + await submit(t); + await tap(t, find.byType(ExpansionTile)); + await tap(t, find.text('Decline')); + expect(find.byKey(const ValueKey('kyc-denied')), findsOneWidget); + expect(find.byKey(const ValueKey('kyc-start')), findsNothing); + await tap(t, find.text('Contact support')); + expect(find.byType(AlertDialog), findsOneWidget); + }); + testWidgets('correctable rejection can be resubmitted', (t) async { + await open(t); + await submit(t); + await tap(t, find.byType(ExpansionTile)); + await tap(t, find.text('Request clearer passport')); + expect(find.byKey(const ValueKey('kyc-changesRequested')), findsOneWidget); + await submit(t); + expect(find.byKey(const ValueKey('kyc-pending')), findsOneWidget); + }); + testWidgets('picker error recovery and discard confirmation', (t) async { + final photos = Photos(bytes)..error = KycError.permission; + await open(t, photos: photos); + await tap(t, find.byKey(const ValueKey('kyc-start'))); + await tap(t, find.byKey(const ValueKey('pick-passport'))); + expect(find.byKey(const ValueKey('kyc-error')), findsOneWidget); + photos.error = null; + await tap(t, find.byKey(const ValueKey('pick-passport'))); + await tap(t, find.text('Keep basic limits')); + expect(find.byType(AlertDialog), findsOneWidget); + await tap(t, find.text('Keep editing')); + expect(find.byKey(const ValueKey('pick-passport')), findsOneWidget); + await tap(t, find.text('Keep basic limits')); + await tap(t, find.text('Discard & leave')); + expect(find.byKey(const ValueKey('kyc-unverified')), findsOneWidget); + }); + testWidgets( + '320px Russian at large text works through submission and keyboard', + (t) async { + t.view.physicalSize = const Size(320, 900); + t.view.devicePixelRatio = 1; + t.platformDispatcher.textScaleFactorTestValue = 1.6; + addTearDown(t.view.resetPhysicalSize); + addTearDown(t.view.resetDevicePixelRatio); + addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue); + await open(t, locale: const Locale('ru')); + expect(find.text('Верификация и лимиты'), findsOneWidget); + await t.sendKeyEvent(LogicalKeyboardKey.tab); + await t.pump(); + await submit(t); + expect(find.byKey(const ValueKey('kyc-pending')), findsOneWidget); + expect(t.takeException(), null); + }, + ); + testWidgets('status failure exposes retry without asserting basic state', ( + t, + ) async { + t.platformDispatcher.defaultRouteNameTestValue = '/identity'; + addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue); + final service = DummyIdentityService(delay: Duration.zero) + ..nextError = KycError.unavailable; + await t.pumpWidget( + SxchngeApp( + identityService: service, + photoService: Photos(bytes), + analyticsService: DummyAnalyticsService(), + ), + ); + await t.pumpAndSettle(); + expect(find.byKey(const ValueKey('kyc-unverified')), findsNothing); + await tap(t, find.text('Refresh status & limits')); + expect(find.byKey(const ValueKey('kyc-unverified')), findsOneWidget); + }); + testWidgets('route guard preserves draft when navigation is cancelled', ( + t, + ) async { + await open(t); + await tap(t, find.byKey(const ValueKey('kyc-start'))); + await tap(t, find.byKey(const ValueKey('pick-passport'))); + final router = GoRouter.of(t.element(find.text('Verification & limits'))); + router.go('/'); + await t.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + await tap(t, find.text('Keep editing')); + expect(find.byKey(const ValueKey('pick-passport')), findsOneWidget); + router.go('/'); + await t.pumpAndSettle(); + await tap(t, find.text('Discard & leave')); + expect(find.byKey(const ValueKey('kyc-start')), findsNothing); + expect(find.text('Explore your accounts'), findsOneWidget); + }); + testWidgets('overview controls have accessible labels and touch targets', ( + t, + ) async { + final semantics = t.ensureSemantics(); + await open(t); + expect(t, meetsGuideline(labeledTapTargetGuideline)); + expect(t, meetsGuideline(androidTapTargetGuideline)); + final start = find.byKey(const ValueKey('kyc-start')); + await t.ensureVisible(start); + final buttonContext = t.element(start); + FocusScope.of(buttonContext).requestFocus(Focus.of(buttonContext)); + await t.sendKeyEvent(LogicalKeyboardKey.tab); + await t.pump(); + expect(t.takeException(), null); + semantics.dispose(); + }); +} diff --git a/frontend/app/test/limits_test.dart b/frontend/app/test/limits_test.dart new file mode 100644 index 0000000..cfac36a --- /dev/null +++ b/frontend/app/test/limits_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +import 'package:sxchnge_theme/sxchnge_theme.dart'; + +import 'package:sxchnge_app/kyc/limits.dart'; + +import 'package:sxchnge_app/generated/i18n/app_localizations.dart'; + +class Service implements LimitsService { + Service(this.row); + final VisibleLimit row; + @override + Future fetch() async => + Limits(tier: AccountTier.basic, rows: [row], simulated: true); +} + +void main() { + for (final locale in ['en', 'ru']) { + testWidgets( + 'usage and authoritative available capacity fit narrow $locale layout', + (tester) async { + tester.view.physicalSize = const Size(320, 1800); + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = 1.6; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + final provider = LimitsProvider( + Service( + const VisibleLimit( + period: LimitPeriod.day, + currency: 'EUR', + basicMinor: 10000, + verifiedMinor: 250000, + currentMinor: 10000, + remainingMinor: 5500, + usedMinor: 3000, + reservedMinor: 1000, + ), + ), + ); + addTearDown(provider.dispose); + await provider.refresh(); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + theme: SxchngeTheme.light(), + locale: Locale(locale), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: SingleChildScrollView(child: LimitsPanel()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + find.text( + locale == 'en' ? 'Used this period' : 'Использовано за период', + ), + findsOneWidget, + ); + expect( + find.text(locale == 'en' ? 'Reserved' : 'Зарезервировано'), + findsNothing, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('usage-day')), + ) + .value, + 0.4, + ); + expect(find.textContaining('55'), findsOneWidget); + expect(tester.takeException(), null); + }, + ); + } +} 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 b638a2e..da8aa0f 100644 --- a/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart +++ b/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart @@ -2,11 +2,26 @@ import 'dart:async'; import 'dart:collection'; import 'dart:math'; -enum JourneyKind { login, signup, verification } +enum JourneyKind { login, signup, verification, kyc, topUp, exchange, payout } -enum JourneyStep { login, signup, emailDelivery, emailConfirmation } +enum JourneyStep { + login, + signup, + emailDelivery, + emailConfirmation, + kycPreparation, + kycPassport, + kycSelfie, + kycSubmission, + kycStatus, + transferDraft, + transferQuote, + transferSubmission, + settlement, +} enum JourneySignal { + cancelled, started, requested, succeeded, @@ -16,6 +31,10 @@ enum JourneySignal { } enum JourneyFailure { + insufficientFunds, + limitExceeded, + declined, + document, invalidCredentials, emailInUse, unavailable, @@ -66,8 +85,19 @@ class AnalyticsEvent { final bool terminal; final bool simulated; final DateTime occurredAt; - String get name => - 'auth_${step == null ? 'journey' : _snake(step!.name)}_${signal.name}'; + String get name { + final area = switch (kind) { + JourneyKind.kyc => 'kyc', + JourneyKind.topUp => 'top_up', + JourneyKind.exchange => 'exchange', + JourneyKind.payout => 'payout', + _ => 'auth', + }; + var stage = step == null ? 'journey' : _snake(step!.name); + if (kind == JourneyKind.kyc) stage = stage.replaceFirst('kyc_', ''); + return '${area}_${stage}_${signal.name}'; + } + Map get properties => Map.unmodifiable({ 'schema_version': 1, 'event_id': eventId, @@ -165,16 +195,19 @@ class JourneyRecorder { bool simulated = false, JourneyFailure? failure, bool terminal = false, + bool cancelled = false, }) { final journey = operation._journey; if (_disposed || operation._finished || journey.closed) return; operation._finished = true; journey.pending--; journey.simulated |= simulated; - final complete = failure == null && terminal; + final complete = !cancelled && terminal; _emit( journey, - failure != null + cancelled + ? JourneySignal.cancelled + : failure != null ? JourneySignal.failed : (simulated || (complete && journey.simulated)) ? JourneySignal.simulated @@ -237,8 +270,8 @@ class JourneyRecorder { ); } - void dispose() { - abandon(JourneyExit.disposed); + void dispose({bool abandonActive = true}) { + if (abandonActive) abandon(JourneyExit.disposed); _disposed = true; _idle?.cancel(); } diff --git a/frontend/packages/sxchnge_kyc/.gitignore b/frontend/packages/sxchnge_kyc/.gitignore new file mode 100644 index 0000000..61608ef --- /dev/null +++ b/frontend/packages/sxchnge_kyc/.gitignore @@ -0,0 +1,3 @@ +.dart_tool/ +build/ +pubspec.lock diff --git a/frontend/packages/sxchnge_kyc/README.md b/frontend/packages/sxchnge_kyc/README.md new file mode 100644 index 0000000..afe46bf --- /dev/null +++ b/frontend/packages/sxchnge_kyc/README.md @@ -0,0 +1,43 @@ +# sxchnge_kyc + +Shared Flutter identity verification and backend-owned visible limits. Both web +and future mobile clients consume the same models, providers and service contracts. + +- `IdentityProvider` coordinates draft photos, validation errors, submission, + refresh and semantic analytics. Inject `IdentityService` and `PhotoService`. +- `LimitsProvider` fetches `LimitsService` models; it does not calculate caps from + a client-selected status. Refresh after status updates. Existing values are + marked stale after an error; out-of-order responses cannot replace newer data. +- `DummyIdentityService` keeps only review metadata in memory. `DummyLimitsService` + mimics backend policy with explicitly illustrative EUR fixtures. Neither is + production authorization. `review` exists only on the dummy service. +- `FilePhotoService` opens the platform picker and validates JPEG/PNG content, + 10 MiB size, minimum 600px per dimension, maximum 40 megapixels and decodability. + It performs no face matching, OCR or identity approval. Camera capture is not + implemented; users can take a photo separately and select it. +- Photo models are transient; clients must dispose providers and evict sensitive + image cache entries. No disk, browser storage, network or document telemetry is + used by these stubs. No guarantee of cryptographic memory erasure is made. + +Replace both identity and limits services when connecting a backend. A submit +adapter must reuse the supplied idempotency key across retries, scope requests to +the authenticated account, map safe errors and return server-confirmed status. +Numeric values use minor currency units (currently a two-decimal currency fixture); +extend the transport with currency precision before supporting other units. + +The app's `/identity` route is an explicitly public demo, not an authenticated +account endpoint. Production requires session/email guards, server authorization, +private document storage, staff review and support integration. See +[the UX/measurement contract](../../../docs/ux/kyc.md). + +Validation: `flutter pub get`, `flutter analyze`, `flutter test`. + +Photo selection uses Flutter's [file_selector](https://pub.dev/packages/file_selector) +and [image metadata decoding](https://pub.dev/documentation/image/latest/image/Decoder-class.html). +For native macOS consumers enable the user-selected read-only file entitlement; +iOS/Android camera permissions are not required for this file-picker-only flow. + +Visible limit rows also expose `usedMinor` (committed activity) and `reservedMinor` +(pending holds). `remainingMinor` is independently provided by the backend and +must not be inferred as cap minus those fields. The display-only occupied ratio +includes used plus reserved and is bounded for progress rendering. 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_kyc/lib/src/models/identity.dart b/frontend/packages/sxchnge_kyc/lib/src/models/identity.dart new file mode 100644 index 0000000..2c90d64 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/lib/src/models/identity.dart @@ -0,0 +1,54 @@ +import 'dart:typed_data'; + +enum IdentityStatus { unverified, pending, approved, changesRequested, denied } + +enum ReviewReason { + passportUnreadable, + selfieUnreadable, + passportExpired, + unableToVerify, +} + +enum PhotoKind { passport, selfie } + +enum KycError { + unavailable, + internal, + permission, + format, + tooLarge, + unreadable, + tooSmall, + incomplete, + sessionExpired, + rateLimited, + conflict, +} + +class KycException implements Exception { + const KycException(this.code); + final KycError code; +} + +class Identity { + const Identity({ + required this.status, + this.reason, + this.submissionId, + this.simulated = false, + }); + final IdentityStatus status; + final ReviewReason? reason; + final String? submissionId; + final bool simulated; + bool get canSubmit => + status == IdentityStatus.unverified || + status == IdentityStatus.changesRequested; +} + +/// Ephemeral image data only. Never serialize this model into storage or telemetry. +class Photo { + Photo(Uint8List bytes) + : bytes = Uint8List.fromList(bytes).asUnmodifiableView(); + final Uint8List bytes; +} diff --git a/frontend/packages/sxchnge_kyc/lib/src/models/limits.dart b/frontend/packages/sxchnge_kyc/lib/src/models/limits.dart new file mode 100644 index 0000000..476e028 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/lib/src/models/limits.dart @@ -0,0 +1,40 @@ +enum AccountTier { basic, verified } + +enum LimitPeriod { day, month } + +class VisibleLimit { + const VisibleLimit({ + required this.period, + required this.currency, + required this.basicMinor, + required this.verifiedMinor, + required this.currentMinor, + required this.remainingMinor, + required this.usedMinor, + required this.reservedMinor, + }); + final LimitPeriod period; + final String currency; + final int basicMinor; + final int verifiedMinor; + final int currentMinor; + final int remainingMinor; + final int usedMinor; + final int reservedMinor; + + /// Presentation only: remaining capacity and payment authorization stay server-owned. + double get occupiedFraction => currentMinor <= 0 + ? 0 + : ((usedMinor + reservedMinor) / currentMinor).clamp(0.0, 1.0); +} + +class Limits { + Limits({ + required this.tier, + required List rows, + required this.simulated, + }) : rows = List.unmodifiable(rows); + final AccountTier tier; + final List rows; + final bool simulated; +} diff --git a/frontend/packages/sxchnge_kyc/lib/src/providers/identity.dart b/frontend/packages/sxchnge_kyc/lib/src/providers/identity.dart new file mode 100644 index 0000000..6aa1508 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/lib/src/providers/identity.dart @@ -0,0 +1,235 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; + +import 'package:sxchnge_analytics/sxchnge_analytics.dart'; + +import '../models/identity.dart'; +import '../services/contracts.dart'; +import '../services/dummy.dart'; + +class IdentityProvider extends ChangeNotifier { + IdentityProvider( + this.service, + this.photos, + this.journeys, { + this.onStatusChanged, + }); + final Future Function()? onStatusChanged; + final IdentityService service; + final PhotoService photos; + final JourneyRecorder journeys; + Identity? record; + Photo? passport; + Photo? selfie; + KycError? error; + PhotoKind? photoError; + bool busy = false; + bool stale = false; + bool editing = false; + bool confirmed = false; + int step = 0; + bool _disposed = false; + String? _key; + bool get hasDraft => passport != null || selfie != null; + bool get canSubmit => + !busy && + !stale && + confirmed && + passport != null && + selfie != null && + (record?.canSubmit ?? false); + bool get isDemo => service.isSimulated; + void _notify() { + if (!_disposed) notifyListeners(); + } + + JourneyOperation _begin(JourneyStep step) => + journeys.begin(JourneyKind.kyc, step, simulated: isDemo); + Future refresh() async { + if (busy) return; + busy = true; + error = null; + photoError = null; + _notify(); + final op = _begin(JourneyStep.kycStatus); + try { + final result = await service.fetch(); + if (_disposed) return; + record = result; + stale = false; + await onStatusChanged?.call(); + if (_disposed) return; + if (!result.canSubmit) { + _clear(); + editing = false; + } + journeys.finish(op, simulated: isDemo, terminal: true); + } catch (e) { + if (_disposed) return; + stale = true; + error = _error(e); + journeys.finish(op, failure: JourneyFailure.unavailable, terminal: false); + } + busy = false; + _notify(); + } + + void start() { + if (busy || stale || !(record?.canSubmit ?? false)) return; + editing = true; + error = null; + if (!hasDraft) { + step = 0; + confirmed = false; + } + final op = _begin(JourneyStep.kycPreparation); + journeys.finish(op, simulated: isDemo); + _notify(); + } + + Future pick(PhotoKind kind) async { + if (busy || !editing) return; + busy = true; + error = null; + photoError = null; + _notify(); + final op = _begin( + kind == PhotoKind.passport + ? JourneyStep.kycPassport + : JourneyStep.kycSelfie, + ); + try { + final photo = await photos.pick(); + if (_disposed) return; + if (photo != null) { + if (kind == PhotoKind.passport) { + passport = photo; + } else { + selfie = photo; + } + confirmed = false; + _key = null; + } + journeys.finish(op, simulated: isDemo, cancelled: photo == null); + } catch (e) { + if (_disposed) return; + error = _error(e); + photoError = kind; + journeys.finish(op, failure: JourneyFailure.document); + } + busy = false; + _notify(); + } + + void remove(PhotoKind kind) { + if (busy) return; + if (kind == PhotoKind.passport) { + passport = null; + } else { + selfie = null; + } + confirmed = false; + _key = null; + _notify(); + } + + void back() { + if (!busy && step > 0) { + step--; + error = null; + _notify(); + } + } + + void next() { + if (busy || + (step == 0 && passport == null) || + (step == 1 && selfie == null)) + return; + if (step < 2) step++; + error = null; + _notify(); + } + + void confirm(bool value) { + if (!busy) { + confirmed = value; + _notify(); + } + } + + Future submit() async { + if (!canSubmit) return; + busy = true; + error = null; + photoError = null; + _notify(); + _key ??= List.generate( + 16, + (_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + final op = _begin(JourneyStep.kycSubmission); + try { + final result = await service.submit( + passport: passport!, + selfie: selfie!, + idempotencyKey: _key!, + ); + if (_disposed) return; + if (result.canSubmit) throw const KycException(KycError.conflict); + record = result; + editing = false; + _clear(); + await onStatusChanged?.call(); + if (_disposed) return; + journeys.finish(op, simulated: isDemo, terminal: true); + } catch (e) { + if (_disposed) return; + error = _error(e); + if (error == KycError.conflict || error == KycError.sessionExpired) + stale = true; + journeys.finish(op, failure: JourneyFailure.unavailable); + } + busy = false; + _notify(); + } + + Future demoReview(IdentityStatus status, {ReviewReason? reason}) async { + if (busy || service is! DummyIdentityService) return; + try { + (service as DummyIdentityService).review(status, reason: reason); + } on KycException catch (e) { + error = e.code; + _notify(); + return; + } + await refresh(); + } + + void exit() { + if (busy) return; + journeys.abandon(JourneyExit.navigation); + _clear(); + editing = false; + error = null; + _notify(); + } + + void _clear() { + passport = null; + selfie = null; + confirmed = false; + _key = null; + step = 0; + } + + KycError _error(Object e) => e is KycException ? e.code : KycError.internal; + @override + void dispose() { + _disposed = true; + _clear(); + journeys.dispose(); + super.dispose(); + } +} diff --git a/frontend/packages/sxchnge_kyc/lib/src/providers/limits.dart b/frontend/packages/sxchnge_kyc/lib/src/providers/limits.dart new file mode 100644 index 0000000..6fa7e26 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/lib/src/providers/limits.dart @@ -0,0 +1,37 @@ +import 'package:flutter/foundation.dart'; + +import '../models/limits.dart'; +import '../services/contracts.dart'; + +class LimitsProvider extends ChangeNotifier { + LimitsProvider(this.service); + final LimitsService service; + Limits? data; + bool loading = false; + bool failed = false; + int _revision = 0; + bool _disposed = false; + Future refresh() async { + final revision = ++_revision; + loading = true; + failed = false; + notifyListeners(); + try { + final result = await service.fetch(); + if (_disposed || revision != _revision) return; + data = result; + } catch (_) { + if (_disposed || revision != _revision) return; + failed = true; + } + if (_disposed || revision != _revision) return; + loading = false; + notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/frontend/packages/sxchnge_kyc/lib/src/services/contracts.dart b/frontend/packages/sxchnge_kyc/lib/src/services/contracts.dart new file mode 100644 index 0000000..2a8b782 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/lib/src/services/contracts.dart @@ -0,0 +1,24 @@ +import '../models/identity.dart'; +import '../models/limits.dart'; + +abstract interface class IdentityService { + bool get isSimulated; + Future fetch(); + + /// Backend adapters must scope the key to the authenticated account and reject + /// stale/duplicate review transitions. Retrying the same key is idempotent. + Future submit({ + required Photo passport, + required Photo selfie, + required String idempotencyKey, + }); +} + +abstract interface class LimitsService { + /// Fetch server-owned visible limits. No client-selected KYC status or tier. + Future fetch(); +} + +abstract interface class PhotoService { + Future pick(); +} diff --git a/frontend/packages/sxchnge_kyc/lib/src/services/dummy.dart b/frontend/packages/sxchnge_kyc/lib/src/services/dummy.dart new file mode 100644 index 0000000..6b9a3ba --- /dev/null +++ b/frontend/packages/sxchnge_kyc/lib/src/services/dummy.dart @@ -0,0 +1,97 @@ +import '../models/identity.dart'; +import '../models/limits.dart'; +import '../transport/contracts.dart'; +import 'contracts.dart'; + +/// Process-local backend stand-in. It never stores or sends document bytes. +class DummyIdentityService implements IdentityService { + DummyIdentityService({this.delay = const Duration(milliseconds: 450)}); + final Duration delay; + IdentityDTO _record = const IdentityDTO('unverified'); + String? _lastKey; + KycError? nextError; + @override + bool get isSimulated => true; + Future _wait() async { + await Future.delayed(delay); + final error = nextError; + nextError = null; + if (error != null) throw KycException(error); + } + + @override + Future fetch() async { + await _wait(); + return IdentityMapper.fromDTO(_record); + } + + @override + Future submit({ + required Photo passport, + required Photo selfie, + required String idempotencyKey, + }) async { + await _wait(); + if (_lastKey == idempotencyKey) return IdentityMapper.fromDTO(_record); + if (!IdentityMapper.fromDTO(_record).canSubmit) + throw const KycException(KycError.conflict); + if (passport.bytes.isEmpty || selfie.bytes.isEmpty) + throw const KycException(KycError.incomplete); + _lastKey = idempotencyKey; + _record = IdentityDTO('pending', id: idempotencyKey); + return IdentityMapper.fromDTO(_record); + } + + /// Explicit demo-only human decision stand-in; unavailable in real adapters. + void review(IdentityStatus status, {ReviewReason? reason}) { + if (_record.status != 'pending') + throw const KycException(KycError.conflict); + if (![ + IdentityStatus.approved, + IdentityStatus.changesRequested, + IdentityStatus.denied, + ].contains(status)) + throw const KycException(KycError.conflict); + _record = IdentityDTO(status.name, id: _record.id, reason: reason?.name); + } +} + +/// Mimics backend policy evaluation. Widgets/providers never compute active caps. +/// Values are illustrative fixtures, not the product's real limits policy. +class DummyLimitsService implements LimitsService { + DummyLimitsService(this.identity); + final IdentityService identity; + @override + Future fetch() async { + final record = await identity.fetch(); + final approved = record.status == IdentityStatus.approved; + return LimitsMapper.fromDTO( + LimitsDTO( + tier: approved ? 'verified' : 'basic', + simulated: true, + rows: [ + LimitDTO( + 'day', + 'EUR', + 10000, + 250000, + approved ? 250000 : 10000, + (approved ? 250000 : 10000) - 4000, + 3000, + 1000, + ), + LimitDTO( + 'month', + 'EUR', + 50000, + 1000000, + approved ? 1000000 : 50000, + (approved ? 1000000 : 50000) - 19000, + 18000, + 1000, + ), + ], + ), + ); + } +} diff --git a/frontend/packages/sxchnge_kyc/lib/src/services/photos.dart b/frontend/packages/sxchnge_kyc/lib/src/services/photos.dart new file mode 100644 index 0000000..cc6f2f9 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/lib/src/services/photos.dart @@ -0,0 +1,81 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:file_selector/file_selector.dart'; + +import 'package:image/image.dart' as metadata; + +import '../models/identity.dart'; +import 'contracts.dart'; + +class FilePhotoService implements PhotoService { + const FilePhotoService(); + static const maxBytes = 10 * 1024 * 1024; + @override + Future pick() async { + try { + final file = await openFile( + acceptedTypeGroups: const [ + XTypeGroup( + label: 'JPEG / PNG', + extensions: ['jpg', 'jpeg', 'png'], + mimeTypes: ['image/jpeg', 'image/png'], + uniformTypeIdentifiers: ['public.jpeg', 'public.png'], + ), + ], + ); + if (file == null) return null; + if (await file.length() > maxBytes) + throw const KycException(KycError.tooLarge); + final bytes = await file.readAsBytes(); + return await validate(bytes); + } on KycException { + rethrow; + } catch (_) { + throw const KycException(KycError.permission); + } + } + + static Future validate(Uint8List bytes) async { + try { + if (bytes.length > maxBytes) throw const KycException(KycError.tooLarge); + final png = + bytes.length > 8 && + bytes[0] == 137 && + bytes[1] == 80 && + bytes[2] == 78 && + bytes[3] == 71; + final jpg = + bytes.length > 3 && + bytes[0] == 255 && + bytes[1] == 216 && + bytes[2] == 255; + if (!png && !jpg) throw const KycException(KycError.format); + final metadata.Decoder decoder = png + ? metadata.PngDecoder() + : metadata.JpegDecoder(); + final info = decoder.startDecode(bytes); + if (info == null) throw const KycException(KycError.unreadable); + if (info.width < 600 || info.height < 600) + throw const KycException(KycError.tooSmall); + if (info.width * info.height > 40000000) + throw const KycException(KycError.tooLarge); + final codec = await ui.instantiateImageCodec( + bytes, + targetWidth: 1000, + allowUpscaling: false, + ); + try { + final frame = await codec.getNextFrame(); + frame.image.dispose(); + } finally { + codec.dispose(); + } + return Photo(bytes); + } on KycException { + rethrow; + } catch (_) { + throw const KycException(KycError.unreadable); + } + } +} diff --git a/frontend/packages/sxchnge_kyc/lib/src/transport/contracts.dart b/frontend/packages/sxchnge_kyc/lib/src/transport/contracts.dart new file mode 100644 index 0000000..04bde34 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/lib/src/transport/contracts.dart @@ -0,0 +1,68 @@ +import '../models/identity.dart'; +import '../models/limits.dart'; + +// Transport contracts for the stub. Replace with generated backend DTOs later. +class IdentityDTO { + const IdentityDTO(this.status, {this.reason, this.id, this.simulated = true}); + final String status; + final String? reason; + final String? id; + final bool simulated; +} + +class IdentityMapper { + static Identity fromDTO(IdentityDTO dto) => Identity( + status: IdentityStatus.values.byName(dto.status), + reason: dto.reason == null ? null : ReviewReason.values.byName(dto.reason!), + submissionId: dto.id, + simulated: dto.simulated, + ); +} + +class LimitsDTO { + const LimitsDTO({ + required this.tier, + required this.rows, + required this.simulated, + }); + final String tier; + final List rows; + final bool simulated; +} + +class LimitDTO { + const LimitDTO( + this.period, + this.currency, + this.basic, + this.verified, + this.current, + this.remaining, + this.used, + this.reserved, + ); + final String period; + final String currency; + final int basic, verified, current, remaining, used, reserved; +} + +class LimitsMapper { + static Limits fromDTO(LimitsDTO dto) => Limits( + tier: AccountTier.values.byName(dto.tier), + simulated: dto.simulated, + rows: dto.rows + .map( + (r) => VisibleLimit( + period: LimitPeriod.values.byName(r.period), + currency: r.currency, + basicMinor: r.basic, + verifiedMinor: r.verified, + currentMinor: r.current, + remainingMinor: r.remaining, + usedMinor: r.used, + reservedMinor: r.reserved, + ), + ) + .toList(), + ); +} diff --git a/frontend/packages/sxchnge_kyc/lib/sxchnge_kyc.dart b/frontend/packages/sxchnge_kyc/lib/sxchnge_kyc.dart new file mode 100644 index 0000000..dc1e5e8 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/lib/sxchnge_kyc.dart @@ -0,0 +1,7 @@ +export 'src/models/identity.dart'; +export 'src/models/limits.dart'; +export 'src/providers/identity.dart'; +export 'src/providers/limits.dart'; +export 'src/services/contracts.dart'; +export 'src/services/dummy.dart'; +export 'src/services/photos.dart'; diff --git a/frontend/packages/sxchnge_kyc/pubspec.yaml b/frontend/packages/sxchnge_kyc/pubspec.yaml new file mode 100644 index 0000000..76cdf68 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/pubspec.yaml @@ -0,0 +1,17 @@ +name: sxchnge_kyc +description: Shared identity verification and visible limits for SXCHNGE clients. +version: 0.1.0 +publish_to: none +environment: + sdk: '>=3.13.0 <4.0.0' + flutter: '>=3.47.0' +dependencies: + flutter: + sdk: flutter + image: ^4.9.0 + file_selector: ^1.1.0 + sxchnge_analytics: + path: ../sxchnge_analytics +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/frontend/packages/sxchnge_kyc/test/kyc_test.dart b/frontend/packages/sxchnge_kyc/test/kyc_test.dart new file mode 100644 index 0000000..5af1ea8 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/test/kyc_test.dart @@ -0,0 +1,263 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sxchnge_analytics/sxchnge_analytics.dart'; + +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +class Photos implements PhotoService { + Photo? result = Photo(Uint8List.fromList([1, 2, 3])); + KycError? error; + Completer? pending; + @override + Future pick() async { + if (error != null) throw KycException(error!); + return pending == null ? result : pending!.future; + } +} + +class LimitStub implements LimitsService { + final requests = >[]; + @override + Future fetch() { + final c = Completer(); + requests.add(c); + return c.future; + } +} + +class BrokenAnalytics implements AnalyticsService { + @override + Future capture(AnalyticsEvent event) => throw StateError('offline'); +} + +Future draft(IdentityProvider p) async { + await p.refresh(); + p.start(); + await p.pick(PhotoKind.passport); + p.next(); + await p.pick(PhotoKind.selfie); + p.next(); + p.confirm(true); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late DummyIdentityService service; + late Photos photos; + late DummyAnalyticsService sink; + late IdentityProvider p; + setUp(() { + service = DummyIdentityService(delay: Duration.zero); + photos = Photos(); + sink = DummyAnalyticsService(); + p = IdentityProvider(service, photos, JourneyRecorder(sink)); + }); + tearDown(() => p.dispose()); + test( + 'unverified, pending, correction and denial retain basic service limits', + () async { + final limits = LimitsProvider(DummyLimitsService(service)); + addTearDown(limits.dispose); + await limits.refresh(); + expect(limits.data!.tier, AccountTier.basic); + await draft(p); + await p.submit(); + expect(p.record!.status, IdentityStatus.pending); + expect(p.hasDraft, false); + await limits.refresh(); + expect(limits.data!.tier, AccountTier.basic); + await p.demoReview( + IdentityStatus.changesRequested, + reason: ReviewReason.passportExpired, + ); + await limits.refresh(); + expect(limits.data!.tier, AccountTier.basic); + await draft(p); + await p.submit(); + await p.demoReview( + IdentityStatus.denied, + reason: ReviewReason.unableToVerify, + ); + p.start(); + expect(p.editing, false); + await limits.refresh(); + expect(limits.data!.tier, AccountTier.basic); + }, + ); + test('approval refreshes backend limits through callback', () async { + final limits = LimitsProvider(DummyLimitsService(service)); + addTearDown(limits.dispose); + final subject = IdentityProvider( + service, + photos, + JourneyRecorder(sink), + onStatusChanged: limits.refresh, + ); + addTearDown(subject.dispose); + await draft(subject); + final before = limits.data!.rows.first.currentMinor; + await subject.submit(); + await subject.demoReview(IdentityStatus.approved); + expect(limits.data!.tier, AccountTier.verified); + expect(limits.data!.rows.first.currentMinor, greaterThan(before)); + }); + test('cancel and invalid replacement preserve photo', () async { + await draft(p); + p.back(); + final original = p.selfie; + photos.result = null; + await p.pick(PhotoKind.selfie); + expect(p.selfie, same(original)); + expect(p.error, null); + photos.error = KycError.tooLarge; + await p.pick(PhotoKind.selfie); + expect(p.selfie, same(original)); + expect(p.error, KycError.tooLarge); + expect(p.step, 1); + await Future.delayed(Duration.zero); + expect(sink.events.any((e) => e.signal == JourneySignal.cancelled), true); + }); + test( + 'submit retry preserves photos and counts one terminal simulated outcome', + () async { + await draft(p); + service.nextError = KycError.unavailable; + await p.submit(); + expect(p.hasDraft, true); + expect(p.canSubmit, true); + await p.submit(); + expect(p.record!.status, IdentityStatus.pending); + expect(p.hasDraft, false); + await Future.delayed(Duration.zero); + final events = sink.events.where( + (e) => e.step == JourneyStep.kycSubmission, + ); + expect(events.where((e) => e.terminal).length, 1); + expect(events.map((e) => e.journeyId).toSet().length, 1); + expect(events.every((e) => e.simulated), true); + expect( + sink.events + .expand((e) => e.properties.keys) + .any( + (k) => [ + 'email', + 'filename', + 'bytes', + 'passport', + 'token', + ].contains(k), + ), + false, + ); + }, + ); + test('duplicate submit suppressed and service idempotency replays', () async { + await draft(p); + await Future.wait([p.submit(), p.submit()]); + await Future.delayed(Duration.zero); + expect( + sink.events + .where( + (e) => + e.step == JourneyStep.kycSubmission && + e.signal == JourneySignal.requested, + ) + .length, + 1, + ); + final replay = await service.submit( + passport: photos.result!, + selfie: photos.result!, + idempotencyKey: p.record!.submissionId!, + ); + expect(replay.submissionId, p.record!.submissionId); + }); + test('status error does not invent a tier or allow submission', () async { + service.nextError = KycError.unavailable; + await p.refresh(); + expect(p.record, null); + expect(p.stale, true); + p.start(); + expect(p.editing, false); + await p.refresh(); + p.start(); + expect(p.editing, true); + }); + test( + 'limits ignore late response and mark retained values stale on error', + () async { + final s = LimitStub(); + final limits = LimitsProvider(s); + addTearDown(limits.dispose); + final old = limits.refresh(); + final recent = limits.refresh(); + s.requests[1].complete( + Limits(tier: AccountTier.verified, rows: [], simulated: true), + ); + await recent; + s.requests[0].complete( + Limits(tier: AccountTier.basic, rows: [], simulated: true), + ); + await old; + expect(limits.data!.tier, AccountTier.verified); + final failure = limits.refresh(); + s.requests[2].completeError(StateError('offline')); + await failure; + expect(limits.failed, true); + expect(limits.data!.tier, AccountTier.verified); + }, + ); + test( + 'exit discards images; analytics errors cannot block submission', + () async { + await draft(p); + p.exit(); + expect(p.hasDraft, false); + await Future.delayed(Duration.zero); + expect(sink.events.any((e) => e.signal == JourneySignal.abandoned), true); + final subject = IdentityProvider( + service, + photos, + JourneyRecorder(BrokenAnalytics()), + ); + addTearDown(subject.dispose); + await draft(subject); + await subject.submit(); + expect(subject.record!.status, IdentityStatus.pending); + }, + ); + test('late picker does not repopulate disposed state', () async { + final subject = IdentityProvider(service, photos, JourneyRecorder(sink)); + await subject.refresh(); + subject.start(); + photos.pending = Completer(); + final pending = subject.pick(PhotoKind.passport); + subject.dispose(); + photos.pending!.complete(photos.result); + await pending; + expect(subject.hasDraft, false); + }); + test('validates bytes, signature, corrupt encoding and resolution', () async { + Future rejects(Uint8List bytes, KycError code) async => expectLater( + FilePhotoService.validate(bytes), + throwsA(isA().having((e) => e.code, 'code', code)), + ); + await rejects(Uint8List(FilePhotoService.maxBytes + 1), KycError.tooLarge); + await rejects(Uint8List.fromList([1, 2, 3]), KycError.format); + await rejects(Uint8List.fromList([255, 216, 255, 0]), KycError.unreadable); + final r = ui.PictureRecorder(); + ui.Canvas(r); + final pic = r.endRecording(); + final image = await pic.toImage(20, 20); + final bytes = (await image.toByteData(format: ui.ImageByteFormat.png))! + .buffer + .asUint8List(); + image.dispose(); + pic.dispose(); + await rejects(bytes, KycError.tooSmall); + }); +} diff --git a/frontend/packages/sxchnge_kyc/test/limits_test.dart b/frontend/packages/sxchnge_kyc/test/limits_test.dart new file mode 100644 index 0000000..4ec6978 --- /dev/null +++ b/frontend/packages/sxchnge_kyc/test/limits_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; +import 'package:sxchnge_kyc/src/transport/contracts.dart'; + +void main() { + test('mapping preserves backend usage and independent remaining value', () { + final data = LimitsMapper.fromDTO( + const LimitsDTO( + tier: 'basic', + simulated: false, + rows: [LimitDTO('day', 'EUR', 10000, 250000, 10000, 5500, 3000, 1000)], + ), + ); + final row = data.rows.single; + expect(row.usedMinor, 3000); + expect(row.reservedMinor, 1000); + expect(row.remainingMinor, 5500); + expect(row.occupiedFraction, 0.4); + }); + test('capacity visualization handles unused, exhausted and zero caps', () { + VisibleLimit row(int cap, int used, int reserved) => VisibleLimit( + period: LimitPeriod.day, + currency: 'EUR', + basicMinor: cap, + verifiedMinor: cap, + currentMinor: cap, + remainingMinor: 0, + usedMinor: used, + reservedMinor: reserved, + ); + expect(row(10000, 0, 0).occupiedFraction, 0); + expect(row(10000, 8000, 2000).occupiedFraction, 1); + expect(row(10000, 12000, 2000).occupiedFraction, 1); + expect(row(0, 0, 0).occupiedFraction, 0); + }); + test('stub provides nonzero daily and monthly usage', () async { + final identity = DummyIdentityService(delay: Duration.zero); + final service = DummyLimitsService(identity); + final data = await service.fetch(); + expect(data.rows.first.usedMinor, 3000); + expect(data.rows.first.reservedMinor, 1000); + expect(data.rows.first.remainingMinor, 6000); + expect(data.rows.last.usedMinor, 18000); + expect(data.rows.last.remainingMinor, 31000); + }); +} 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, + ); + }, + ); +}