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:
1 parent
27424ed886
commit
10a278c65a
44 files changed
+4128
-28
No files matched your search
@@ -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/
|
||||
@@ -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'
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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 => 'ПОДТВЕРДИТЕ ПОЧТУ';
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "ПОДТВЕРДИТЕ ПОЧТУ"
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
abstract final class AppRoutes {
|
||||
static const login = 'login';
|
||||
static const verificationSent = 'verification-sent';
|
||||
static const verifyEmail = 'verify-email';
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 917 B |
@@ -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 |
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user