From 10a278c65a4e6f265bf94a8cad0194061327ce8f Mon Sep 17 00:00:00 2001 From: Stephan D Date: Fri, 11 Sep 2026 15:37:07 +0200 Subject: [PATCH] Deliver localized auth app with verification and go_router Integrate themed login/signup, EN/RU localization, shared dummy providers, verification recovery screens and named URL routes. Store frontend guidelines, workflow skills, validation and delivery documentation. Related to #2, #3, #5 and #6. --- .agents/skills/manage-gitea-task/SKILL.md | 72 +++ .../skills/review-frontend-change/SKILL.md | 56 ++ AGENTS.md | 123 +++- Makefile | 7 + README.md | 41 +- docs/implementation.md | 71 +++ frontend/AGENTS.md | 114 ++++ frontend/app/.gitignore | 48 ++ frontend/app/.metadata | 30 + frontend/app/README.md | 64 +++ frontend/app/analysis_options.yaml | 33 ++ frontend/app/l10n.yaml | 5 + frontend/app/lib/auth/brand_panel.dart | 158 ++++++ frontend/app/lib/auth/feedback.dart | 49 ++ frontend/app/lib/auth/form.dart | 330 +++++++++++ frontend/app/lib/auth/page.dart | 88 +++ frontend/app/lib/auth/validation.dart | 35 ++ .../lib/generated/i18n/app_localizations.dart | 530 ++++++++++++++++++ .../generated/i18n/app_localizations_en.dart | 222 ++++++++ .../generated/i18n/app_localizations_ru.dart | 223 ++++++++ frontend/app/lib/l10n/en.arb | 69 +++ frontend/app/lib/l10n/ru.arb | 69 +++ frontend/app/lib/main.dart | 71 +++ frontend/app/lib/providers/locale.dart | 50 ++ frontend/app/lib/routing/router.dart | 48 ++ frontend/app/lib/routing/routes.dart | 5 + frontend/app/lib/verification/page.dart | 279 +++++++++ frontend/app/lib/widgets/locale.dart | 54 ++ frontend/app/pubspec.lock | 421 ++++++++++++++ frontend/app/pubspec.yaml | 28 + frontend/app/test/auth_services_test.dart | 104 ++++ frontend/app/test/auth_test.dart | 160 ++++++ frontend/app/test/localization_test.dart | 138 +++++ frontend/app/test/routing_test.dart | 157 ++++++ frontend/app/test/verification_test.dart | 136 +++++ frontend/app/untranslated.txt | 1 + frontend/app/web/favicon.png | Bin 0 -> 917 bytes frontend/app/web/favicon.svg | 1 + frontend/app/web/icons/Icon-192.png | Bin 0 -> 5292 bytes frontend/app/web/icons/Icon-512.png | Bin 0 -> 8252 bytes frontend/app/web/icons/Icon-maskable-192.png | Bin 0 -> 5594 bytes frontend/app/web/icons/Icon-maskable-512.png | Bin 0 -> 20998 bytes frontend/app/web/index.html | 47 ++ frontend/app/web/manifest.json | 19 + 44 files changed, 4128 insertions(+), 28 deletions(-) create mode 100644 .agents/skills/manage-gitea-task/SKILL.md create mode 100644 .agents/skills/review-frontend-change/SKILL.md create mode 100644 Makefile create mode 100644 docs/implementation.md create mode 100644 frontend/AGENTS.md create mode 100644 frontend/app/.gitignore create mode 100644 frontend/app/.metadata create mode 100644 frontend/app/README.md create mode 100644 frontend/app/analysis_options.yaml create mode 100644 frontend/app/l10n.yaml create mode 100644 frontend/app/lib/auth/brand_panel.dart create mode 100644 frontend/app/lib/auth/feedback.dart create mode 100644 frontend/app/lib/auth/form.dart create mode 100644 frontend/app/lib/auth/page.dart create mode 100644 frontend/app/lib/auth/validation.dart create mode 100644 frontend/app/lib/generated/i18n/app_localizations.dart create mode 100644 frontend/app/lib/generated/i18n/app_localizations_en.dart create mode 100644 frontend/app/lib/generated/i18n/app_localizations_ru.dart create mode 100644 frontend/app/lib/l10n/en.arb create mode 100644 frontend/app/lib/l10n/ru.arb create mode 100644 frontend/app/lib/main.dart create mode 100644 frontend/app/lib/providers/locale.dart create mode 100644 frontend/app/lib/routing/router.dart create mode 100644 frontend/app/lib/routing/routes.dart create mode 100644 frontend/app/lib/verification/page.dart create mode 100644 frontend/app/lib/widgets/locale.dart create mode 100644 frontend/app/pubspec.lock create mode 100644 frontend/app/pubspec.yaml create mode 100644 frontend/app/test/auth_services_test.dart create mode 100644 frontend/app/test/auth_test.dart create mode 100644 frontend/app/test/localization_test.dart create mode 100644 frontend/app/test/routing_test.dart create mode 100644 frontend/app/test/verification_test.dart create mode 100644 frontend/app/untranslated.txt create mode 100644 frontend/app/web/favicon.png create mode 100644 frontend/app/web/favicon.svg create mode 100644 frontend/app/web/icons/Icon-192.png create mode 100644 frontend/app/web/icons/Icon-512.png create mode 100644 frontend/app/web/icons/Icon-maskable-192.png create mode 100644 frontend/app/web/icons/Icon-maskable-512.png create mode 100644 frontend/app/web/index.html create mode 100644 frontend/app/web/manifest.json diff --git a/.agents/skills/manage-gitea-task/SKILL.md b/.agents/skills/manage-gitea-task/SKILL.md new file mode 100644 index 0000000..099cbfe --- /dev/null +++ b/.agents/skills/manage-gitea-task/SKILL.md @@ -0,0 +1,72 @@ +--- +name: manage-gitea-task +description: Create, update, or prepare Sendico Gitea tasks and implementation branches with the required body, metadata, branch naming, issue association, and verification. Use when Codex creates a Gitea issue or task, corrects its labels, assignee, or project, or begins code work that requires a task branch. +--- + +# Manage Gitea Task + +## Establish scope + +1. Read the repository root `AGENTS.md`. +2. Resolve the target owner and repository from authoritative repository + context. Do not infer ambiguous values. +3. Determine whether the request only manages a task or also starts code work. +4. Before any tracker mutation, resolve the required existing labels, assignee, + and project. Ask the user for any value that authoritative data cannot + establish. + +## Create or update the task + +1. Use a concise, outcome-oriented title. +2. Write a self-contained body with these exact sections: + + - `### Goal`: state the concrete outcome, why it is needed, and its value. + - `### Approach`: describe the intended direction, likely components, known + constraints, and boundaries without inventing decisions. + - `### Acceptance criteria`: provide a checklist of observable, testable + completion outcomes, including relevant edge cases and validation. + - `### Notes`: record contracts, compatibility, migrations, dependencies, + rollout, configuration, security, observability, risks, open questions, + exclusions, and authoritative links when relevant. Write + `None identified` when no significant notes apply. + +3. Prefer the configured Gitea connector for issue operations. If it cannot + assign a project or attach a branch, use the authenticated Gitea REST API. +4. Resolve the project ID from authoritative Gitea data. If no project-list + endpoint is available, inspect the `projects` fields on existing issues in + the required project. Never infer an ID from its name. +5. When creating an issue through + `POST /api/v1/repos///issues`, include `labels`, `assignees`, and + `projects: []` in the request. +6. When correcting project assignment through + `PATCH /api/v1/repos///issues/`, send the complete + intended `projects` list because it replaces the existing assignment. +7. Verify the response contains the intended project, labels, assignee, title, + and body before proceeding. + +Never print, log, store in repository files, or expose API tokens or Git +credentials. + +## Prepare a code-change branch + +Skip this section when the task does not require repository changes. + +1. Resolve the numeric task number and the specific primary module before + changing code. +2. Form the short branch name as `-` with a lowercase + kebab-case module. Do not use `codex/`, `#`, task text, or an avoidably generic + module name. +3. Create or select the branch only after the task exists. +4. Push the branch to the Gitea repository before changing repository files. +5. Attach the branch by setting the issue `ref` to the exact short name. Prefer + the connector; otherwise send `{"ref": "-"}` through + the issue PATCH endpoint. +6. Verify the response contains the exact short `ref` and the required project, + labels, and assignee. Stop before code changes if any value is empty or + mismatched. + +## Report the result + +Report the task number and URL, resolved metadata, and attached branch when +applicable. Identify any unresolved metadata or external blocker precisely; do +not claim the task is ready when verification did not succeed. diff --git a/.agents/skills/review-frontend-change/SKILL.md b/.agents/skills/review-frontend-change/SKILL.md new file mode 100644 index 0000000..6cee547 --- /dev/null +++ b/.agents/skills/review-frontend-change/SKILL.md @@ -0,0 +1,56 @@ +--- +name: review-frontend-change +description: Review, fix, and validate the complete Sendico frontend contribution for a task. Use before reporting completion whenever Dart or Flutter code under frontend/ was added or modified, or when the user requests a frontend implementation review. +--- + +# Review Frontend Change + +## Establish the review scope + +1. Read the repository root `AGENTS.md` and `frontend/AGENTS.md` completely. +2. Inspect repository status and the full frontend diff attributable to the + current task, including uncommitted and committed changes against the task's + base. +3. Separate unrelated pre-existing work from the task contribution. Preserve + it and do not include it in review fixes. +4. Identify every changed Dart file and each affected Flutter package before + validation. + +## Review and fix the contribution + +1. Apply every quality requirement in `frontend/AGENTS.md` to the entire task + diff, not only the most recently edited file. +2. Verify the established DTO-to-mapper-to-service-to-model-to-provider flow and + ensure widgets do not absorb transport, I/O, or state-orchestration work. +3. Search for reusable DTOs, models, providers, widgets, and utilities before + accepting new representations. +4. Review widget structure, file cohesion, lifecycle behavior, asynchronous + races, notification scope, accessibility, responsive behavior, and import + header layout. +5. Review loading, empty, success, and error states together with nullability, + navigation, user-visible failures, edge cases, and focused test coverage. +6. Fix every in-scope issue found, then re-read the resulting complete frontend + diff. Repeat until no task-caused finding remains. + +Do not weaken lints, suppress failures, or modify unrelated user work to make +validation pass. + +## Validate + +1. Run `dart format --output=none --set-exit-if-changed` for every changed Dart + file. +2. Run `flutter analyze` from every affected frontend package directory. +3. Run `make test-frontend` from the repository root. +4. Select and run every applicable security command from the change-scoped + security completion requirements in the root `AGENTS.md`. + +All commands must succeed before reporting the frontend task as complete. If a +command fails, determine whether the task caused it and fix in-scope failures. +Report an external or environmental blocker with the exact command and failure; +never represent blocked validation as successful. + +## Report the result + +Summarize the reviewed frontend scope, material fixes made during review, tests +added or updated, affected packages, and the outcome of every required command. +Call out relevant accessibility, behavior, contract, or rollout implications. diff --git a/AGENTS.md b/AGENTS.md index c5ff429..acdf458 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,22 +1,115 @@ -## Development +# Repository Agent Guidelines -When starting the dev server, use background mode: +## Instruction hierarchy -``` -astro dev --background -``` +- The rules in this file apply to every repository task. +- Before changing files in a scoped module, read and follow that module's + `AGENTS.md` in addition to this file. +- For cross-module work, apply every relevant scoped instruction file and + completion workflow. -Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`. +## Gitea task policy -## Documentation +Whenever creating or updating a Gitea task, correcting its tracker metadata, or +preparing its implementation branch, use the +[`$manage-gitea-task`](.agents/skills/manage-gitea-task/SKILL.md) skill. -Full documentation: https://docs.astro.build +Every created task must have: -Consult these guides before working on related tasks: +- A concise, outcome-oriented title. +- One or more relevant existing labels. +- A responsible person assigned as owner. +- The appropriate existing Gitea project. +- A self-contained body with `Goal`, `Approach`, `Acceptance criteria`, and + `Notes` sections. -- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/) -- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/) -- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/) -- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/) -- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/) -- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/) +Do not guess an assignee, project, label, repository, task number, or module. +Resolve missing metadata from authoritative repository or Gitea data, and ask +the user when it cannot be determined. Do not create labels or projects unless +explicitly requested. Never print, log, or commit API tokens or Git +credentials. + +## Implementation branch policy + +Every Gitea task that requires code changes must use a dedicated branch after +the task exists and before repository files are changed. Tasks without +repository changes do not require a branch. + +- Name the branch `-` using the numeric Gitea task number + without `#` and a specific lowercase kebab-case primary module. +- Do not add a `codex/` prefix, task title, summary, or generic module such as + `feature`, `fix`, or `backend` when a more specific module is known. +- For cross-module work, name the primary module and document secondary modules + in the task or pull request. +- Push the branch and attach its exact short name to the issue `ref` before + changing repository files. +- Verify the issue response contains the intended branch, project, labels, and + assignee before implementation begins. + +## Scoped implementation requirements + +- For backend code under `api/`, read and follow + [`api/AGENTS.md`](api/AGENTS.md). Before reporting completion, use the + [`$review-backend-change`](.agents/skills/review-backend-change/SKILL.md) + skill. +- For Dart or Flutter code under `frontend/`, read and follow + [`frontend/AGENTS.md`](frontend/AGENTS.md). Before reporting completion, use + the + [`$review-frontend-change`](.agents/skills/review-frontend-change/SKILL.md) + skill. +- For a change spanning both modules, use both completion-review skills. + +These references are explicit because nested `AGENTS.md` and `.agents/skills` +discovery depends on the working directory. Keeping the skills at the repository +root makes them available to tasks launched from any repository subtree. + +## Optional security validation + +Security checks are not mandatory prerequisites for completing a task, +committing changes, or pushing a branch. Do not run security scans solely +because files changed or a commit or push is planned. Run them when the user +requests security validation or when security validation is an explicit part +of the task's acceptance criteria. + +References in scoped `AGENTS.md` files and completion-review skills to the +root security requirements follow this optional policy; they do not create +an automatic security gate for completion, commits, or pushes. + +When security validation is requested, select commands from the task's own +diff, including its uncommitted files, and ignore unrelated pre-existing +worktree changes: + +- Use `make security-source-frontend` for frontend source, dependencies, + configuration, assets, or executable tests. +- Use `make security-source-backend` for backend Go source, dependencies, + configuration, or protobuf contracts that affect generated backend code. +- Use `make security-source` for changes spanning both source areas or shared + source-security inputs. +- Use `make security-images SERVICES="..."` for the exact affected Compose + services when container contents, Dockerfiles, container dependencies, + Compose image/build configuration, or deployment image selection change. +- Use `make test-security` for changes to the security gate, scanner wrappers, + lock handling, exception evaluation, database handling, security-related + Make targets, or deployment-gate orchestration. Pair it with the relevant + live scan when that behavior is part of the requested validation. +- Use the full `make security` gate when the requested validation covers the + canonical service set or requires a combined source and image verdict. + +For scans that are run, use the latest stable scanner images, current validated +databases, and the canonical exception manifest. Do not add native scanner +suppressions or weaken thresholds. Any reviewed temporary exception must use +the canonical exception manifest and satisfy its validation policy. + +Report commands actually run and their outcomes, including any failures or +external blockers. Do not claim successful validation for checks that were +not run. This policy does not change executable CI or deployment gates. + +## Delivery workflow + +For implementation work, finish the authorized delivery workflow: validate, +commit the task contribution, push the implementation branch, and create or +update its Gitea pull request. Do not leave completed implementation solely in +an uncommitted working tree. Preserve unrelated changes and avoid mixing them +into task commits. Record scope, validation, limitations, and PR links in the +task and durable repository documentation. Merge only when authorized by the +user. If work is delivered as stacked PRs, document dependencies and merge order. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4d6eccc --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: test-frontend + +test-frontend: + cd frontend/packages/sxchnge_auth && flutter test + cd frontend/packages/sxchnge_theme && flutter test + cd frontend/packages/sxchnge_theme/example && flutter test + cd frontend/app && flutter test diff --git a/README.md b/README.md index b24ca0a..10d61e4 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,43 @@ # SXCHNGE -Astro public website for a crypto-to-fiat exchange. English and Russian, responsive layouts, an illustrative exchange calculator, and explicit company / policy placeholders. +Product repository for the sxchnge public website, consumer frontend, and backend for frontend (BFF). -## Development +## Repository structure -Node >=22.12.0 (Node >=22.18 for the native TypeScript test runner), npm. +| Folder | Purpose | Status | +| --- | --- | --- | +| [`website/`](website/README.md) | Existing Astro public website, assets, brand references, and Sites configuration | Implemented | +| [`frontend/`](frontend/README.md) | Consumer application and Flutter packages | Theme, shared auth, localized app, verification and routing implemented | +| [`backend/`](backend/README.md) | Product BFF integrating with Sendico | Prepared for implementation | + +Each application owns its dependencies and deployment configuration. The frontend includes the reusable `sxchnge_theme` Flutter package; the backend currently contains documentation only. + +## Website development + +Run the existing website commands from its new folder: ```sh +cd website npm ci npm run dev -- --background --host 127.0.0.1 npm run test +npm run check npm run build ``` -Use `npm exec astro dev stop` to stop the background preview. Static output is `dist/`. `/` redirects to `/en/`; localized content lives at `/en/` and `/ru/`. Legal placeholders have separate localized routes. +From `website/`, manage the background server with `npm exec astro dev status`, `npm exec astro dev logs`, and `npm exec astro dev stop`. Build output is `website/dist/`. Use `website/` as the working directory for website hosting tools; its existing Sites identity is preserved in `website/.openai/hosting.json`. -## Content and launch configuration +## Product boundaries -- Translation source: `src/i18n/copy.ts`. -- Brand reference: `design/tokens.json`, documented in `design/README.md`. -- Optional Flutter palette reference: `flutter/brand_palette.dart`. Compatibility does not require identical application styling. -- Full account URLs: set `PUBLIC_FLUTTER_REGISTER_URL` and `PUBLIC_FLUTTER_LOGIN_URL` at build time, as shown in `.env.example`. Locale is appended as `lang`. Until configured, actions open a clear preview dialog; no credentials are collected. -- Quote fixtures: `src/lib/quote.ts`. Demo only; no rates are fetched, orders placed, or funds handled. -- Replace all company placeholders, supply approved legal policies, and confirm supported assets, payout methods, countries, fees and timelines before launch. -- The preview explicitly uses `noindex,nofollow`. Remove only when launch content is complete, and set the real domain in `astro.config.mjs`. +The intended integration is: consumer frontend → sxchnge BFF → supported Sendico API. -Fonts are self-hosted with their license. No analytics, third-party trackers, client storage, or authentication backend is included. The website uses Astro's own TypeScript script support; React is unnecessary for the current interactions. +- sxchnge owns consumer registration and sessions, the onboarding and exchange experience, and mapping authenticated consumers to stable organization-scoped Sendico customer references. +- Sendico remains authoritative for payment execution, KYC records and verification, policy decisions, and limit enforcement. +- The sxchnge organization, backend integration principal, and individual customer references are distinct identities. Consumers must not share the integration account's customer identity. +- The BFF should use a supported API contract rather than copying Sendico's existing BFF or depending on its local internal modules. + +Before implementing the product, define one complete transaction flow and agree on the Sendico customer, KYC, eligibility/limits, and customer-scoped payment contracts. These are intended boundaries, not claims that the required Sendico operations already exist. + +## Delivery record + +See [the implementation record](docs/implementation.md) for delivered scope, task mapping, validation, and PR dependencies. Repository workflow skills and frontend guidelines are stored locally under `.agents/skills/` and `frontend/AGENTS.md`. diff --git a/docs/implementation.md b/docs/implementation.md new file mode 100644 index 0000000..362ab46 --- /dev/null +++ b/docs/implementation.md @@ -0,0 +1,71 @@ +# Product implementation record + +## Scope + +The public Astro website now lives in `website/`, preserving its source, assets, +brand references and hosting identity. `backend/` describes the proposed BFF +boundary; no backend service is implemented. + +The frontend includes: + +- `sxchnge_theme`: reusable Flutter theme, Onest font/license, light/dark themes, + and a consumer example. UI uses ColorScheme and TextTheme roles. +- `sxchnge_auth`: shared login, signup, and email-verification contracts, + models, providers, and deterministic dummy services, reusable on mobile. +- `app`: responsive login/signup, EN/RU localization and saved locale, + email-sent and verified screens, expired/invalid/internal-error outcomes, + resend/retry, and centralized go_router navigation. + +## Review boundaries and task mapping + +The accumulated work was initially left uncommitted across task branches. +Delivery groups the actual final code into three stacked contributions, without +claiming that the earlier intermediate implementations were committed: + +1. `theme-1` into `main`: website relocation, repository scaffolding and theme. + Related task: [#1](https://git.sendico.io/tech/sxchnge/issues/1). +2. `auth-4` into `theme-1`: shared auth and verification package. + Related tasks: [#4](https://git.sendico.io/tech/sxchnge/issues/4), + [#5](https://git.sendico.io/tech/sxchnge/issues/5). +3. `routing-6` into `auth-4`: integrated application, root test entry point, + self-contained frontend guidelines/workflow skills and this delivery record. + Related tasks: [#2](https://git.sendico.io/tech/sxchnge/issues/2), + [#3](https://git.sendico.io/tech/sxchnge/issues/3), + [#5](https://git.sendico.io/tech/sxchnge/issues/5), + [#6](https://git.sendico.io/tech/sxchnge/issues/6). + +Review and merge in that order. Preserve commit ancestry with merge commits, +then retarget the next PR to `main` after its dependency merges. With squash +merges, rebase the remaining stack before merging. PR links are recorded below +once created. Earlier empty task branches are retained for traceability. + +## Validation + +The frontend suite covers 56 tests: 20 shared auth, 3 theme, 3 theme consumer, +and 30 app tests. Coverage includes validation, localized layouts at 200% text +scale, provider lifecycle/race handling, signup through email confirmation, +resend/retry, token changes, transient history extras and URL route updates. + +Commands used for delivery: + +- Root: `make test-frontend`. +- Flutter packages: formatting checks and `flutter analyze`. +- App: `flutter build web --no-wasm-dry-run`. +- Website: `npm run test`, `npm run check`, `npm run build`. + +The localhost preview was manually checked for confirmation deep-link reload, +return to login, and browser back/forward. Tests and builds are local validation; +no remote CI outcome is implied. Build outputs and dependency caches are ignored. + +## Current limitations + +Authentication and verification are explicitly simulated. No real account, +session, email delivery, backend token validation or KYC access guard exists. +The three app routes are public; backend authorization remains future work. +`demo-valid`, `demo-expired` and `demo-internal` are preview-only confirmation +values. Other/missing tokens display invalid confirmation. Signup email is +transient and excluded from serialized browser history. Only locale preference +is persisted by the application. + +The website and Flutter app are separate applications. The Flutter app currently +has a web runner; the shared packages can be consumed by a future mobile app. diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 0000000..78530b8 --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,114 @@ +# Frontend Agent Guidelines + +These instructions extend the repository-wide rules in the root `AGENTS.md` +and apply whenever a task adds or modifies Dart or Flutter code under +`frontend/`. + +Before completing the task, review the entire frontend diff produced by the +current task, fix every in-scope issue found, and then perform the required +validation. Use the +[`$review-frontend-change`](../.agents/skills/review-frontend-change/SKILL.md) +skill to perform this completion workflow. + +## Preserve the frontend architecture + +- Strictly preserve the existing `dto` -> `mapper` -> `service` -> `model` -> + `provider` flow. Widgets consume models and provider state; they must not + bypass the flow by parsing transport data or calling backend APIs directly. +- Keep responsibilities separated: + - DTOs represent serialized transport contracts only. + - Mappers convert between DTOs and frontend domain models. + - Services own API and external I/O operations and return mapped models. + - Models express frontend domain and presentation data without transport or + provider lifecycle concerns. + - Providers coordinate services, state transitions, loading and error state, + and expose models to widgets. +- Extend the established layer for new behavior instead of introducing parallel + data paths, embedding mapping in services or providers, or moving business + state into widgets. + +## Reuse definitions and avoid naming stutter + +- Search existing DTOs, models, providers, widgets, and shared utilities before + introducing another representation of the same concept. +- Avoid stutter in type names, field names, parameters, nested access paths, + directories, and filenames. The surrounding package or directory already + provides context; each child name should add meaning instead of repeating it. +- Prefer concise access such as `payment.details.id` over + `payment.payment.paymentId`. +- Avoid repeating a parent directory in a filename. For example, prefer + `payment/widget.dart` over `payment/payment_widget.dart` and + `profile/header.dart` over `profile/profile_header.dart`. +- Keep architectural suffixes such as `DTO`, `Mapper`, `Service`, `Model`, and + `Provider` only where they clarify the layer; do not repeat the domain name + around them, such as `PaymentPaymentDTO`. + +## Keep widgets structured and files focused + +- Keep widgets declarative and focused on presentation, composition, and user + interaction. Move state orchestration and side effects into the established + provider, service, or controller layer. +- Review large `build` methods, deeply nested widget trees, and files containing + multiple responsibilities. Extract cohesive widgets or supporting files with + clear ownership before the code becomes difficult to scan or test. +- Prefer one primary public widget or feature concept per file. Small private + helpers may remain when they are tightly coupled and improve readability; do + not fragment code into trivial files mechanically. +- Treat reasonable file size as a cohesion and readability requirement rather + than a fixed line limit. Generated files are excluded from this review. +- Preserve lifecycle correctness when extracting widgets or providers, + including disposal, subscription cleanup, mounted checks, asynchronous race + handling, and minimal provider notifications. + +## Maintain Dart header layout + +- Keep hand-written imports ordered from generic to specific: + 1. Dart SDK imports (`dart:`). + 2. Flutter SDK packages. + 3. Third-party packages. + 4. Shared internal packages such as `pshared`. + 5. The current application package, such as `pweb` or `backoffice`. + 6. Relative imports, when they are necessary. +- Group imports from the same package together and sort their paths within the + group. Separate every distinct package group with one blank line. +- Place generated-code imports, including imports under a package's + `generated/` path, after all hand-written import groups. Group generated + imports by package and separate distinct generated package groups with one + blank line. +- Place `part` directives after all hand-written and generated import groups; + they are the final directives in the header. Sort multiple `part` directives + consistently. In a part file, keep the required `part of` directive first and + do not add imports that Dart forbids there. +- Separate the final header directive from the first declaration or annotation + with exactly one empty line, meaning two consecutive newline characters. + This is the layout produced by `dart format`. +- Verify header layout with `dart format` and `flutter analyze`. Do not change + lint configuration preemptively. If an analyzer rule actually conflicts with + this layout, adjust only the narrowest affected package configuration, + document why, and rerun the checks. + +## Review implementation quality + +- Review the current task's frontend contribution for correctness, readability, + maintainability, testability, accessibility, responsive behavior, and + consistency with surrounding code and architecture. +- Check loading, empty, success, and error states; nullability; asynchronous + behavior; provider update scope; navigation; and user-visible failure paths. +- Remove duplicated logic, dead code, unnecessary indirection, speculative + abstractions, and avoidable widget rebuilds introduced by the task. +- Add or update focused tests for changed widgets, DTO mapping, services, + models, providers, edge cases, and failure paths as applicable. +- Fix all findings caused by the current task before reporting completion. + +## Required validation + +- Run `dart format --output=none --set-exit-if-changed` for every changed Dart + file. +- Run `flutter analyze` from each affected frontend package directory. +- From the repository root, run `make test-frontend`, plus every applicable + command from the root `AGENTS.md` change-scoped security requirements, after + all review fixes are complete. +- All commands must succeed before the task is reported as complete. Do not + silently skip, suppress, or weaken checks. Report the commands and outcomes + in the final response, or report the exact environmental blocker without + claiming successful validation. diff --git a/frontend/app/.gitignore b/frontend/app/.gitignore new file mode 100644 index 0000000..79f7eca --- /dev/null +++ b/frontend/app/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Widget Preview related +.widget_preview/ diff --git a/frontend/app/.metadata b/frontend/app/.metadata new file mode 100644 index 0000000..7fdd725 --- /dev/null +++ b/frontend/app/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d3b14c876900e553bc736ca19295fc09e3853e8e" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e + base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e + - platform: web + create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e + base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/frontend/app/README.md b/frontend/app/README.md new file mode 100644 index 0000000..b23fbd3 --- /dev/null +++ b/frontend/app/README.md @@ -0,0 +1,64 @@ +# SXCHNGE consumer app + +Flutter web application using the local `sxchnge_theme` package. + +## Run + +From this directory: + +```sh +flutter pub get +flutter run -d chrome +``` + +## Current scope + +A responsive login/sign-up preview with a themed brand panel, email and password validation, password visibility controls, keyboard submission, and inline feedback. Sign-up additionally asks for a name and matching password confirmation. The 12-character sign-up minimum is a UI preview policy and must be aligned with the backend when authentication is integrated. + +No authentication endpoint is configured. Submission validates locally and calls the shared dummy authentication services; it never creates an account, starts a session, sends credentials, or persists them. Passwords are cleared when switching modes. Password managers/autofill integration is deferred until real authentication exists. LoginProvider and SignupProvider own loading/results/errors; SxchngeApp accepts injected loginService and signupService implementations. Dummy completion is explicitly labeled and does not establish a real session. + +The interface uses `ColorScheme` and `TextTheme` roles from `sxchnge_theme`; the desktop brand panel installs the package's dark theme locally. Entrance animation respects reduced-motion preferences. The public website remains separate in `website/`. + +## Validate + +```sh +dart format --output=none --set-exit-if-changed lib test +flutter analyze +flutter test +flutter build web +``` + +From the repository root, `make test-frontend` runs both theme suites and the application tests. Tracker: tech/sxchnge#2, branch `auth-2`. + +## English and Russian + +Localization follows Sendico's Flutter pattern: `lib/l10n/en.arb` is the template, `ru.arb` contains Russian translations, and `flutter gen-l10n` writes `lib/generated/i18n/`. Edit ARB files, not generated Dart files. Flutter's Material and Cupertino localizations are included through the generated delegates. + +The language dropdown is available in both authentication modes. Startup reads `sxchnge_last_locale` using shared preferences; without a saved choice it uses the device language. Russian regional variants resolve to Russian and unsupported languages fall back to English. Preference writes are serialized, and storage failures leave the selected language usable for the current session with a localized notice. Only the language preference is stored; credentials remain transient. + +Language switching preserves mode and input, translates existing validation errors, and updates the application title. Tracker: tech/sxchnge#3, branch `localization-3`. + +## Email verification + +Successful signup opens `/verification-sent` and requests a verification email through the injected `VerificationService`. The screen shows sending, sent and resend errors. Reloading this route asks for the email again; the app does not persist it. A production signup endpoint should avoid sending a duplicate email if this separate send operation is retained. + +Confirmation links use `/#/verify-email?token=…`. They display checking, verified, expired, invalid (including missing tokens), or internal error. Internal confirmation errors allow retry; invalid and expired links allow requesting a new email. The backend must validate tokens and enforce verification before enabling restricted operations. + +The default service is a **dummy**: no email is sent or account verified. Preview tokens: + +- `demo-valid`: verified +- `demo-expired`: expired +- `demo-internal`: internal error +- Any other token or missing token: invalid + +The sent screen includes a clearly labeled demo confirmation link. All states and actions support EN/RU and theme colors. Tracker: tech/sxchnge#5, branch `verification-5`. + +## Routing + +`lib/routing/router.dart` owns the `go_router` route table, with route names in `routes.dart`. `SxchngeApp` creates one router for its lifetime, supplies it to `MaterialApp.router`, and disposes it on teardown. Locale changes preserve the router and current page state. + +Use `context.goNamed(AppRoutes.… )` for top-level navigation. Existing hash URLs remain compatible: `/`, `/#/verification-sent`, and `/#/verify-email?token=…`. Confirmation query changes re-run verification and discard stale async results. Unknown routes redirect to `/`. + +The signup email travels as a transient route extra. The router's extra codec deliberately omits extras from serialized browser history, so a restored sent page asks for the email again. Do not add persistent route extras without explicitly updating that policy. + +These are public routes: no session or KYC guard is added to the dummy authentication flow. Future protected pages should use centralized redirects driven by authoritative session state, with backend access enforcement. Tracker: tech/sxchnge#6, branch `routing-6`. diff --git a/frontend/app/analysis_options.yaml b/frontend/app/analysis_options.yaml new file mode 100644 index 0000000..6ba33d0 --- /dev/null +++ b/frontend/app/analysis_options.yaml @@ -0,0 +1,33 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - web/** + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/frontend/app/l10n.yaml b/frontend/app/l10n.yaml new file mode 100644 index 0000000..05ff2a0 --- /dev/null +++ b/frontend/app/l10n.yaml @@ -0,0 +1,5 @@ +arb-dir: lib/l10n +output-dir: lib/generated/i18n +template-arb-file: en.arb +output-localization-file: app_localizations.dart +untranslated-messages-file: untranslated.txt diff --git a/frontend/app/lib/auth/brand_panel.dart b/frontend/app/lib/auth/brand_panel.dart new file mode 100644 index 0000000..2e4c59a --- /dev/null +++ b/frontend/app/lib/auth/brand_panel.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class BrandWordmark extends StatelessWidget { + const BrandWordmark({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(9), + ), + child: Icon(Icons.bolt, color: theme.colorScheme.onPrimary, size: 26), + ), + const SizedBox(width: 9), + Flexible( + child: Text( + 'sxchnge', + style: theme.textTheme.headlineMedium!.copyWith( + letterSpacing: -1.5, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ); + } +} + +class BrandPanel extends StatelessWidget { + const BrandPanel({super.key}); + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final colors = theme.colorScheme; + return ColoredBox( + color: colors.surface, + child: Padding( + padding: const EdgeInsets.all(48), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const BrandWordmark(), + const SizedBox(height: 64), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l.brandEyebrow, + style: theme.textTheme.labelMedium!.copyWith( + letterSpacing: 1.5, + color: colors.primary, + ), + ), + const SizedBox(height: 24), + Text( + l.brandHeading, + style: theme.textTheme.displayLarge!.copyWith( + fontSize: 68, + height: 1.03, + letterSpacing: -3, + ), + ), + const SizedBox(height: 32), + Text( + l.brandIntro, + style: theme.textTheme.bodyLarge!.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 48), + const _MotionCard(), + ], + ), + const SizedBox(height: 64), + Text( + l.brandFooter, + style: theme.textTheme.labelSmall!.copyWith( + letterSpacing: 1, + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} + +class _MotionCard extends StatelessWidget { + const _MotionCard(); + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final colors = theme.colorScheme; + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 700), + curve: Curves.easeOutCubic, + builder: (context, value, child) => Opacity( + opacity: value, + child: Transform.translate( + offset: Offset(0, (1 - value) * 24), + child: child, + ), + ), + child: Transform.rotate( + angle: -0.035, + child: Card( + color: colors.primaryContainer, + child: Padding( + padding: const EdgeInsets.all(28), + child: Row( + children: [ + Expanded( + child: Text( + l.brandCard, + style: theme.textTheme.headlineSmall!.copyWith( + color: colors.onPrimaryContainer, + ), + ), + ), + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: colors.tertiaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.north_east, + size: 38, + color: colors.onTertiaryContainer, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/frontend/app/lib/auth/feedback.dart b/frontend/app/lib/auth/feedback.dart new file mode 100644 index 0000000..c310288 --- /dev/null +++ b/frontend/app/lib/auth/feedback.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +import 'package:sxchnge_auth/sxchnge_auth.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class AuthFeedback extends StatelessWidget { + const AuthFeedback({super.key, required this.state, required this.signUp}); + final AuthState state; + final bool signUp; + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final colors = Theme.of(context).colorScheme; + final error = state.error; + final message = error == null + ? (state.result!.isSimulated + ? (signUp ? l.signupPreview : l.loginPreview) + : l.authCompleted) + : switch (error) { + AuthError.invalidCredentials => l.authInvalidCredentials, + AuthError.emailInUse => l.authEmailInUse, + AuthError.unavailable => l.authUnavailable, + AuthError.unexpected => l.authUnexpected, + }; + return Semantics( + liveRegion: true, + child: Container( + key: ValueKey(error == null ? 'preview-result' : 'auth-error'), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: error == null + ? colors.secondaryContainer + : colors.errorContainer, + borderRadius: BorderRadius.circular(14), + ), + child: Text( + message, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: error == null + ? colors.onSecondaryContainer + : colors.onErrorContainer, + ), + ), + ), + ); + } +} diff --git a/frontend/app/lib/auth/form.dart b/frontend/app/lib/auth/form.dart new file mode 100644 index 0000000..4a2f43e --- /dev/null +++ b/frontend/app/lib/auth/form.dart @@ -0,0 +1,330 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_auth/sxchnge_auth.dart'; + +import '../routing/routes.dart'; +import 'feedback.dart'; +import 'validation.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class AuthForm extends StatefulWidget { + const AuthForm({super.key}); + + @override + State createState() => _AuthFormState(); +} + +class _AuthFormState extends State { + final _form = GlobalKey(); + final _password = TextEditingController(); + final _confirmation = TextEditingController(); + bool _signUp = false; + bool _showPassword = false; + bool _showConfirmation = false; + bool _submitted = false; + final _email = TextEditingController(); + final _name = TextEditingController(); + + AuthProvider get _provider => + _signUp ? context.read() : context.read(); + + Locale? _lastLocale; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final locale = Localizations.localeOf(context); + if (_lastLocale != null && _lastLocale != locale && _submitted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _form.currentState?.validate(); + }); + } + _lastLocale = locale; + } + + @override + void dispose() { + _email.dispose(); + _name.dispose(); + _password.dispose(); + _confirmation.dispose(); + super.dispose(); + } + + void _switch(bool signUp) { + if (signUp == _signUp) return; + context.read().reset(); + context.read().reset(); + _form.currentState?.reset(); + _password.clear(); + _confirmation.clear(); + setState(() { + _signUp = signUp; + _submitted = false; + _showPassword = false; + _showConfirmation = false; + }); + } + + Future _submit() async { + if (_provider.state.isLoading) return; + setState(() => _submitted = true); + if (!_form.currentState!.validate()) return; + FocusScope.of(context).unfocus(); + final locale = Localizations.localeOf(context).toLanguageTag(); + if (_signUp) { + final signup = context.read(); + await signup.signup( + name: _name.text, + email: _email.text, + password: _password.text, + locale: locale, + ); + if (!mounted || !_signUp || signup.state.status != AuthStatus.success) { + return; + } + final email = _email.text.trim(); + _password.clear(); + _confirmation.clear(); + context.goNamed(AppRoutes.verificationSent, extra: email); + } else { + await context.read().login( + email: _email.text, + password: _password.text, + locale: locale, + ); + } + } + + @override + Widget build(BuildContext context) { + final login = context.watch(); + final signup = context.watch(); + final state = (_signUp ? signup : login).state; + final l = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final colors = theme.colorScheme; + final text = theme.textTheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: ShapeDecoration( + color: colors.tertiaryContainer, + shape: const StadiumBorder(), + ), + child: Text( + l.nextChapter, + style: text.labelSmall!.copyWith( + color: colors.onTertiaryContainer, + letterSpacing: 0.8, + ), + ), + ), + ), + const SizedBox(height: 24), + Text( + _signUp ? l.signupHeading : l.loginHeading, + style: text.displayMedium, + ), + const SizedBox(height: 16), + Text( + _signUp ? l.signupIntro : l.loginIntro, + style: text.bodyLarge!.copyWith(color: colors.onSurfaceVariant), + ), + const SizedBox(height: 32), + SegmentedButton( + showSelectedIcon: false, + segments: [ + ButtonSegment(value: false, label: Text(l.login)), + ButtonSegment(value: true, label: Text(l.signup)), + ], + selected: {_signUp}, + onSelectionChanged: (selected) => _switch(selected.single), + expandedInsets: EdgeInsets.zero, + ), + const SizedBox(height: 32), + Form( + key: _form, + autovalidateMode: _submitted + ? AutovalidateMode.onUserInteraction + : AutovalidateMode.disabled, + onChanged: () { + if (_provider.state.status == AuthStatus.success || + _provider.state.status == AuthStatus.error) { + _provider.reset(); + } + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_signUp) ...[ + TextFormField( + key: const ValueKey('name'), + controller: _name, + enabled: !state.isLoading, + decoration: InputDecoration( + labelText: l.name, + prefixIcon: Icon(Icons.person_outline), + errorMaxLines: 3, + ), + textInputAction: TextInputAction.next, + textCapitalization: TextCapitalization.words, + validator: (value) => AuthValidation.name(value, l), + ), + const SizedBox(height: 20), + ], + TextFormField( + key: const ValueKey('email'), + controller: _email, + enabled: !state.isLoading, + decoration: InputDecoration( + labelText: l.email, + hintText: l.emailHint, + prefixIcon: Icon(Icons.alternate_email), + errorMaxLines: 3, + ), + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + autocorrect: false, + validator: (value) => AuthValidation.email(value, l), + ), + const SizedBox(height: 20), + TextFormField( + key: const ValueKey('password'), + enabled: !state.isLoading, + controller: _password, + obscureText: !_showPassword, + autocorrect: false, + enableSuggestions: false, + textInputAction: _signUp + ? TextInputAction.next + : TextInputAction.done, + onFieldSubmitted: (_) { + if (!_signUp) _submit(); + }, + decoration: InputDecoration( + labelText: l.password, + prefixIcon: const Icon(Icons.lock_outline), + errorMaxLines: 3, + helperText: _signUp ? l.passwordHint : null, + helperMaxLines: 3, + suffixIcon: IconButton( + tooltip: _showPassword ? l.hidePassword : l.showPassword, + onPressed: () => + setState(() => _showPassword = !_showPassword), + icon: Icon( + _showPassword + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + ), + ), + ), + validator: (value) => + AuthValidation.password(value, l, signUp: _signUp), + ), + if (_signUp) ...[ + const SizedBox(height: 20), + TextFormField( + key: const ValueKey('confirmation'), + enabled: !state.isLoading, + controller: _confirmation, + obscureText: !_showConfirmation, + autocorrect: false, + enableSuggestions: false, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + decoration: InputDecoration( + labelText: l.confirmPassword, + prefixIcon: const Icon(Icons.lock_outline), + errorMaxLines: 3, + suffixIcon: IconButton( + tooltip: _showConfirmation + ? l.hideConfirmation + : l.showConfirmation, + onPressed: () => setState( + () => _showConfirmation = !_showConfirmation, + ), + icon: Icon( + _showConfirmation + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + ), + ), + ), + validator: (value) => + AuthValidation.confirmation(value, _password.text, l), + ), + ], + const SizedBox(height: 28), + FilledButton( + key: const ValueKey('submit'), + onPressed: state.isLoading ? null : _submit, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + state.isLoading + ? l.authWorking + : (_signUp ? l.createAccount : l.letsGo), + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 12), + state.isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.arrow_forward, size: 20), + ], + ), + ), + ), + ], + ), + ), + if (state.status == AuthStatus.success || + state.status == AuthStatus.error) ...[ + const SizedBox(height: 20), + AuthFeedback(state: state, signUp: _signUp), + ], + const SizedBox(height: 20), + Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + _signUp ? l.alreadyMember : l.newHere, + style: text.bodySmall!.copyWith(color: colors.onSurfaceVariant), + ), + TextButton( + onPressed: () => _switch(!_signUp), + child: Text(_signUp ? l.loginInstead : l.createAnAccount), + ), + ], + ), + const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 16), + Text( + l.earlyPreview, + textAlign: TextAlign.center, + style: text.bodySmall!.copyWith(color: colors.onSurfaceVariant), + ), + ], + ); + } +} diff --git a/frontend/app/lib/auth/page.dart b/frontend/app/lib/auth/page.dart new file mode 100644 index 0000000..101a586 --- /dev/null +++ b/frontend/app/lib/auth/page.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +import 'package:sxchnge_theme/sxchnge_theme.dart'; + +import '../widgets/locale.dart'; + +import 'brand_panel.dart'; +import 'form.dart'; + +class AuthPage extends StatefulWidget { + const AuthPage({super.key}); + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + // Preserve the form when moving between the desktop and mobile layouts. + final _formKey = GlobalKey(); + + @override + Widget build(BuildContext context) => Scaffold( + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= 1000; + final form = SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Center( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: wide ? 56 : 24, + vertical: wide ? 48 : 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Align( + alignment: Alignment.centerRight, + child: LocaleSelector(), + ), + const SizedBox(height: 16), + if (!wide) ...[ + const Align( + alignment: Alignment.centerLeft, + child: BrandWordmark(), + ), + const SizedBox(height: 48), + ], + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: AuthForm(key: _formKey), + ), + ), + ], + ), + ), + ), + ), + ); + if (!wide) return form; + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 47, + child: Theme( + data: SxchngeTheme.dark(), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: const BrandPanel(), + ), + ), + ), + ), + Expanded(flex: 53, child: form), + ], + ); + }, + ), + ), + ); +} diff --git a/frontend/app/lib/auth/validation.dart b/frontend/app/lib/auth/validation.dart new file mode 100644 index 0000000..e30d703 --- /dev/null +++ b/frontend/app/lib/auth/validation.dart @@ -0,0 +1,35 @@ +import '../generated/i18n/app_localizations.dart'; + +abstract final class AuthValidation { + static String? email(String? value, AppLocalizations l) { + final email = value?.trim() ?? ''; + if (email.isEmpty) return l.emailRequired; + if (!RegExp(r'^[^\s@]+@[^\s@]+\.[^\s@]+$').hasMatch(email)) { + return l.emailInvalid; + } + return null; + } + + static String? name(String? value, AppLocalizations l) => + (value?.trim().isEmpty ?? true) ? l.nameRequired : null; + + static String? password( + String? value, + AppLocalizations l, { + required bool signUp, + }) { + if (value == null || value.isEmpty) return l.passwordRequired; + if (signUp && value.length < 12) return l.passwordHint; + return null; + } + + static String? confirmation( + String? value, + String password, + AppLocalizations l, + ) => value == null || value.isEmpty + ? l.confirmationRequired + : value != password + ? l.passwordMismatch + : null; +} diff --git a/frontend/app/lib/generated/i18n/app_localizations.dart b/frontend/app/lib/generated/i18n/app_localizations.dart new file mode 100644 index 0000000..0da3b48 --- /dev/null +++ b/frontend/app/lib/generated/i18n/app_localizations.dart @@ -0,0 +1,530 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_ru.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'i18n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('ru'), + ]; + + /// No description provided for @appTitle. + /// + /// In en, this message translates to: + /// **'SXCHNGE · Your next move'** + String get appTitle; + + /// No description provided for @nextChapter. + /// + /// In en, this message translates to: + /// **'YOUR NEXT CHAPTER'** + String get nextChapter; + + /// No description provided for @loginHeading. + /// + /// In en, this message translates to: + /// **'Hey, welcome\nback.'** + String get loginHeading; + + /// No description provided for @signupHeading. + /// + /// In en, this message translates to: + /// **'Make your\nfirst move.'** + String get signupHeading; + + /// No description provided for @loginIntro. + /// + /// In en, this message translates to: + /// **'Your next move is just a login away.'** + String get loginIntro; + + /// No description provided for @signupIntro. + /// + /// In en, this message translates to: + /// **'A new account. A world of possibility.'** + String get signupIntro; + + /// No description provided for @login. + /// + /// In en, this message translates to: + /// **'Log in'** + String get login; + + /// No description provided for @signup. + /// + /// In en, this message translates to: + /// **'Sign up'** + String get signup; + + /// No description provided for @name. + /// + /// In en, this message translates to: + /// **'Your name'** + String get name; + + /// No description provided for @email. + /// + /// In en, this message translates to: + /// **'Email address'** + String get email; + + /// No description provided for @emailHint. + /// + /// In en, this message translates to: + /// **'you@example.com'** + String get emailHint; + + /// No description provided for @password. + /// + /// In en, this message translates to: + /// **'Password'** + String get password; + + /// No description provided for @confirmPassword. + /// + /// In en, this message translates to: + /// **'Confirm password'** + String get confirmPassword; + + /// No description provided for @passwordHint. + /// + /// In en, this message translates to: + /// **'Use at least 12 characters.'** + String get passwordHint; + + /// No description provided for @showPassword. + /// + /// In en, this message translates to: + /// **'Show password'** + String get showPassword; + + /// No description provided for @hidePassword. + /// + /// In en, this message translates to: + /// **'Hide password'** + String get hidePassword; + + /// No description provided for @showConfirmation. + /// + /// In en, this message translates to: + /// **'Show confirmation'** + String get showConfirmation; + + /// No description provided for @hideConfirmation. + /// + /// In en, this message translates to: + /// **'Hide confirmation'** + String get hideConfirmation; + + /// No description provided for @createAccount. + /// + /// In en, this message translates to: + /// **'Create account'** + String get createAccount; + + /// No description provided for @letsGo. + /// + /// In en, this message translates to: + /// **'Let’s go'** + String get letsGo; + + /// 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.'** + 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.'** + String get signupPreview; + + /// No description provided for @alreadyMember. + /// + /// In en, this message translates to: + /// **'Already part of sxchnge?'** + String get alreadyMember; + + /// No description provided for @newHere. + /// + /// In en, this message translates to: + /// **'New around here?'** + String get newHere; + + /// No description provided for @loginInstead. + /// + /// In en, this message translates to: + /// **'Log in instead'** + String get loginInstead; + + /// No description provided for @createAnAccount. + /// + /// In en, this message translates to: + /// **'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: + /// **'A LITTLE CHANGE. A BIG NEXT STEP.'** + String get brandEyebrow; + + /// No description provided for @brandHeading. + /// + /// In en, this message translates to: + /// **'Good moves.\nStart here.'** + String get brandHeading; + + /// No description provided for @brandIntro. + /// + /// In en, this message translates to: + /// **'A fresh way forward.\nMake it yours with sxchnge.'** + String get brandIntro; + + /// No description provided for @brandCard. + /// + /// In en, this message translates to: + /// **'Less friction.\nMore possibility.'** + String get brandCard; + + /// No description provided for @brandFooter. + /// + /// In en, this message translates to: + /// **'YOUR NEXT MOVE STARTS WITH YOU.'** + String get brandFooter; + + /// No description provided for @emailRequired. + /// + /// In en, this message translates to: + /// **'Enter your email address.'** + String get emailRequired; + + /// No description provided for @emailInvalid. + /// + /// In en, this message translates to: + /// **'Enter a valid email address.'** + String get emailInvalid; + + /// No description provided for @nameRequired. + /// + /// In en, this message translates to: + /// **'Enter your name.'** + String get nameRequired; + + /// No description provided for @passwordRequired. + /// + /// In en, this message translates to: + /// **'Enter your password.'** + String get passwordRequired; + + /// No description provided for @confirmationRequired. + /// + /// In en, this message translates to: + /// **'Confirm your password.'** + String get confirmationRequired; + + /// No description provided for @passwordMismatch. + /// + /// In en, this message translates to: + /// **'Passwords do not match.'** + String get passwordMismatch; + + /// No description provided for @language. + /// + /// In en, this message translates to: + /// **'Language'** + String get language; + + /// No description provided for @preferenceNotSaved. + /// + /// In en, this message translates to: + /// **'Language changed for this session. Your preference could not be saved.'** + String get preferenceNotSaved; + + /// No description provided for @authWorking. + /// + /// In en, this message translates to: + /// **'Please wait…'** + String get authWorking; + + /// No description provided for @authCompleted. + /// + /// In en, this message translates to: + /// **'Request completed.'** + String get authCompleted; + + /// No description provided for @authInvalidCredentials. + /// + /// In en, this message translates to: + /// **'Email or password is incorrect. Please try again.'** + String get authInvalidCredentials; + + /// No description provided for @authEmailInUse. + /// + /// In en, this message translates to: + /// **'This email is already registered. Try logging in.'** + String get authEmailInUse; + + /// No description provided for @authUnavailable. + /// + /// In en, this message translates to: + /// **'The service is unavailable. Please try again.'** + String get authUnavailable; + + /// No description provided for @authUnexpected. + /// + /// In en, this message translates to: + /// **'Something went wrong. Please try again.'** + String get authUnexpected; + + /// No description provided for @checkInbox. + /// + /// In en, this message translates to: + /// **'Check your inbox.'** + String get checkInbox; + + /// No description provided for @verificationSent. + /// + /// In en, this message translates to: + /// **'We’ve sent you a verification email. Open the link in your inbox to confirm your email address.'** + String get verificationSent; + + /// No description provided for @verificationSending. + /// + /// In en, this message translates to: + /// **'Preparing your verification email…'** + String get verificationSending; + + /// No description provided for @verificationChecking. + /// + /// In en, this message translates to: + /// **'Verifying your email…'** + String get verificationChecking; + + /// No description provided for @verificationVerified. + /// + /// In en, this message translates to: + /// **'Your email\nis verified.'** + String get verificationVerified; + + /// No description provided for @verificationVerifiedBody. + /// + /// In en, this message translates to: + /// **'One step done. You can now return to login. Identity verification is a separate step.'** + String get verificationVerifiedBody; + + /// No description provided for @verificationExpired. + /// + /// In en, this message translates to: + /// **'This link has expired.'** + String get verificationExpired; + + /// No description provided for @verificationExpiredBody. + /// + /// In en, this message translates to: + /// **'Request a new verification email and use the latest link.'** + String get verificationExpiredBody; + + /// No description provided for @verificationInvalid. + /// + /// In en, this message translates to: + /// **'This link isn’t valid.'** + String get verificationInvalid; + + /// No description provided for @verificationInvalidBody. + /// + /// In en, this message translates to: + /// **'The confirmation link is missing or invalid. Open the complete link from your email, or request a new one.'** + String get verificationInvalidBody; + + /// No description provided for @verificationInternal. + /// + /// In en, this message translates to: + /// **'Something went wrong.'** + String get verificationInternal; + + /// No description provided for @verificationInternalBody. + /// + /// In en, this message translates to: + /// **'We couldn’t complete your request because of an internal error. Please try again.'** + String get verificationInternalBody; + + /// No description provided for @verificationResend. + /// + /// In en, this message translates to: + /// **'Resend verification email'** + String get verificationResend; + + /// No description provided for @verificationRequest. + /// + /// In en, this message translates to: + /// **'Send a new verification email'** + String get verificationRequest; + + /// No description provided for @verificationRetry. + /// + /// In en, this message translates to: + /// **'Try again'** + String get verificationRetry; + + /// No description provided for @verificationBack. + /// + /// In en, this message translates to: + /// **'Back to login'** + String get verificationBack; + + /// No description provided for @verificationHint. + /// + /// In en, this message translates to: + /// **'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'** + String get verificationDemoLink; + + /// No description provided for @verificationStep. + /// + /// In en, this message translates to: + /// **'CONFIRM YOUR EMAIL'** + String get verificationStep; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'ru'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + case 'ru': + return AppLocalizationsRu(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/frontend/app/lib/generated/i18n/app_localizations_en.dart b/frontend/app/lib/generated/i18n/app_localizations_en.dart new file mode 100644 index 0000000..2627c5a --- /dev/null +++ b/frontend/app/lib/generated/i18n/app_localizations_en.dart @@ -0,0 +1,222 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; + +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get appTitle => 'SXCHNGE · Your next move'; + + @override + String get nextChapter => 'YOUR NEXT CHAPTER'; + + @override + String get loginHeading => 'Hey, welcome\nback.'; + + @override + String get signupHeading => 'Make your\nfirst move.'; + + @override + String get loginIntro => 'Your next move is just a login away.'; + + @override + String get signupIntro => 'A new account. A world of possibility.'; + + @override + String get login => 'Log in'; + + @override + String get signup => 'Sign up'; + + @override + String get name => 'Your name'; + + @override + String get email => 'Email address'; + + @override + String get emailHint => 'you@example.com'; + + @override + String get password => 'Password'; + + @override + String get confirmPassword => 'Confirm password'; + + @override + String get passwordHint => 'Use at least 12 characters.'; + + @override + String get showPassword => 'Show password'; + + @override + String get hidePassword => 'Hide password'; + + @override + String get showConfirmation => 'Show confirmation'; + + @override + String get hideConfirmation => 'Hide confirmation'; + + @override + String get createAccount => 'Create account'; + + @override + 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.'; + + @override + String get signupPreview => + 'Demo sign-up completed. No real account was created. Your credentials have not been sent or saved.'; + + @override + String get alreadyMember => 'Already part of sxchnge?'; + + @override + String get newHere => 'New around here?'; + + @override + String get loginInstead => 'Log in instead'; + + @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.'; + + @override + String get brandHeading => 'Good moves.\nStart here.'; + + @override + String get brandIntro => 'A fresh way forward.\nMake it yours with sxchnge.'; + + @override + String get brandCard => 'Less friction.\nMore possibility.'; + + @override + String get brandFooter => 'YOUR NEXT MOVE STARTS WITH YOU.'; + + @override + String get emailRequired => 'Enter your email address.'; + + @override + String get emailInvalid => 'Enter a valid email address.'; + + @override + String get nameRequired => 'Enter your name.'; + + @override + String get passwordRequired => 'Enter your password.'; + + @override + String get confirmationRequired => 'Confirm your password.'; + + @override + String get passwordMismatch => 'Passwords do not match.'; + + @override + String get language => 'Language'; + + @override + String get preferenceNotSaved => + 'Language changed for this session. Your preference could not be saved.'; + + @override + String get authWorking => 'Please wait…'; + + @override + String get authCompleted => 'Request completed.'; + + @override + String get authInvalidCredentials => + 'Email or password is incorrect. Please try again.'; + + @override + String get authEmailInUse => + 'This email is already registered. Try logging in.'; + + @override + String get authUnavailable => 'The service is unavailable. Please try again.'; + + @override + String get authUnexpected => 'Something went wrong. Please try again.'; + + @override + String get checkInbox => 'Check your inbox.'; + + @override + String get verificationSent => + 'We’ve sent you a verification email. Open the link in your inbox to confirm your email address.'; + + @override + String get verificationSending => 'Preparing your verification email…'; + + @override + String get verificationChecking => 'Verifying your email…'; + + @override + String get verificationVerified => 'Your email\nis verified.'; + + @override + String get verificationVerifiedBody => + 'One step done. You can now return to login. Identity verification is a separate step.'; + + @override + String get verificationExpired => 'This link has expired.'; + + @override + String get verificationExpiredBody => + 'Request a new verification email and use the latest link.'; + + @override + String get verificationInvalid => 'This link isn’t valid.'; + + @override + String get verificationInvalidBody => + 'The confirmation link is missing or invalid. Open the complete link from your email, or request a new one.'; + + @override + String get verificationInternal => 'Something went wrong.'; + + @override + String get verificationInternalBody => + 'We couldn’t complete your request because of an internal error. Please try again.'; + + @override + String get verificationResend => 'Resend verification email'; + + @override + String get verificationRequest => 'Send a new verification email'; + + @override + String get verificationRetry => 'Try again'; + + @override + String get verificationBack => 'Back to login'; + + @override + String get verificationHint => + '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'; + + @override + String get verificationStep => 'CONFIRM YOUR EMAIL'; +} diff --git a/frontend/app/lib/generated/i18n/app_localizations_ru.dart b/frontend/app/lib/generated/i18n/app_localizations_ru.dart new file mode 100644 index 0000000..3a76c19 --- /dev/null +++ b/frontend/app/lib/generated/i18n/app_localizations_ru.dart @@ -0,0 +1,223 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; + +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Russian (`ru`). +class AppLocalizationsRu extends AppLocalizations { + AppLocalizationsRu([String locale = 'ru']) : super(locale); + + @override + String get appTitle => 'SXCHNGE · Ваш следующий шаг'; + + @override + String get nextChapter => 'НОВАЯ ГЛАВА'; + + @override + String get loginHeading => 'Рады видеть\nвас снова.'; + + @override + String get signupHeading => 'Сделайте\nпервый шаг.'; + + @override + String get loginIntro => 'Войдите, чтобы сделать следующий шаг.'; + + @override + String get signupIntro => 'Новый аккаунт. Новые возможности.'; + + @override + String get login => 'Войти'; + + @override + String get signup => 'Регистрация'; + + @override + String get name => 'Ваше имя'; + + @override + String get email => 'Электронная почта'; + + @override + String get emailHint => 'you@example.com'; + + @override + String get password => 'Пароль'; + + @override + String get confirmPassword => 'Повторите пароль'; + + @override + String get passwordHint => 'Используйте не менее 12 символов.'; + + @override + String get showPassword => 'Показать пароль'; + + @override + String get hidePassword => 'Скрыть пароль'; + + @override + String get showConfirmation => 'Показать повтор пароля'; + + @override + String get hideConfirmation => 'Скрыть повтор пароля'; + + @override + String get createAccount => 'Создать аккаунт'; + + @override + String get letsGo => 'Продолжить'; + + @override + String get loginPreview => + 'Демонстрационный вход выполнен. Реальная сессия не создана. Учётные данные не отправлены и не сохранены.'; + + @override + String get signupPreview => + 'Демонстрационная регистрация выполнена. Реальный аккаунт не создан. Учётные данные не отправлены и не сохранены.'; + + @override + String get alreadyMember => 'Уже есть аккаунт sxchnge?'; + + @override + String get newHere => 'Впервые здесь?'; + + @override + String get loginInstead => 'Войти в аккаунт'; + + @override + String get createAnAccount => 'Зарегистрироваться'; + + @override + String get earlyPreview => + 'ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ · Вход и регистрация пока недоступны.'; + + @override + String get brandEyebrow => 'МАЛЕНЬКАЯ ПЕРЕМЕНА. БОЛЬШОЙ ШАГ.'; + + @override + String get brandHeading => 'Всё начинается\nс перемен.'; + + @override + String get brandIntro => + 'Новый путь вперёд.\nОткройте его вместе со sxchnge.'; + + @override + String get brandCard => 'Меньше преград.\nБольше возможностей.'; + + @override + String get brandFooter => 'СЛЕДУЮЩИЙ ШАГ — ЗА ВАМИ.'; + + @override + String get emailRequired => 'Введите электронную почту.'; + + @override + String get emailInvalid => 'Введите корректный адрес почты.'; + + @override + String get nameRequired => 'Введите ваше имя.'; + + @override + String get passwordRequired => 'Введите пароль.'; + + @override + String get confirmationRequired => 'Повторите пароль.'; + + @override + String get passwordMismatch => 'Пароли не совпадают.'; + + @override + String get language => 'Язык'; + + @override + String get preferenceNotSaved => + 'Язык изменён для этого сеанса. Сохранить выбор не удалось.'; + + @override + String get authWorking => 'Подождите…'; + + @override + String get authCompleted => 'Запрос выполнен.'; + + @override + String get authInvalidCredentials => + 'Неверная почта или пароль. Попробуйте ещё раз.'; + + @override + String get authEmailInUse => + 'Эта почта уже зарегистрирована. Попробуйте войти.'; + + @override + String get authUnavailable => 'Сервис недоступен. Попробуйте ещё раз.'; + + @override + String get authUnexpected => 'Что-то пошло не так. Попробуйте ещё раз.'; + + @override + String get checkInbox => 'Проверьте почту.'; + + @override + String get verificationSent => + 'Мы отправили вам письмо для подтверждения. Откройте ссылку в письме, чтобы подтвердить электронную почту.'; + + @override + String get verificationSending => 'Готовим письмо для подтверждения…'; + + @override + String get verificationChecking => 'Подтверждаем вашу почту…'; + + @override + String get verificationVerified => 'Ваша почта\nподтверждена.'; + + @override + String get verificationVerifiedBody => + 'Первый шаг пройден. Теперь можно вернуться ко входу. Подтверждение личности — отдельный этап.'; + + @override + String get verificationExpired => 'Срок действия ссылки истёк.'; + + @override + String get verificationExpiredBody => + 'Запросите новое письмо и перейдите по ссылке из него.'; + + @override + String get verificationInvalid => 'Ссылка недействительна.'; + + @override + String get verificationInvalidBody => + 'Ссылка подтверждения отсутствует или недействительна. Откройте полную ссылку из письма или запросите новую.'; + + @override + String get verificationInternal => 'Что-то пошло не так.'; + + @override + String get verificationInternalBody => + 'Не удалось выполнить запрос из-за внутренней ошибки. Попробуйте ещё раз.'; + + @override + String get verificationResend => 'Отправить письмо ещё раз'; + + @override + String get verificationRequest => 'Отправить новое письмо'; + + @override + String get verificationRetry => 'Попробовать ещё раз'; + + @override + String get verificationBack => 'Вернуться ко входу'; + + @override + String get verificationHint => + 'Нет письма? Проверьте папку «Спам» или запросите новое ниже.'; + + @override + String get verificationDemo => + 'ДЕМО · Письмо не отправляется, реальный аккаунт не подтверждается.'; + + @override + String get verificationDemoLink => 'Открыть демоссылку подтверждения'; + + @override + String get verificationStep => 'ПОДТВЕРДИТЕ ПОЧТУ'; +} diff --git a/frontend/app/lib/l10n/en.arb b/frontend/app/lib/l10n/en.arb new file mode 100644 index 0000000..4ad63ce --- /dev/null +++ b/frontend/app/lib/l10n/en.arb @@ -0,0 +1,69 @@ +{ + "@@locale": "en", + "appTitle": "SXCHNGE · Your next move", + "nextChapter": "YOUR NEXT CHAPTER", + "loginHeading": "Hey, welcome\nback.", + "signupHeading": "Make your\nfirst move.", + "loginIntro": "Your next move is just a login away.", + "signupIntro": "A new account. A world of possibility.", + "login": "Log in", + "signup": "Sign up", + "name": "Your name", + "email": "Email address", + "emailHint": "you@example.com", + "password": "Password", + "confirmPassword": "Confirm password", + "passwordHint": "Use at least 12 characters.", + "showPassword": "Show password", + "hidePassword": "Hide password", + "showConfirmation": "Show confirmation", + "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.", + "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.", + "brandCard": "Less friction.\nMore possibility.", + "brandFooter": "YOUR NEXT MOVE STARTS WITH YOU.", + "emailRequired": "Enter your email address.", + "emailInvalid": "Enter a valid email address.", + "nameRequired": "Enter your name.", + "passwordRequired": "Enter your password.", + "confirmationRequired": "Confirm your password.", + "passwordMismatch": "Passwords do not match.", + "language": "Language", + "preferenceNotSaved": "Language changed for this session. Your preference could not be saved.", + "authWorking": "Please wait…", + "authCompleted": "Request completed.", + "authInvalidCredentials": "Email or password is incorrect. Please try again.", + "authEmailInUse": "This email is already registered. Try logging in.", + "authUnavailable": "The service is unavailable. Please try again.", + "authUnexpected": "Something went wrong. Please try again.", + "checkInbox": "Check your inbox.", + "verificationSent": "We’ve sent you a verification email. Open the link in your inbox to confirm your email address.", + "verificationSending": "Preparing your verification email…", + "verificationChecking": "Verifying your email…", + "verificationVerified": "Your email\nis verified.", + "verificationVerifiedBody": "One step done. You can now return to login. Identity verification is a separate step.", + "verificationExpired": "This link has expired.", + "verificationExpiredBody": "Request a new verification email and use the latest link.", + "verificationInvalid": "This link isn’t valid.", + "verificationInvalidBody": "The confirmation link is missing or invalid. Open the complete link from your email, or request a new one.", + "verificationInternal": "Something went wrong.", + "verificationInternalBody": "We couldn’t complete your request because of an internal error. Please try again.", + "verificationResend": "Resend verification email", + "verificationRequest": "Send a new verification email", + "verificationRetry": "Try again", + "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", + "verificationStep": "CONFIRM YOUR EMAIL" +} diff --git a/frontend/app/lib/l10n/ru.arb b/frontend/app/lib/l10n/ru.arb new file mode 100644 index 0000000..63ca5b3 --- /dev/null +++ b/frontend/app/lib/l10n/ru.arb @@ -0,0 +1,69 @@ +{ + "@@locale": "ru", + "appTitle": "SXCHNGE · Ваш следующий шаг", + "nextChapter": "НОВАЯ ГЛАВА", + "loginHeading": "Рады видеть\nвас снова.", + "signupHeading": "Сделайте\nпервый шаг.", + "loginIntro": "Войдите, чтобы сделать следующий шаг.", + "signupIntro": "Новый аккаунт. Новые возможности.", + "login": "Войти", + "signup": "Регистрация", + "name": "Ваше имя", + "email": "Электронная почта", + "emailHint": "you@example.com", + "password": "Пароль", + "confirmPassword": "Повторите пароль", + "passwordHint": "Используйте не менее 12 символов.", + "showPassword": "Показать пароль", + "hidePassword": "Скрыть пароль", + "showConfirmation": "Показать повтор пароля", + "hideConfirmation": "Скрыть повтор пароля", + "createAccount": "Создать аккаунт", + "letsGo": "Продолжить", + "loginPreview": "Демонстрационный вход выполнен. Реальная сессия не создана. Учётные данные не отправлены и не сохранены.", + "signupPreview": "Демонстрационная регистрация выполнена. Реальный аккаунт не создан. Учётные данные не отправлены и не сохранены.", + "alreadyMember": "Уже есть аккаунт sxchnge?", + "newHere": "Впервые здесь?", + "loginInstead": "Войти в аккаунт", + "createAnAccount": "Зарегистрироваться", + "earlyPreview": "ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ · Вход и регистрация пока недоступны.", + "brandEyebrow": "МАЛЕНЬКАЯ ПЕРЕМЕНА. БОЛЬШОЙ ШАГ.", + "brandHeading": "Всё начинается\nс перемен.", + "brandIntro": "Новый путь вперёд.\nОткройте его вместе со sxchnge.", + "brandCard": "Меньше преград.\nБольше возможностей.", + "brandFooter": "СЛЕДУЮЩИЙ ШАГ — ЗА ВАМИ.", + "emailRequired": "Введите электронную почту.", + "emailInvalid": "Введите корректный адрес почты.", + "nameRequired": "Введите ваше имя.", + "passwordRequired": "Введите пароль.", + "confirmationRequired": "Повторите пароль.", + "passwordMismatch": "Пароли не совпадают.", + "language": "Язык", + "preferenceNotSaved": "Язык изменён для этого сеанса. Сохранить выбор не удалось.", + "authWorking": "Подождите…", + "authCompleted": "Запрос выполнен.", + "authInvalidCredentials": "Неверная почта или пароль. Попробуйте ещё раз.", + "authEmailInUse": "Эта почта уже зарегистрирована. Попробуйте войти.", + "authUnavailable": "Сервис недоступен. Попробуйте ещё раз.", + "authUnexpected": "Что-то пошло не так. Попробуйте ещё раз.", + "checkInbox": "Проверьте почту.", + "verificationSent": "Мы отправили вам письмо для подтверждения. Откройте ссылку в письме, чтобы подтвердить электронную почту.", + "verificationSending": "Готовим письмо для подтверждения…", + "verificationChecking": "Подтверждаем вашу почту…", + "verificationVerified": "Ваша почта\nподтверждена.", + "verificationVerifiedBody": "Первый шаг пройден. Теперь можно вернуться ко входу. Подтверждение личности — отдельный этап.", + "verificationExpired": "Срок действия ссылки истёк.", + "verificationExpiredBody": "Запросите новое письмо и перейдите по ссылке из него.", + "verificationInvalid": "Ссылка недействительна.", + "verificationInvalidBody": "Ссылка подтверждения отсутствует или недействительна. Откройте полную ссылку из письма или запросите новую.", + "verificationInternal": "Что-то пошло не так.", + "verificationInternalBody": "Не удалось выполнить запрос из-за внутренней ошибки. Попробуйте ещё раз.", + "verificationResend": "Отправить письмо ещё раз", + "verificationRequest": "Отправить новое письмо", + "verificationRetry": "Попробовать ещё раз", + "verificationBack": "Вернуться ко входу", + "verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.", + "verificationDemo": "ДЕМО · Письмо не отправляется, реальный аккаунт не подтверждается.", + "verificationDemoLink": "Открыть демоссылку подтверждения", + "verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ" +} diff --git a/frontend/app/lib/main.dart b/frontend/app/lib/main.dart new file mode 100644 index 0000000..06c501e --- /dev/null +++ b/frontend/app/lib/main.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_auth/sxchnge_auth.dart'; + +import 'package:sxchnge_theme/sxchnge_theme.dart'; + +import 'providers/locale.dart'; +import 'routing/router.dart'; + +import 'generated/i18n/app_localizations.dart'; + +Future main() async { + final binding = WidgetsFlutterBinding.ensureInitialized(); + final locale = await LocaleProvider.load(binding.platformDispatcher.locales); + runApp(SxchngeApp(localeProvider: locale)); +} + +class SxchngeApp extends StatefulWidget { + const SxchngeApp({ + super.key, + this.localeProvider, + this.loginService = const DummyLoginService(), + this.signupService = const DummySignupService(), + this.verificationService = const DummyVerificationService(), + }); + final LocaleProvider? localeProvider; + final LoginService loginService; + final SignupService signupService; + final VerificationService verificationService; + + @override + State createState() => _SxchngeAppState(); +} + +class _SxchngeAppState extends State { + late final _locale = widget.localeProvider ?? LocaleProvider(); + + late final _router = createAppRouter(); + + @override + void dispose() { + _router.dispose(); + _locale.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: _locale), + Provider.value(value: widget.verificationService), + ChangeNotifierProvider(create: (_) => LoginProvider(widget.loginService)), + ChangeNotifierProvider( + create: (_) => SignupProvider(widget.signupService), + ), + ], + child: Consumer( + builder: (context, locale, _) => MaterialApp.router( + onGenerateTitle: (context) => AppLocalizations.of(context)!.appTitle, + debugShowCheckedModeBanner: false, + theme: SxchngeTheme.light(), + locale: locale.locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: _router, + ), + ), + ); +} diff --git a/frontend/app/lib/providers/locale.dart b/frontend/app/lib/providers/locale.dart new file mode 100644 index 0000000..2798933 --- /dev/null +++ b/frontend/app/lib/providers/locale.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import 'package:shared_preferences/shared_preferences.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; + final SharedPreferences? preferences; + Future _pendingSave = Future.value(true); + Locale get locale => _locale; + + static Locale resolve(String? saved, List systemLocales) { + final code = + saved?.split(RegExp('[-_]')).first ?? + (systemLocales.isEmpty ? 'en' : systemLocales.first.languageCode); + return Locale(code == 'ru' ? 'ru' : 'en'); + } + + static Future load(List systemLocales) async { + try { + final prefs = await SharedPreferences.getInstance(); + return LocaleProvider( + initialLocale: resolve(prefs.getString(storageKey), systemLocales), + preferences: prefs, + ); + } catch (_) { + return LocaleProvider(initialLocale: resolve(null, systemLocales)); + } + } + + Future setLocale(Locale value) { + final next = resolve(value.languageCode, const []); + if (next == _locale) return _pendingSave; + _locale = next; + notifyListeners(); + // Serialize writes so rapid language changes persist the last selection. + _pendingSave = _pendingSave.then((_) async { + try { + return await preferences?.setString(storageKey, next.languageCode) ?? + false; + } catch (_) { + return false; + } + }); + return _pendingSave; + } +} diff --git a/frontend/app/lib/routing/router.dart b/frontend/app/lib/routing/router.dart new file mode 100644 index 0000000..0cfd021 --- /dev/null +++ b/frontend/app/lib/routing/router.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; + +import 'package:go_router/go_router.dart'; + +import '../auth/page.dart'; +import '../verification/page.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, + ), + ), + 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(); + @override + Converter get encoder => const _DiscardExtra(); + @override + Converter get decoder => const _DiscardExtra(); +} + +class _DiscardExtra extends Converter { + const _DiscardExtra(); + @override + Object? convert(Object? input) => null; +} diff --git a/frontend/app/lib/routing/routes.dart b/frontend/app/lib/routing/routes.dart new file mode 100644 index 0000000..c97a4d4 --- /dev/null +++ b/frontend/app/lib/routing/routes.dart @@ -0,0 +1,5 @@ +abstract final class AppRoutes { + 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 new file mode 100644 index 0000000..28d5acb --- /dev/null +++ b/frontend/app/lib/verification/page.dart @@ -0,0 +1,279 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +import 'package:provider/provider.dart'; + +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'; + +class VerificationPage extends StatefulWidget { + const VerificationPage({super.key, this.email, this.token}); + final String? email; + final String? token; + + @override + State createState() => _VerificationPageState(); +} + +class _VerificationPageState extends State { + final _form = GlobalKey(); + late final _email = TextEditingController(text: widget.email); + late final _provider = VerificationProvider( + context.read(), + ); + bool _initialized = false; + bool _lastWasSend = false; + bool _validated = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_initialized) { + _initialized = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (widget.token != null) { + _provider.confirm(widget.token!); + } else if (_email.text.isNotEmpty) { + _send(); + } + }); + } else if (_validated) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _form.currentState?.validate(); + }); + } + } + + @override + void didUpdateWidget(covariant VerificationPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.token == widget.token) return; + _provider.reset(); + _lastWasSend = false; + _validated = false; + _email.clear(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && widget.token != null) _provider.confirm(widget.token!); + }); + } + + void _send() { + if (_provider.state.isLoading) return; + _validated = true; + if (!(_form.currentState?.validate() ?? false)) return; + _lastWasSend = true; + _provider.send( + email: _email.text, + locale: Localizations.localeOf(context).languageCode, + ); + } + + @override + void dispose() { + _provider.dispose(); + _email.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: _provider, + builder: (context, _) { + final l = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final colors = theme.colorScheme; + final state = _provider.state; + final status = state.status; + final failed = [ + VerificationStatus.expired, + VerificationStatus.invalid, + VerificationStatus.error, + ].contains(status); + final verified = status == VerificationStatus.verified; + final demo = + _provider.service is DummyVerificationService || state.isSimulated; + final title = switch (status) { + VerificationStatus.sent => l.checkInbox, + VerificationStatus.sending => l.verificationSending, + VerificationStatus.checking => l.verificationChecking, + VerificationStatus.verified => l.verificationVerified, + VerificationStatus.expired => l.verificationExpired, + VerificationStatus.invalid => l.verificationInvalid, + VerificationStatus.error => l.verificationInternal, + VerificationStatus.idle => + widget.token != null ? l.verificationChecking : l.verificationRequest, + }; + final body = switch (status) { + VerificationStatus.sent => l.verificationSent, + VerificationStatus.verified => l.verificationVerifiedBody, + VerificationStatus.expired => l.verificationExpiredBody, + VerificationStatus.invalid => l.verificationInvalidBody, + VerificationStatus.error => l.verificationInternalBody, + _ => null, + }; + return Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Align( + alignment: Alignment.centerRight, + child: LocaleSelector(), + ), + const Align( + alignment: Alignment.centerLeft, + child: BrandWordmark(), + ), + const SizedBox(height: 48), + Align( + alignment: Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: failed + ? colors.errorContainer + : colors.primaryContainer, + borderRadius: BorderRadius.circular(24), + ), + child: state.isLoading + ? const SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator(), + ) + : Icon( + verified + ? Icons.mark_email_read_outlined + : failed + ? Icons.error_outline + : Icons.mark_email_unread_outlined, + size: 32, + color: failed + ? colors.onErrorContainer + : colors.onPrimaryContainer, + ), + ), + ), + const SizedBox(height: 28), + Text( + l.verificationStep, + style: theme.textTheme.labelLarge?.copyWith( + color: colors.primary, + ), + ), + const SizedBox(height: 12), + Semantics( + liveRegion: true, + child: Text( + title, + key: ValueKey('verification-${status.name}'), + style: theme.textTheme.displaySmall, + ), + ), + if (body != null) ...[ + const SizedBox(height: 20), + Text( + body, + style: theme.textTheme.bodyLarge?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + if (status == VerificationStatus.sent) ...[ + const SizedBox(height: 16), + Text( + l.verificationHint, + style: theme.textTheme.bodyMedium, + ), + ], + if (!verified && status != VerificationStatus.checking) ...[ + const SizedBox(height: 28), + Form( + key: _form, + child: TextFormField( + key: const ValueKey('verification-email'), + controller: _email, + enabled: !state.isLoading, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + textInputAction: TextInputAction.send, + validator: (value) => AuthValidation.email(value, l), + onFieldSubmitted: (_) => _send(), + decoration: InputDecoration( + labelText: l.email, + hintText: l.emailHint, + errorMaxLines: 3, + prefixIcon: const Icon(Icons.alternate_email), + ), + ), + ), + const SizedBox(height: 16), + FilledButton( + key: const ValueKey('verification-resend'), + onPressed: state.isLoading ? null : _send, + child: Text( + state.isLoading + ? l.authWorking + : status == VerificationStatus.sent + ? l.verificationResend + : l.verificationRequest, + textAlign: TextAlign.center, + ), + ), + ], + if (status == VerificationStatus.error && + !_lastWasSend && + widget.token != null) ...[ + const SizedBox(height: 12), + OutlinedButton( + key: const ValueKey('verification-retry'), + onPressed: () => _provider.confirm(widget.token!), + child: Text(l.verificationRetry), + ), + ], + const SizedBox(height: 16), + TextButton( + onPressed: () => context.goNamed(AppRoutes.login), + child: Text(l.verificationBack), + ), + if (demo) ...[ + const SizedBox(height: 24), + Text( + l.verificationDemo, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + if (status == VerificationStatus.sent) + TextButton( + key: const ValueKey('demo-confirmation'), + onPressed: () => context.goNamed( + AppRoutes.verifyEmail, + queryParameters: {'token': 'demo-valid'}, + ), + child: Text(l.verificationDemoLink), + ), + ], + ], + ), + ), + ), + ), + ), + ); + }, + ); +} diff --git a/frontend/app/lib/widgets/locale.dart b/frontend/app/lib/widgets/locale.dart new file mode 100644 index 0000000..0e02876 --- /dev/null +++ b/frontend/app/lib/widgets/locale.dart @@ -0,0 +1,54 @@ +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/pubspec.lock b/frontend/app/pubspec.lock new file mode 100644 index 0000000..0374cb1 --- /dev/null +++ b/frontend/app/pubspec.lock @@ -0,0 +1,421 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + cupertino_ui: + dependency: transitive + description: + name: cupertino_ui + sha256: e9dfe7fac704028f8928cbe4028a0be5e8a709498e8daf8247de99e99a32aef3 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "008ab21d89d0de8ccd79586838f7533f126c2d894f50bf25f2947888d3c225b5" + url: "https://pub.dev" + source: hosted + version: "18.0.1" + intl: + dependency: "direct main" + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + material_ui: + dependency: transitive + description: + name: material_ui + sha256: fbfb53cab6c4629438feeade5f3d305f72944bc9d9f25786b19106d32b80ec45 + url: "https://pub.dev" + source: hosted + version: "1.2.0" + meta: + dependency: transitive + description: + name: meta + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + url: "https://pub.dev" + source: hosted + version: "1.18.3" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.dev" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + sxchnge_auth: + dependency: "direct main" + description: + path: "../packages/sxchnge_auth" + relative: true + source: path + version: "0.1.0" + sxchnge_theme: + dependency: "direct main" + description: + path: "../packages/sxchnge_theme" + relative: true + source: path + version: "0.1.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.13.0 <4.0.0" + flutter: ">=3.47.0" diff --git a/frontend/app/pubspec.yaml b/frontend/app/pubspec.yaml new file mode 100644 index 0000000..97eec43 --- /dev/null +++ b/frontend/app/pubspec.yaml @@ -0,0 +1,28 @@ +name: sxchnge_app +description: SXCHNGE consumer application. +publish_to: none +version: 0.1.0+1 +environment: + sdk: '>=3.13.0 <4.0.0' + flutter: '>=3.47.0' +dependencies: + flutter: + sdk: flutter + sxchnge_auth: + path: ../packages/sxchnge_auth + sxchnge_theme: + path: ../packages/sxchnge_theme + flutter_localizations: + sdk: flutter + intl: any + provider: ^6.1.5 + shared_preferences: ^2.5.3 + cupertino_icons: ^1.0.8 + go_router: ^18.0.1 +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 +flutter: + generate: true + uses-material-design: true diff --git a/frontend/app/test/auth_services_test.dart b/frontend/app/test/auth_services_test.dart new file mode 100644 index 0000000..1a91fd6 --- /dev/null +++ b/frontend/app/test/auth_services_test.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sxchnge_auth/sxchnge_auth.dart'; + +import 'package:sxchnge_app/main.dart'; +import 'package:sxchnge_app/providers/locale.dart'; + +class ControlledLogin implements LoginService { + final calls = >[]; + LoginRequest? request; + @override + Future login(LoginRequest input) { + request = input; + final completer = Completer(); + calls.add(completer); + return completer.future; + } +} + +void main() { + testWidgets( + 'app shows loading, localized failure, and retries through injected service', + (tester) async { + tester.view.physicalSize = const Size(1440, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final service = ControlledLogin(); + await tester.pumpWidget( + SxchngeApp( + loginService: service, + localeProvider: LocaleProvider(initialLocale: const Locale('ru')), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('email')), + 'a@example.com', + ); + await tester.enterText( + find.byKey(const ValueKey('password')), + 'example-password', + ); + final button = find.byKey(const ValueKey('submit')); + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pump(); + expect(tester.widget(button).onPressed, isNull); + expect(find.text('Подождите…'), findsOneWidget); + expect(service.request!.locale, 'ru'); + service.calls.single.completeError( + const AuthException(AuthError.unavailable), + ); + await tester.pumpAndSettle(); + expect( + find.text('Сервис недоступен. Попробуйте ещё раз.'), + findsOneWidget, + ); + await tester.tap(button); + await tester.pump(); + expect(service.calls, hasLength(2)); + service.calls.last.complete( + const AuthResult(userId: 'demo', isSimulated: true), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('preview-result')), findsOneWidget); + expect(find.byKey(const ValueKey('auth-error')), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('changing mode while loading ignores the old login result', ( + tester, + ) async { + final service = ControlledLogin(); + await tester.pumpWidget(SxchngeApp(loginService: service)); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('email')), + 'a@example.com', + ); + await tester.enterText( + find.byKey(const ValueKey('password')), + 'example-password', + ); + await tester.ensureVisible(find.byKey(const ValueKey('submit'))); + await tester.tap(find.byKey(const ValueKey('submit'))); + await tester.pump(); + await tester.ensureVisible(find.text('Sign up')); + await tester.tap(find.text('Sign up')); + await tester.pumpAndSettle(); + service.calls.single.complete( + const AuthResult(userId: 'demo', isSimulated: true), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('confirmation')), findsOneWidget); + expect(find.byKey(const ValueKey('preview-result')), findsNothing); + expect(tester.takeException(), isNull); + }); +} diff --git a/frontend/app/test/auth_test.dart b/frontend/app/test/auth_test.dart new file mode 100644 index 0000000..ffc947b --- /dev/null +++ b/frontend/app/test/auth_test.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sxchnge_app/auth/validation.dart'; +import 'package:sxchnge_app/main.dart'; + +import 'package:sxchnge_app/generated/i18n/app_localizations_en.dart'; + +Future openApp( + WidgetTester tester, { + Size size = const Size(390, 844), + double scale = 1, +}) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = scale; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + await tester.pumpWidget(const SxchngeApp()); + await tester.pumpAndSettle(); +} + +Future submit(WidgetTester tester) async { + final button = find.byKey(const ValueKey('submit')); + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pumpAndSettle(); +} + +void main() { + final l = AppLocalizationsEn(); + test( + 'validation rejects malformed email and mismatched signup passwords', + () { + for (final email in ['', 'user', 'user@', 'user @example.com']) { + expect(AuthValidation.email(email, l), isNotNull); + } + expect(AuthValidation.email(' user+tag@example.com ', l), isNull); + expect(AuthValidation.password('short', l, signUp: true), isNotNull); + expect(AuthValidation.password('short', l, signUp: false), isNull); + expect( + AuthValidation.confirmation('different', 'password', l), + isNotNull, + ); + expect(AuthValidation.name(' ', l), isNotNull); + }, + ); + + testWidgets( + 'login validates, toggles password, and submits with the keyboard', + (tester) async { + await openApp(tester); + await submit(tester); + expect(find.text('Enter your email address.'), findsOneWidget); + expect(find.text('Enter your password.'), findsOneWidget); + await tester.enterText( + find.byKey(const ValueKey('email')), + 'test@example.com', + ); + await tester.enterText( + find.byKey(const ValueKey('password')), + 'example-password', + ); + await tester.tap(find.byTooltip('Show password')); + await tester.pump(); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const ValueKey('password')), + matching: find.byType(TextField), + ), + ) + .obscureText, + isFalse, + ); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('preview-result')), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'signup validates confirmation and clears credentials when switching', + (tester) async { + await openApp(tester); + await tester.tap(find.text('Sign up')); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('name')), 'Alex'); + await tester.enterText( + find.byKey(const ValueKey('email')), + 'alex@example.com', + ); + await tester.enterText( + find.byKey(const ValueKey('password')), + 'example-password', + ); + await tester.enterText( + find.byKey(const ValueKey('confirmation')), + 'different', + ); + await submit(tester); + expect(find.text('Passwords do not match.'), findsOneWidget); + await tester.enterText( + find.byKey(const ValueKey('confirmation')), + 'example-password', + ); + await submit(tester); + expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget); + await tester.ensureVisible( + find.byKey(const ValueKey('demo-confirmation')), + ); + await tester.tap(find.byKey(const ValueKey('demo-confirmation'))); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('verification-verified')), + findsOneWidget, + ); + await tester.ensureVisible(find.text('Back to login')); + await tester.tap(find.text('Back to login')); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('confirmation')), findsNothing); + expect(find.text('example-password'), findsNothing); + expect(find.byKey(const ValueKey('preview-result')), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('resizing preserves the selected mode and entered values', ( + tester, + ) async { + await openApp(tester, size: const Size(1440, 1000)); + await tester.tap(find.text('Sign up')); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('email')), + 'alex@example.com', + ); + tester.view.physicalSize = const Size(390, 844); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('name')), findsOneWidget); + expect(find.text('alex@example.com'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + for (final size in [const Size(320, 740), const Size(1440, 1000)]) { + testWidgets('both modes fit $size at 200% text scale', (tester) async { + await openApp(tester, size: size, scale: 2); + expect(tester.takeException(), isNull); + await tester.ensureVisible(find.text('Sign up')); + await tester.tap(find.text('Sign up')); + await tester.pumpAndSettle(); + await submit(tester); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/frontend/app/test/localization_test.dart b/frontend/app/test/localization_test.dart new file mode 100644 index 0000000..2aa8f3e --- /dev/null +++ b/frontend/app/test/localization_test.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:sxchnge_app/main.dart'; +import 'package:sxchnge_app/providers/locale.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('saved choice overrides system locale, with English fallback', () async { + expect( + LocaleProvider.resolve(null, [const Locale('ru', 'RU')]), + const Locale('ru'), + ); + expect( + LocaleProvider.resolve('en', [const Locale('ru')]), + const Locale('en'), + ); + expect( + LocaleProvider.resolve('ru-RU', [const Locale('en')]), + const Locale('ru'), + ); + expect( + LocaleProvider.resolve('de', [const Locale('ru')]), + const Locale('en'), + ); + expect( + LocaleProvider.resolve(null, [const Locale('fr')]), + const Locale('en'), + ); + expect(LocaleProvider.resolve(null, []), const Locale('en')); + final first = await LocaleProvider.load([const Locale('en')]); + await first.setLocale(const Locale('ru')); + final restarted = await LocaleProvider.load([const Locale('en')]); + expect(restarted.locale, const Locale('ru')); + await Future.wait([ + first.setLocale(const Locale('en')), + first.setLocale(const Locale('ru')), + ]); + expect( + (await SharedPreferences.getInstance()).getString( + LocaleProvider.storageKey, + ), + 'ru', + ); + first.dispose(); + restarted.dispose(); + }); + + test('catalogs have equal keys and no empty translations', () { + final en = jsonDecode(File('lib/l10n/en.arb').readAsStringSync()) as Map; + final ru = jsonDecode(File('lib/l10n/ru.arb').readAsStringSync()) as Map; + expect(ru.keys.toSet(), en.keys.toSet()); + expect( + ru.values.every((value) => value is String && value.trim().isNotEmpty), + isTrue, + ); + }); + + testWidgets( + 'switching language updates errors and keeps form data and mode', + (tester) async { + tester.view.physicalSize = const Size(1440, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final locale = await LocaleProvider.load([const Locale('en')]); + await tester.pumpWidget(SxchngeApp(localeProvider: locale)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sign up')); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('name')), 'Алексей'); + await tester.enterText(find.byKey(const ValueKey('email')), 'invalid'); + await tester.ensureVisible(find.byKey(const ValueKey('submit'))); + 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 tester.pumpAndSettle(); + expect(find.text('Введите корректный адрес почты.'), findsOneWidget); + expect(find.text('Алексей'), findsOneWidget); + expect(find.text('invalid'), findsOneWidget); + expect(find.byKey(const ValueKey('confirmation')), findsOneWidget); + expect(find.byTooltip('Показать пароль'), findsOneWidget); + expect( + (await SharedPreferences.getInstance()).getString( + LocaleProvider.storageKey, + ), + 'ru', + ); + expect(tester.takeException(), isNull); + }, + ); + + for (final width in [320.0, 1440.0]) { + testWidgets('Russian forms fit at width $width with 200% text', ( + tester, + ) async { + tester.view.physicalSize = Size(width, 1000); + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + await tester.pumpWidget( + SxchngeApp( + localeProvider: LocaleProvider(initialLocale: const Locale('ru')), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.ensureVisible(find.text('Регистрация')); + await tester.tap(find.text('Регистрация')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.byKey(const ValueKey('submit'))); + await tester.tap(find.byKey(const ValueKey('submit'))); + await tester.pumpAndSettle(); + expect(find.text('Введите ваше имя.'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/frontend/app/test/routing_test.dart b/frontend/app/test/routing_test.dart new file mode 100644 index 0000000..6d8d645 --- /dev/null +++ b/frontend/app/test/routing_test.dart @@ -0,0 +1,157 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:go_router/go_router.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'; +import 'package:sxchnge_app/routing/routes.dart'; +import 'package:sxchnge_app/verification/page.dart'; + +class PendingConfirmation implements VerificationService { + final results = >{}; + @override + Future confirm({required String token}) => + (results[token] = Completer()).future; + @override + Future send({ + required String email, + required String locale, + }) async => const VerificationResult(isSimulated: true); +} + +Future openRoute( + WidgetTester tester, + String route, { + VerificationService service = const DummyVerificationService( + delay: Duration.zero, + ), +}) async { + tester.platformDispatcher.defaultRouteNameTestValue = route; + addTearDown(tester.platformDispatcher.clearDefaultRouteNameTestValue); + await tester.pumpWidget(SxchngeApp(verificationService: service)); + await tester.pump(); + return GoRouter.of(tester.element(find.byType(Scaffold).last)); +} + +void main() { + testWidgets('unknown deep link redirects to canonical login URL', ( + tester, + ) async { + final router = await openRoute(tester, '/unknown?token=private'); + await tester.pumpAndSettle(); + expect(router.routeInformationProvider.value.uri.toString(), '/'); + expect(find.byKey(const ValueKey('email')), findsOneWidget); + }); + + testWidgets( + 'changing confirmation token ignores the previous pending result', + (tester) async { + final service = PendingConfirmation(); + final router = await openRoute( + tester, + '/verify-email?token=first', + service: service, + ); + await tester.pump(); + expect(service.results.containsKey('first'), isTrue); + router.goNamed( + AppRoutes.verifyEmail, + queryParameters: {'token': 'second'}, + ); + await tester.pump(); + await tester.pump(); + service.results['second']!.completeError( + const VerificationException(VerificationError.expired), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('verification-expired')), + findsOneWidget, + ); + service.results['first']!.complete( + const VerificationResult(isSimulated: true), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('verification-expired')), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('verification-verified')), findsNothing); + }, + ); + + testWidgets( + 'platform back and forward route updates recheck confirmation tokens', + (tester) async { + final router = await openRoute(tester, '/verify-email?token=demo-valid'); + await tester.pumpAndSettle(); + await router.routeInformationProvider.didPushRouteInformation( + RouteInformation(uri: Uri.parse('/verify-email?token=demo-expired')), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('verification-expired')), + findsOneWidget, + ); + await router.routeInformationProvider.didPushRouteInformation( + RouteInformation(uri: Uri.parse('/verify-email?token=demo-valid')), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('verification-verified')), + findsOneWidget, + ); + expect( + router.routeInformationProvider.value.uri.queryParameters['token'], + 'demo-valid', + ); + }, + ); + + testWidgets('locale rebuild preserves router, URL and confirmation state', ( + tester, + ) async { + final router = await openRoute(tester, '/verify-email?token=demo-valid'); + await tester.pumpAndSettle(); + final context = tester.element(find.byType(VerificationPage)); + await context.read().setLocale(const Locale('ru')); + await tester.pumpAndSettle(); + expect( + GoRouter.of(tester.element(find.byType(VerificationPage))), + same(router), + ); + expect(find.byKey(const ValueKey('verification-verified')), findsOneWidget); + expect(find.text('Ваша почта\nподтверждена.'), findsOneWidget); + }); + + testWidgets( + 'signup email stays in memory and is excluded from serialized history', + (tester) async { + final router = await openRoute(tester, '/'); + await tester.pumpAndSettle(); + router.goNamed(AppRoutes.verificationSent, extra: 'alex@example.com'); + await tester.pumpAndSettle(); + expect(find.text('alex@example.com'), findsOneWidget); + expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget); + final restored = router.routeInformationParser.restoreRouteInformation( + router.routerDelegate.currentConfiguration, + )!; + expect(restored.state.toString(), isNot(contains('alex@example.com'))); + expect(restored.uri.toString(), '/verification-sent'); + router.goNamed(AppRoutes.login); + await tester.pumpAndSettle(); + router.goNamed(AppRoutes.verificationSent); + await tester.pumpAndSettle(); + expect(find.text('alex@example.com'), findsNothing); + expect(find.byKey(const ValueKey('verification-idle')), findsOneWidget); + }, + ); +} diff --git a/frontend/app/test/verification_test.dart b/frontend/app/test/verification_test.dart new file mode 100644 index 0000000..28744bb --- /dev/null +++ b/frontend/app/test/verification_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sxchnge_auth/sxchnge_auth.dart'; + +import 'package:sxchnge_app/main.dart'; + +class RecoveringService implements VerificationService { + int sends = 0; + int confirmations = 0; + @override + Future send({ + required String email, + required String locale, + }) async { + if (++sends == 1) throw StateError('private error'); + return const VerificationResult(isSimulated: true); + } + + @override + Future confirm({required String token}) async { + if (++confirmations == 1) throw StateError('private error'); + return const VerificationResult(isSimulated: true); + } +} + +Future openVerification( + WidgetTester tester, + String route, { + VerificationService service = const DummyVerificationService( + delay: Duration.zero, + ), +}) async { + tester.platformDispatcher.defaultRouteNameTestValue = route; + addTearDown(tester.platformDispatcher.clearDefaultRouteNameTestValue); + await tester.pumpWidget(SxchngeApp(verificationService: service)); + await tester.pumpAndSettle(); +} + +void main() { + for (final scenario in { + 'demo-valid': 'verified', + 'demo-expired': 'expired', + 'wrong': 'invalid', + '': 'invalid', + 'demo-internal': 'error', + }.entries) { + testWidgets( + 'confirmation link resolves ${scenario.key} as ${scenario.value}', + (tester) async { + await openVerification(tester, '/verify-email?token=${scenario.key}'); + expect( + find.byKey(ValueKey('verification-${scenario.value}')), + findsOneWidget, + ); + expect(find.textContaining('DEMO'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets('internal confirmation error can retry without entering email', ( + tester, + ) async { + final service = RecoveringService(); + await openVerification( + tester, + '/verify-email?token=opaque', + service: service, + ); + expect(find.text('private error'), findsNothing); + final retry = find.byKey(const ValueKey('verification-retry')); + await tester.ensureVisible(retry); + await tester.tap(retry); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('verification-verified')), findsOneWidget); + expect(service.confirmations, 2); + }); + testWidgets( + 'request validates email, reports send error and resends successfully', + (tester) async { + final service = RecoveringService(); + await openVerification(tester, '/verification-sent', service: service); + final resend = find.byKey(const ValueKey('verification-resend')); + await tester.ensureVisible(resend); + await tester.tap(resend); + await tester.pumpAndSettle(); + expect(find.text('Enter your email address.'), findsOneWidget); + expect(service.sends, 0); + await tester.enterText( + find.byKey(const ValueKey('verification-email')), + 'alex@example.com', + ); + await tester.ensureVisible(resend); + await tester.tap(resend); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('verification-error')), findsOneWidget); + await tester.ensureVisible(resend); + await tester.tap(resend); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget); + expect(service.sends, 2); + }, + ); + for (final route in [ + '/verification-sent', + '/verify-email?token=demo-valid', + '/verify-email?token=demo-expired', + '/verify-email?token=bad', + '/verify-email?token=demo-internal', + ]) { + testWidgets('$route fits narrow viewport at 200% in EN and RU', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 740); + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + await openVerification(tester, route); + expect(tester.takeException(), isNull); + 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 tester.pumpAndSettle(); + expect(find.text('RU'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/frontend/app/untranslated.txt b/frontend/app/untranslated.txt new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/frontend/app/untranslated.txt @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/frontend/app/web/favicon.png b/frontend/app/web/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8aaa46ac1ae21512746f852a42ba87e4165dfdd1 GIT binary patch literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM literal 0 HcmV?d00001 diff --git a/frontend/app/web/favicon.svg b/frontend/app/web/favicon.svg new file mode 100644 index 0000000..053a344 --- /dev/null +++ b/frontend/app/web/favicon.svg @@ -0,0 +1 @@ + diff --git a/frontend/app/web/icons/Icon-192.png b/frontend/app/web/icons/Icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b749bfef07473333cf1dd31e9eed89862a5d52aa GIT binary patch literal 5292 zcmZ`-2T+sGz6~)*FVZ`aW+(v>MIm&M-g^@e2u-B-DoB?qO+b1Tq<5uCCv>ESfRum& zp%X;f!~1{tzL__3=gjVJ=j=J>+nMj%ncXj1Q(b|Ckbw{Y0FWpt%4y%$uD=Z*c-x~o zE;IoE;xa#7Ll5nj-e4CuXB&G*IM~D21rCP$*xLXAK8rIMCSHuSu%bL&S3)8YI~vyp@KBu9Ph7R_pvKQ@xv>NQ`dZp(u{Z8K3yOB zn7-AR+d2JkW)KiGx0hosml;+eCXp6+w%@STjFY*CJ?udJ64&{BCbuebcuH;}(($@@ znNlgBA@ZXB)mcl9nbX#F!f_5Z=W>0kh|UVWnf!At4V*LQP%*gPdCXd6P@J4Td;!Ur z<2ZLmwr(NG`u#gDEMP19UcSzRTL@HsK+PnIXbVBT@oHm53DZr?~V(0{rsalAfwgo zEh=GviaqkF;}F_5-yA!1u3!gxaR&Mj)hLuj5Q-N-@Lra{%<4ONja8pycD90&>yMB` zchhd>0CsH`^|&TstH-8+R`CfoWqmTTF_0?zDOY`E`b)cVi!$4xA@oO;SyOjJyP^_j zx^@Gdf+w|FW@DMdOi8=4+LJl$#@R&&=UM`)G!y%6ZzQLoSL%*KE8IO0~&5XYR9 z&N)?goEiWA(YoRfT{06&D6Yuu@Qt&XVbuW@COb;>SP9~aRc+z`m`80pB2o%`#{xD@ zI3RAlukL5L>px6b?QW1Ac_0>ew%NM!XB2(H+1Y3AJC?C?O`GGs`331Nd4ZvG~bMo{lh~GeL zSL|tT*fF-HXxXYtfu5z+T5Mx9OdP7J4g%@oeC2FaWO1D{=NvL|DNZ}GO?O3`+H*SI z=grGv=7dL{+oY0eJFGO!Qe(e2F?CHW(i!!XkGo2tUvsQ)I9ev`H&=;`N%Z{L zO?vV%rDv$y(@1Yj@xfr7Kzr<~0{^T8wM80xf7IGQF_S-2c0)0D6b0~yD7BsCy+(zL z#N~%&e4iAwi4F$&dI7x6cE|B{f@lY5epaDh=2-(4N05VO~A zQT3hanGy_&p+7Fb^I#ewGsjyCEUmSCaP6JDB*=_()FgQ(-pZ28-{qx~2foO4%pM9e z*_63RT8XjgiaWY|*xydf;8MKLd{HnfZ2kM%iq}fstImB-K6A79B~YoPVa@tYN@T_$ zea+9)<%?=Fl!kd(Y!G(-o}ko28hg2!MR-o5BEa_72uj7Mrc&{lRh3u2%Y=Xk9^-qa zBPWaD=2qcuJ&@Tf6ue&)4_V*45=zWk@Z}Q?f5)*z)-+E|-yC4fs5CE6L_PH3=zI8p z*Z3!it{1e5_^(sF*v=0{`U9C741&lub89gdhKp|Y8CeC{_{wYK-LSbp{h)b~9^j!s z7e?Y{Z3pZv0J)(VL=g>l;<}xk=T*O5YR|hg0eg4u98f2IrA-MY+StQIuK-(*J6TRR z|IM(%uI~?`wsfyO6Tgmsy1b3a)j6M&-jgUjVg+mP*oTKdHg?5E`!r`7AE_#?Fc)&a z08KCq>Gc=ne{PCbRvs6gVW|tKdcE1#7C4e`M|j$C5EYZ~Y=jUtc zj`+?p4ba3uy7><7wIokM79jPza``{Lx0)zGWg;FW1^NKY+GpEi=rHJ+fVRGfXO zPHV52k?jxei_!YYAw1HIz}y8ZMwdZqU%ESwMn7~t zdI5%B;U7RF=jzRz^NuY9nM)&<%M>x>0(e$GpU9th%rHiZsIT>_qp%V~ILlyt^V`=d z!1+DX@ah?RnB$X!0xpTA0}lN@9V-ePx>wQ?-xrJr^qDlw?#O(RsXeAvM%}rg0NT#t z!CsT;-vB=B87ShG`GwO;OEbeL;a}LIu=&@9cb~Rsx(ZPNQ!NT7H{@j0e(DiLea>QD zPmpe90gEKHEZ8oQ@6%E7k-Ptn#z)b9NbD@_GTxEhbS+}Bb74WUaRy{w;E|MgDAvHw zL)ycgM7mB?XVh^OzbC?LKFMotw3r@i&VdUV%^Efdib)3@soX%vWCbnOyt@Y4swW925@bt45y0HY3YI~BnnzZYrinFy;L?2D3BAL`UQ zEj))+f>H7~g8*VuWQ83EtGcx`hun$QvuurSMg3l4IP8Fe`#C|N6mbYJ=n;+}EQm;< z!!N=5j1aAr_uEnnzrEV%_E|JpTb#1p1*}5!Ce!R@d$EtMR~%9# zd;h8=QGT)KMW2IKu_fA_>p_und#-;Q)p%%l0XZOXQicfX8M~7?8}@U^ihu;mizj)t zgV7wk%n-UOb z#!P5q?Ex+*Kx@*p`o$q8FWL*E^$&1*!gpv?Za$YO~{BHeGY*5%4HXUKa_A~~^d z=E*gf6&+LFF^`j4$T~dR)%{I)T?>@Ma?D!gi9I^HqvjPc3-v~=qpX1Mne@*rzT&Xw zQ9DXsSV@PqpEJO-g4A&L{F&;K6W60D!_vs?Vx!?w27XbEuJJP&);)^+VF1nHqHBWu z^>kI$M9yfOY8~|hZ9WB!q-9u&mKhEcRjlf2nm_@s;0D#c|@ED7NZE% zzR;>P5B{o4fzlfsn3CkBK&`OSb-YNrqx@N#4CK!>bQ(V(D#9|l!e9(%sz~PYk@8zt zPN9oK78&-IL_F zhsk1$6p;GqFbtB^ZHHP+cjMvA0(LqlskbdYE_rda>gvQLTiqOQ1~*7lg%z*&p`Ry& zRcG^DbbPj_jOKHTr8uk^15Boj6>hA2S-QY(W-6!FIq8h$<>MI>PYYRenQDBamO#Fv zAH5&ImqKBDn0v5kb|8i0wFhUBJTpT!rB-`zK)^SNnRmLraZcPYK7b{I@+}wXVdW-{Ps17qdRA3JatEd?rPV z4@}(DAMf5EqXCr4-B+~H1P#;t@O}B)tIJ(W6$LrK&0plTmnPpb1TKn3?f?Kk``?D+ zQ!MFqOX7JbsXfQrz`-M@hq7xlfNz;_B{^wbpG8des56x(Q)H)5eLeDwCrVR}hzr~= zM{yXR6IM?kXxauLza#@#u?Y|o;904HCqF<8yT~~c-xyRc0-vxofnxG^(x%>bj5r}N zyFT+xnn-?B`ohA>{+ZZQem=*Xpqz{=j8i2TAC#x-m;;mo{{sLB_z(UoAqD=A#*juZ zCv=J~i*O8;F}A^Wf#+zx;~3B{57xtoxC&j^ie^?**T`WT2OPRtC`xj~+3Kprn=rVM zVJ|h5ux%S{dO}!mq93}P+h36mZ5aZg1-?vhL$ke1d52qIiXSE(llCr5i=QUS?LIjc zV$4q=-)aaR4wsrQv}^shL5u%6;`uiSEs<1nG^?$kl$^6DL z43CjY`M*p}ew}}3rXc7Xck@k41jx}c;NgEIhKZ*jsBRZUP-x2cm;F1<5$jefl|ppO zmZd%%?gMJ^g9=RZ^#8Mf5aWNVhjAS^|DQO+q$)oeob_&ZLFL(zur$)); zU19yRm)z<4&4-M}7!9+^Wl}Uk?`S$#V2%pQ*SIH5KI-mn%i;Z7-)m$mN9CnI$G7?# zo`zVrUwoSL&_dJ92YhX5TKqaRkfPgC4=Q&=K+;_aDs&OU0&{WFH}kKX6uNQC6%oUH z2DZa1s3%Vtk|bglbxep-w)PbFG!J17`<$g8lVhqD2w;Z0zGsh-r zxZ13G$G<48leNqR!DCVt9)@}(zMI5w6Wo=N zpP1*3DI;~h2WDWgcKn*f!+ORD)f$DZFwgKBafEZmeXQMAsq9sxP9A)7zOYnkHT9JU zRA`umgmP9d6=PHmFIgx=0$(sjb>+0CHG)K@cPG{IxaJ&Ueo8)0RWgV9+gO7+Bl1(F z7!BslJ2MP*PWJ;x)QXbR$6jEr5q3 z(3}F@YO_P1NyTdEXRLU6fp?9V2-S=E+YaeLL{Y)W%6`k7$(EW8EZSA*(+;e5@jgD^I zaJQ2|oCM1n!A&-8`;#RDcZyk*+RPkn_r8?Ak@agHiSp*qFNX)&i21HE?yuZ;-C<3C zwJGd1lx5UzViP7sZJ&|LqH*mryb}y|%AOw+v)yc`qM)03qyyrqhX?ub`Cjwx2PrR! z)_z>5*!*$x1=Qa-0uE7jy0z`>|Ni#X+uV|%_81F7)b+nf%iz=`fF4g5UfHS_?PHbr zB;0$bK@=di?f`dS(j{l3-tSCfp~zUuva+=EWxJcRfp(<$@vd(GigM&~vaYZ0c#BTs z3ijkxMl=vw5AS&DcXQ%eeKt!uKvh2l3W?&3=dBHU=Gz?O!40S&&~ei2vg**c$o;i89~6DVns zG>9a*`k5)NI9|?W!@9>rzJ;9EJ=YlJTx1r1BA?H`LWijk(rTax9(OAu;q4_wTj-yj z1%W4GW&K4T=uEGb+E!>W0SD_C0RR91 literal 0 HcmV?d00001 diff --git a/frontend/app/web/icons/Icon-512.png b/frontend/app/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..88cfd48dff1169879ba46840804b412fe02fefd6 GIT binary patch literal 8252 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 literal 0 HcmV?d00001 diff --git a/frontend/app/web/icons/Icon-maskable-192.png b/frontend/app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9b4d76e525556d5d89141648c724331630325d GIT binary patch literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! literal 0 HcmV?d00001 diff --git a/frontend/app/web/icons/Icon-maskable-512.png b/frontend/app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d69c56691fbdb0b7efa65097c7cc1edac12a6d3e GIT binary patch literal 20998 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 MPgg&ebxsLQ025IeI{*Lx literal 0 HcmV?d00001 diff --git a/frontend/app/web/index.html b/frontend/app/web/index.html new file mode 100644 index 0000000..539396e --- /dev/null +++ b/frontend/app/web/index.html @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + SXCHNGE + + + + + + + diff --git a/frontend/app/web/manifest.json b/frontend/app/web/manifest.json new file mode 100644 index 0000000..79d176e --- /dev/null +++ b/frontend/app/web/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "SXCHNGE", + "short_name": "SXCHNGE", + "start_url": ".", + "display": "standalone", + "background_color": "#f9faf6", + "theme_color": "#14221d", + "description": "SXCHNGE account preview.", + "orientation": "any", + "prefer_related_applications": false, + "icons": [ + { + "src": "favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +}