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,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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user