Deliver localized auth app with verification and go_router

Integrate themed login/signup, EN/RU localization, shared dummy providers, verification recovery screens and named URL routes. Store frontend guidelines, workflow skills, validation and delivery documentation. Related to #2, #3, #5 and #6.
This commit is contained in:
Stephan D committed 2026-09-11 15:37:07 +02:00
1 parent 27424ed886
commit 10a278c65a
44 files changed
+4128 -28

No files matched your search

+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"
}
]
}