Deliver localized auth app with email verification and go_router #9

Merged
tech merged 2 commits from routing-6 into main 2026-09-11 13:45:50 +00:00
44 changed files with 4128 additions and 28 deletions
Showing only changes of commit 10a278c65a - Show all commits

No files matched your search

+72
View File
@@ -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/<owner>/<repo>/issues`, include `labels`, `assignees`, and
`projects: [<project-id>]` in the request.
6. When correcting project assignment through
`PATCH /api/v1/repos/<owner>/<repo>/issues/<number>`, 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 `<module>-<task-number>` 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": "<module>-<task-number>"}` 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.
@@ -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.
+108 -15
View File
@@ -1,22 +1,115 @@
## Development # Repository Agent Guidelines
When starting the dev server, use background mode: ## Instruction hierarchy
``` - The rules in this file apply to every repository task.
astro dev --background - 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/) Do not guess an assignee, project, label, repository, task number, or module.
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/) Resolve missing metadata from authoritative repository or Gitea data, and ask
- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/) the user when it cannot be determined. Do not create labels or projects unless
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/) explicitly requested. Never print, log, or commit API tokens or Git
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/) credentials.
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)
## 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 `<module>-<task-number>` 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.
+7
View File
@@ -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
+28 -13
View File
@@ -1,28 +1,43 @@
# SXCHNGE # 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 ```sh
cd website
npm ci npm ci
npm run dev -- --background --host 127.0.0.1 npm run dev -- --background --host 127.0.0.1
npm run test npm run test
npm run check
npm run build 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`. The intended integration is: consumer frontend → sxchnge BFF → supported Sendico API.
- 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`.
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`.
+71
View File
@@ -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.
+114
View File
@@ -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.
+48
View File
@@ -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/
+30
View File
@@ -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'
+64
View File
@@ -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`.
+33
View File
@@ -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
+5
View File
@@ -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
+158
View File
@@ -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<double>(
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,
),
),
],
),
),
),
),
);
}
}
+49
View File
@@ -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,
),
),
),
);
}
}
+330
View File
@@ -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<AuthForm> createState() => _AuthFormState();
}
class _AuthFormState extends State<AuthForm> {
final _form = GlobalKey<FormState>();
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<SignupProvider>() : context.read<LoginProvider>();
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<LoginProvider>().reset();
context.read<SignupProvider>().reset();
_form.currentState?.reset();
_password.clear();
_confirmation.clear();
setState(() {
_signUp = signUp;
_submitted = false;
_showPassword = false;
_showConfirmation = false;
});
}
Future<void> _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<SignupProvider>();
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<LoginProvider>().login(
email: _email.text,
password: _password.text,
locale: locale,
);
}
}
@override
Widget build(BuildContext context) {
final login = context.watch<LoginProvider>();
final signup = context.watch<SignupProvider>();
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<bool>(
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),
),
],
);
}
}
+88
View File
@@ -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<AuthPage> createState() => _AuthPageState();
}
class _AuthPageState extends State<AuthPage> {
// 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),
],
);
},
),
),
);
}
+35
View File
@@ -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;
}
@@ -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<AppLocalizations>(context, AppLocalizations);
}
static const LocalizationsDelegate<AppLocalizations> 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<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
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<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
Future<AppLocalizations> load(Locale locale) {
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
}
@override
bool isSupported(Locale locale) =>
<String>['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.',
);
}
@@ -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';
}
@@ -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 => 'ПОДТВЕРДИТЕ ПОЧТУ';
}
+69
View File
@@ -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"
}
+69
View File
@@ -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": "ПОДТВЕРДИТЕ ПОЧТУ"
}
+71
View File
@@ -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<void> 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<SxchngeApp> createState() => _SxchngeAppState();
}
class _SxchngeAppState extends State<SxchngeApp> {
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<VerificationService>.value(value: widget.verificationService),
ChangeNotifierProvider(create: (_) => LoginProvider(widget.loginService)),
ChangeNotifierProvider(
create: (_) => SignupProvider(widget.signupService),
),
],
child: Consumer<LocaleProvider>(
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,
),
),
);
}
+50
View File
@@ -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<bool> _pendingSave = Future.value(true);
Locale get locale => _locale;
static Locale resolve(String? saved, List<Locale> systemLocales) {
final code =
saved?.split(RegExp('[-_]')).first ??
(systemLocales.isEmpty ? 'en' : systemLocales.first.languageCode);
return Locale(code == 'ru' ? 'ru' : 'en');
}
static Future<LocaleProvider> load(List<Locale> 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<bool> 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;
}
}
+48
View File
@@ -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<Object?, Object?> {
const _TransientExtras();
@override
Converter<Object?, Object?> get encoder => const _DiscardExtra();
@override
Converter<Object?, Object?> get decoder => const _DiscardExtra();
}
class _DiscardExtra extends Converter<Object?, Object?> {
const _DiscardExtra();
@override
Object? convert(Object? input) => null;
}
+5
View File
@@ -0,0 +1,5 @@
abstract final class AppRoutes {
static const login = 'login';
static const verificationSent = 'verification-sent';
static const verifyEmail = 'verify-email';
}
+279
View File
@@ -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<VerificationPage> createState() => _VerificationPageState();
}
class _VerificationPageState extends State<VerificationPage> {
final _form = GlobalKey<FormState>();
late final _email = TextEditingController(text: widget.email);
late final _provider = VerificationProvider(
context.read<VerificationService>(),
);
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),
),
],
],
),
),
),
),
),
);
},
);
}
+54
View File
@@ -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<LocaleProvider>();
return PopupMenuButton<Locale>(
key: const ValueKey('locale-selector'),
tooltip: AppLocalizations.of(context)!.language,
onSelected: (locale) async {
final saved = await provider.setLocale(locale);
if (!saved && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.preferenceNotSaved),
),
);
}
},
itemBuilder: (_) => AppLocalizations.supportedLocales
.map(
(locale) => CheckedPopupMenuItem(
value: locale,
checked: locale == provider.locale,
child: Text(locale.languageCode == 'ru' ? 'Русский' : 'English'),
),
)
.toList(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.language, size: 20),
const SizedBox(width: 8),
Text(
provider.locale.languageCode.toUpperCase(),
style: Theme.of(context).textTheme.labelLarge,
),
const Icon(Icons.expand_more, size: 20),
],
),
),
);
}
}
+421
View File
@@ -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"
+28
View File
@@ -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
+104
View File
@@ -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 = <Completer<AuthResult>>[];
LoginRequest? request;
@override
Future<AuthResult> login(LoginRequest input) {
request = input;
final completer = Completer<AuthResult>();
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<FilledButton>(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);
});
}
+160
View File
@@ -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<void> 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<void> 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<TextField>(
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);
});
}
}
+138
View File
@@ -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<Locale>),
),
);
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);
});
}
}
+157
View File
@@ -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 = <String, Completer<VerificationResult>>{};
@override
Future<VerificationResult> confirm({required String token}) =>
(results[token] = Completer<VerificationResult>()).future;
@override
Future<VerificationResult> send({
required String email,
required String locale,
}) async => const VerificationResult(isSimulated: true);
}
Future<GoRouter> 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<LocaleProvider>().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);
},
);
}
+136
View File
@@ -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<VerificationResult> send({
required String email,
required String locale,
}) async {
if (++sends == 1) throw StateError('private error');
return const VerificationResult(isSimulated: true);
}
@override
Future<VerificationResult> confirm({required String token}) async {
if (++confirmations == 1) throw StateError('private error');
return const VerificationResult(isSimulated: true);
}
}
Future<void> 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<Locale>),
),
);
await tester.pumpAndSettle();
expect(find.text('RU'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
}
+1
View File
@@ -0,0 +1 @@
{}
Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#14221d"/><path d="M19 45 45 19M20 19h25v25" fill="none" stroke="#c4f9ce" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 249 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+47
View File
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta name="robots" content="noindex,nofollow">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="SXCHNGE account preview.">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="SXCHNGE">
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="favicon.svg"/>
<title>SXCHNGE</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<!--
You can customize the "flutter_bootstrap.js" script.
This is useful to provide a custom configuration to the Flutter loader
or to give the user feedback during the initialization process.
For more details:
* https://docs.flutter.dev/platform-integration/web/initialization
-->
<script src="flutter_bootstrap.js" async></script>
</body>
</html>
+19
View File
@@ -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"
}
]
}