Deliver localized auth app with verification and go_router

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

No files matched your search

+158
View File
@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import '../generated/i18n/app_localizations.dart';
class BrandWordmark extends StatelessWidget {
const BrandWordmark({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(9),
),
child: Icon(Icons.bolt, color: theme.colorScheme.onPrimary, size: 26),
),
const SizedBox(width: 9),
Flexible(
child: Text(
'sxchnge',
style: theme.textTheme.headlineMedium!.copyWith(
letterSpacing: -1.5,
fontWeight: FontWeight.w800,
),
),
),
],
);
}
}
class BrandPanel extends StatelessWidget {
const BrandPanel({super.key});
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final colors = theme.colorScheme;
return ColoredBox(
color: colors.surface,
child: Padding(
padding: const EdgeInsets.all(48),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const BrandWordmark(),
const SizedBox(height: 64),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.brandEyebrow,
style: theme.textTheme.labelMedium!.copyWith(
letterSpacing: 1.5,
color: colors.primary,
),
),
const SizedBox(height: 24),
Text(
l.brandHeading,
style: theme.textTheme.displayLarge!.copyWith(
fontSize: 68,
height: 1.03,
letterSpacing: -3,
),
),
const SizedBox(height: 32),
Text(
l.brandIntro,
style: theme.textTheme.bodyLarge!.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 48),
const _MotionCard(),
],
),
const SizedBox(height: 64),
Text(
l.brandFooter,
style: theme.textTheme.labelSmall!.copyWith(
letterSpacing: 1,
color: colors.onSurfaceVariant,
),
),
],
),
),
);
}
}
class _MotionCard extends StatelessWidget {
const _MotionCard();
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final colors = theme.colorScheme;
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 700),
curve: Curves.easeOutCubic,
builder: (context, value, child) => Opacity(
opacity: value,
child: Transform.translate(
offset: Offset(0, (1 - value) * 24),
child: child,
),
),
child: Transform.rotate(
angle: -0.035,
child: Card(
color: colors.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(28),
child: Row(
children: [
Expanded(
child: Text(
l.brandCard,
style: theme.textTheme.headlineSmall!.copyWith(
color: colors.onPrimaryContainer,
),
),
),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: colors.tertiaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.north_east,
size: 38,
color: colors.onTertiaryContainer,
),
),
],
),
),
),
),
);
}
}
+49
View File
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../generated/i18n/app_localizations.dart';
class AuthFeedback extends StatelessWidget {
const AuthFeedback({super.key, required this.state, required this.signUp});
final AuthState state;
final bool signUp;
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme;
final error = state.error;
final message = error == null
? (state.result!.isSimulated
? (signUp ? l.signupPreview : l.loginPreview)
: l.authCompleted)
: switch (error) {
AuthError.invalidCredentials => l.authInvalidCredentials,
AuthError.emailInUse => l.authEmailInUse,
AuthError.unavailable => l.authUnavailable,
AuthError.unexpected => l.authUnexpected,
};
return Semantics(
liveRegion: true,
child: Container(
key: ValueKey(error == null ? 'preview-result' : 'auth-error'),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: error == null
? colors.secondaryContainer
: colors.errorContainer,
borderRadius: BorderRadius.circular(14),
),
child: Text(
message,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: error == null
? colors.onSecondaryContainer
: colors.onErrorContainer,
),
),
),
);
}
}
+330
View File
@@ -0,0 +1,330 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../routing/routes.dart';
import 'feedback.dart';
import 'validation.dart';
import '../generated/i18n/app_localizations.dart';
class AuthForm extends StatefulWidget {
const AuthForm({super.key});
@override
State<AuthForm> createState() => _AuthFormState();
}
class _AuthFormState extends State<AuthForm> {
final _form = GlobalKey<FormState>();
final _password = TextEditingController();
final _confirmation = TextEditingController();
bool _signUp = false;
bool _showPassword = false;
bool _showConfirmation = false;
bool _submitted = false;
final _email = TextEditingController();
final _name = TextEditingController();
AuthProvider get _provider =>
_signUp ? context.read<SignupProvider>() : context.read<LoginProvider>();
Locale? _lastLocale;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final locale = Localizations.localeOf(context);
if (_lastLocale != null && _lastLocale != locale && _submitted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _form.currentState?.validate();
});
}
_lastLocale = locale;
}
@override
void dispose() {
_email.dispose();
_name.dispose();
_password.dispose();
_confirmation.dispose();
super.dispose();
}
void _switch(bool signUp) {
if (signUp == _signUp) return;
context.read<LoginProvider>().reset();
context.read<SignupProvider>().reset();
_form.currentState?.reset();
_password.clear();
_confirmation.clear();
setState(() {
_signUp = signUp;
_submitted = false;
_showPassword = false;
_showConfirmation = false;
});
}
Future<void> _submit() async {
if (_provider.state.isLoading) return;
setState(() => _submitted = true);
if (!_form.currentState!.validate()) return;
FocusScope.of(context).unfocus();
final locale = Localizations.localeOf(context).toLanguageTag();
if (_signUp) {
final signup = context.read<SignupProvider>();
await signup.signup(
name: _name.text,
email: _email.text,
password: _password.text,
locale: locale,
);
if (!mounted || !_signUp || signup.state.status != AuthStatus.success) {
return;
}
final email = _email.text.trim();
_password.clear();
_confirmation.clear();
context.goNamed(AppRoutes.verificationSent, extra: email);
} else {
await context.read<LoginProvider>().login(
email: _email.text,
password: _password.text,
locale: locale,
);
}
}
@override
Widget build(BuildContext context) {
final login = context.watch<LoginProvider>();
final signup = context.watch<SignupProvider>();
final state = (_signUp ? signup : login).state;
final l = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final colors = theme.colorScheme;
final text = theme.textTheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerLeft,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: ShapeDecoration(
color: colors.tertiaryContainer,
shape: const StadiumBorder(),
),
child: Text(
l.nextChapter,
style: text.labelSmall!.copyWith(
color: colors.onTertiaryContainer,
letterSpacing: 0.8,
),
),
),
),
const SizedBox(height: 24),
Text(
_signUp ? l.signupHeading : l.loginHeading,
style: text.displayMedium,
),
const SizedBox(height: 16),
Text(
_signUp ? l.signupIntro : l.loginIntro,
style: text.bodyLarge!.copyWith(color: colors.onSurfaceVariant),
),
const SizedBox(height: 32),
SegmentedButton<bool>(
showSelectedIcon: false,
segments: [
ButtonSegment(value: false, label: Text(l.login)),
ButtonSegment(value: true, label: Text(l.signup)),
],
selected: {_signUp},
onSelectionChanged: (selected) => _switch(selected.single),
expandedInsets: EdgeInsets.zero,
),
const SizedBox(height: 32),
Form(
key: _form,
autovalidateMode: _submitted
? AutovalidateMode.onUserInteraction
: AutovalidateMode.disabled,
onChanged: () {
if (_provider.state.status == AuthStatus.success ||
_provider.state.status == AuthStatus.error) {
_provider.reset();
}
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_signUp) ...[
TextFormField(
key: const ValueKey('name'),
controller: _name,
enabled: !state.isLoading,
decoration: InputDecoration(
labelText: l.name,
prefixIcon: Icon(Icons.person_outline),
errorMaxLines: 3,
),
textInputAction: TextInputAction.next,
textCapitalization: TextCapitalization.words,
validator: (value) => AuthValidation.name(value, l),
),
const SizedBox(height: 20),
],
TextFormField(
key: const ValueKey('email'),
controller: _email,
enabled: !state.isLoading,
decoration: InputDecoration(
labelText: l.email,
hintText: l.emailHint,
prefixIcon: Icon(Icons.alternate_email),
errorMaxLines: 3,
),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autocorrect: false,
validator: (value) => AuthValidation.email(value, l),
),
const SizedBox(height: 20),
TextFormField(
key: const ValueKey('password'),
enabled: !state.isLoading,
controller: _password,
obscureText: !_showPassword,
autocorrect: false,
enableSuggestions: false,
textInputAction: _signUp
? TextInputAction.next
: TextInputAction.done,
onFieldSubmitted: (_) {
if (!_signUp) _submit();
},
decoration: InputDecoration(
labelText: l.password,
prefixIcon: const Icon(Icons.lock_outline),
errorMaxLines: 3,
helperText: _signUp ? l.passwordHint : null,
helperMaxLines: 3,
suffixIcon: IconButton(
tooltip: _showPassword ? l.hidePassword : l.showPassword,
onPressed: () =>
setState(() => _showPassword = !_showPassword),
icon: Icon(
_showPassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
),
),
),
validator: (value) =>
AuthValidation.password(value, l, signUp: _signUp),
),
if (_signUp) ...[
const SizedBox(height: 20),
TextFormField(
key: const ValueKey('confirmation'),
enabled: !state.isLoading,
controller: _confirmation,
obscureText: !_showConfirmation,
autocorrect: false,
enableSuggestions: false,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
decoration: InputDecoration(
labelText: l.confirmPassword,
prefixIcon: const Icon(Icons.lock_outline),
errorMaxLines: 3,
suffixIcon: IconButton(
tooltip: _showConfirmation
? l.hideConfirmation
: l.showConfirmation,
onPressed: () => setState(
() => _showConfirmation = !_showConfirmation,
),
icon: Icon(
_showConfirmation
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
),
),
),
validator: (value) =>
AuthValidation.confirmation(value, _password.text, l),
),
],
const SizedBox(height: 28),
FilledButton(
key: const ValueKey('submit'),
onPressed: state.isLoading ? null : _submit,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
state.isLoading
? l.authWorking
: (_signUp ? l.createAccount : l.letsGo),
textAlign: TextAlign.center,
),
),
const SizedBox(width: 12),
state.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.arrow_forward, size: 20),
],
),
),
),
],
),
),
if (state.status == AuthStatus.success ||
state.status == AuthStatus.error) ...[
const SizedBox(height: 20),
AuthFeedback(state: state, signUp: _signUp),
],
const SizedBox(height: 20),
Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
_signUp ? l.alreadyMember : l.newHere,
style: text.bodySmall!.copyWith(color: colors.onSurfaceVariant),
),
TextButton(
onPressed: () => _switch(!_signUp),
child: Text(_signUp ? l.loginInstead : l.createAnAccount),
),
],
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 16),
Text(
l.earlyPreview,
textAlign: TextAlign.center,
style: text.bodySmall!.copyWith(color: colors.onSurfaceVariant),
),
],
);
}
}
+88
View File
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:sxchnge_theme/sxchnge_theme.dart';
import '../widgets/locale.dart';
import 'brand_panel.dart';
import 'form.dart';
class AuthPage extends StatefulWidget {
const AuthPage({super.key});
@override
State<AuthPage> createState() => _AuthPageState();
}
class _AuthPageState extends State<AuthPage> {
// Preserve the form when moving between the desktop and mobile layouts.
final _formKey = GlobalKey();
@override
Widget build(BuildContext context) => Scaffold(
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final wide = constraints.maxWidth >= 1000;
final form = SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: wide ? 56 : 24,
vertical: wide ? 48 : 24,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Align(
alignment: Alignment.centerRight,
child: LocaleSelector(),
),
const SizedBox(height: 16),
if (!wide) ...[
const Align(
alignment: Alignment.centerLeft,
child: BrandWordmark(),
),
const SizedBox(height: 48),
],
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: AuthForm(key: _formKey),
),
),
],
),
),
),
),
);
if (!wide) return form;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 47,
child: Theme(
data: SxchngeTheme.dark(),
child: SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: const BrandPanel(),
),
),
),
),
Expanded(flex: 53, child: form),
],
);
},
),
),
);
}
+35
View File
@@ -0,0 +1,35 @@
import '../generated/i18n/app_localizations.dart';
abstract final class AuthValidation {
static String? email(String? value, AppLocalizations l) {
final email = value?.trim() ?? '';
if (email.isEmpty) return l.emailRequired;
if (!RegExp(r'^[^\s@]+@[^\s@]+\.[^\s@]+$').hasMatch(email)) {
return l.emailInvalid;
}
return null;
}
static String? name(String? value, AppLocalizations l) =>
(value?.trim().isEmpty ?? true) ? l.nameRequired : null;
static String? password(
String? value,
AppLocalizations l, {
required bool signUp,
}) {
if (value == null || value.isEmpty) return l.passwordRequired;
if (signUp && value.length < 12) return l.passwordHint;
return null;
}
static String? confirmation(
String? value,
String password,
AppLocalizations l,
) => value == null || value.isEmpty
? l.confirmationRequired
: value != password
? l.passwordMismatch
: null;
}
@@ -0,0 +1,530 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'app_localizations_en.dart';
import 'app_localizations_ru.dart';
// ignore_for_file: type=lint
/// Callers can lookup localized strings with an instance of AppLocalizations
/// returned by `AppLocalizations.of(context)`.
///
/// Applications need to include `AppLocalizations.delegate()` in their app's
/// `localizationDelegates` list, and the locales they support in the app's
/// `supportedLocales` list. For example:
///
/// ```dart
/// import 'i18n/app_localizations.dart';
///
/// return MaterialApp(
/// localizationsDelegates: AppLocalizations.localizationsDelegates,
/// supportedLocales: AppLocalizations.supportedLocales,
/// home: MyApplicationHome(),
/// );
/// ```
///
/// ## Update pubspec.yaml
///
/// Please make sure to update your pubspec.yaml to include the following
/// packages:
///
/// ```yaml
/// dependencies:
/// # Internationalization support.
/// flutter_localizations:
/// sdk: flutter
/// intl: any # Use the pinned version from flutter_localizations
///
/// # Rest of dependencies
/// ```
///
/// ## iOS Applications
///
/// iOS applications define key application metadata, including supported
/// locales, in an Info.plist file that is built into the application bundle.
/// To configure the locales supported by your app, you’ll need to edit this
/// file.
///
/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// project’s Runner folder.
///
/// Next, select the Information Property List item, select Add Item from the
/// Editor menu, then select Localizations from the pop-up menu.
///
/// Select and expand the newly-created Localizations item then, for each
/// locale your application supports, add a new item and select the locale
/// you wish to add from the pop-up menu in the Value field. This list should
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
/// property.
abstract class AppLocalizations {
AppLocalizations(String locale)
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
static AppLocalizations? of(BuildContext context) {
return Localizations.of<AppLocalizations>(context, AppLocalizations);
}
static const LocalizationsDelegate<AppLocalizations> delegate =
_AppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations
/// delegates.
///
/// Returns a list of localizations delegates containing this delegate along with
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
/// and GlobalWidgetsLocalizations.delegate.
///
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
Locale('en'),
Locale('ru'),
];
/// No description provided for @appTitle.
///
/// In en, this message translates to:
/// **'SXCHNGE · Your next move'**
String get appTitle;
/// No description provided for @nextChapter.
///
/// In en, this message translates to:
/// **'YOUR NEXT CHAPTER'**
String get nextChapter;
/// No description provided for @loginHeading.
///
/// In en, this message translates to:
/// **'Hey, welcome\nback.'**
String get loginHeading;
/// No description provided for @signupHeading.
///
/// In en, this message translates to:
/// **'Make your\nfirst move.'**
String get signupHeading;
/// No description provided for @loginIntro.
///
/// In en, this message translates to:
/// **'Your next move is just a login away.'**
String get loginIntro;
/// No description provided for @signupIntro.
///
/// In en, this message translates to:
/// **'A new account. A world of possibility.'**
String get signupIntro;
/// No description provided for @login.
///
/// In en, this message translates to:
/// **'Log in'**
String get login;
/// No description provided for @signup.
///
/// In en, this message translates to:
/// **'Sign up'**
String get signup;
/// No description provided for @name.
///
/// In en, this message translates to:
/// **'Your name'**
String get name;
/// No description provided for @email.
///
/// In en, this message translates to:
/// **'Email address'**
String get email;
/// No description provided for @emailHint.
///
/// In en, this message translates to:
/// **'you@example.com'**
String get emailHint;
/// No description provided for @password.
///
/// In en, this message translates to:
/// **'Password'**
String get password;
/// No description provided for @confirmPassword.
///
/// In en, this message translates to:
/// **'Confirm password'**
String get confirmPassword;
/// No description provided for @passwordHint.
///
/// In en, this message translates to:
/// **'Use at least 12 characters.'**
String get passwordHint;
/// No description provided for @showPassword.
///
/// In en, this message translates to:
/// **'Show password'**
String get showPassword;
/// No description provided for @hidePassword.
///
/// In en, this message translates to:
/// **'Hide password'**
String get hidePassword;
/// No description provided for @showConfirmation.
///
/// In en, this message translates to:
/// **'Show confirmation'**
String get showConfirmation;
/// No description provided for @hideConfirmation.
///
/// In en, this message translates to:
/// **'Hide confirmation'**
String get hideConfirmation;
/// No description provided for @createAccount.
///
/// In en, this message translates to:
/// **'Create account'**
String get createAccount;
/// No description provided for @letsGo.
///
/// In en, this message translates to:
/// **'Let’s go'**
String get letsGo;
/// No description provided for @loginPreview.
///
/// In en, this message translates to:
/// **'Demo login completed. No real session was created. Your credentials have not been sent or saved.'**
String get loginPreview;
/// No description provided for @signupPreview.
///
/// In en, this message translates to:
/// **'Demo sign-up completed. No real account was created. Your credentials have not been sent or saved.'**
String get signupPreview;
/// No description provided for @alreadyMember.
///
/// In en, this message translates to:
/// **'Already part of sxchnge?'**
String get alreadyMember;
/// No description provided for @newHere.
///
/// In en, this message translates to:
/// **'New around here?'**
String get newHere;
/// No description provided for @loginInstead.
///
/// In en, this message translates to:
/// **'Log in instead'**
String get loginInstead;
/// No description provided for @createAnAccount.
///
/// In en, this message translates to:
/// **'Create an account'**
String get createAnAccount;
/// No description provided for @earlyPreview.
///
/// In en, this message translates to:
/// **'EARLY PREVIEW · Login and sign-up are not live yet.'**
String get earlyPreview;
/// No description provided for @brandEyebrow.
///
/// In en, this message translates to:
/// **'A LITTLE CHANGE. A BIG NEXT STEP.'**
String get brandEyebrow;
/// No description provided for @brandHeading.
///
/// In en, this message translates to:
/// **'Good moves.\nStart here.'**
String get brandHeading;
/// No description provided for @brandIntro.
///
/// In en, this message translates to:
/// **'A fresh way forward.\nMake it yours with sxchnge.'**
String get brandIntro;
/// No description provided for @brandCard.
///
/// In en, this message translates to:
/// **'Less friction.\nMore possibility.'**
String get brandCard;
/// No description provided for @brandFooter.
///
/// In en, this message translates to:
/// **'YOUR NEXT MOVE STARTS WITH YOU.'**
String get brandFooter;
/// No description provided for @emailRequired.
///
/// In en, this message translates to:
/// **'Enter your email address.'**
String get emailRequired;
/// No description provided for @emailInvalid.
///
/// In en, this message translates to:
/// **'Enter a valid email address.'**
String get emailInvalid;
/// No description provided for @nameRequired.
///
/// In en, this message translates to:
/// **'Enter your name.'**
String get nameRequired;
/// No description provided for @passwordRequired.
///
/// In en, this message translates to:
/// **'Enter your password.'**
String get passwordRequired;
/// No description provided for @confirmationRequired.
///
/// In en, this message translates to:
/// **'Confirm your password.'**
String get confirmationRequired;
/// No description provided for @passwordMismatch.
///
/// In en, this message translates to:
/// **'Passwords do not match.'**
String get passwordMismatch;
/// No description provided for @language.
///
/// In en, this message translates to:
/// **'Language'**
String get language;
/// No description provided for @preferenceNotSaved.
///
/// In en, this message translates to:
/// **'Language changed for this session. Your preference could not be saved.'**
String get preferenceNotSaved;
/// No description provided for @authWorking.
///
/// In en, this message translates to:
/// **'Please wait…'**
String get authWorking;
/// No description provided for @authCompleted.
///
/// In en, this message translates to:
/// **'Request completed.'**
String get authCompleted;
/// No description provided for @authInvalidCredentials.
///
/// In en, this message translates to:
/// **'Email or password is incorrect. Please try again.'**
String get authInvalidCredentials;
/// No description provided for @authEmailInUse.
///
/// In en, this message translates to:
/// **'This email is already registered. Try logging in.'**
String get authEmailInUse;
/// No description provided for @authUnavailable.
///
/// In en, this message translates to:
/// **'The service is unavailable. Please try again.'**
String get authUnavailable;
/// No description provided for @authUnexpected.
///
/// In en, this message translates to:
/// **'Something went wrong. Please try again.'**
String get authUnexpected;
/// No description provided for @checkInbox.
///
/// In en, this message translates to:
/// **'Check your inbox.'**
String get checkInbox;
/// No description provided for @verificationSent.
///
/// In en, this message translates to:
/// **'We’ve sent you a verification email. Open the link in your inbox to confirm your email address.'**
String get verificationSent;
/// No description provided for @verificationSending.
///
/// In en, this message translates to:
/// **'Preparing your verification email…'**
String get verificationSending;
/// No description provided for @verificationChecking.
///
/// In en, this message translates to:
/// **'Verifying your email…'**
String get verificationChecking;
/// No description provided for @verificationVerified.
///
/// In en, this message translates to:
/// **'Your email\nis verified.'**
String get verificationVerified;
/// No description provided for @verificationVerifiedBody.
///
/// In en, this message translates to:
/// **'One step done. You can now return to login. Identity verification is a separate step.'**
String get verificationVerifiedBody;
/// No description provided for @verificationExpired.
///
/// In en, this message translates to:
/// **'This link has expired.'**
String get verificationExpired;
/// No description provided for @verificationExpiredBody.
///
/// In en, this message translates to:
/// **'Request a new verification email and use the latest link.'**
String get verificationExpiredBody;
/// No description provided for @verificationInvalid.
///
/// In en, this message translates to:
/// **'This link isn’t valid.'**
String get verificationInvalid;
/// No description provided for @verificationInvalidBody.
///
/// In en, this message translates to:
/// **'The confirmation link is missing or invalid. Open the complete link from your email, or request a new one.'**
String get verificationInvalidBody;
/// No description provided for @verificationInternal.
///
/// In en, this message translates to:
/// **'Something went wrong.'**
String get verificationInternal;
/// No description provided for @verificationInternalBody.
///
/// In en, this message translates to:
/// **'We couldn’t complete your request because of an internal error. Please try again.'**
String get verificationInternalBody;
/// No description provided for @verificationResend.
///
/// In en, this message translates to:
/// **'Resend verification email'**
String get verificationResend;
/// No description provided for @verificationRequest.
///
/// In en, this message translates to:
/// **'Send a new verification email'**
String get verificationRequest;
/// No description provided for @verificationRetry.
///
/// In en, this message translates to:
/// **'Try again'**
String get verificationRetry;
/// No description provided for @verificationBack.
///
/// In en, this message translates to:
/// **'Back to login'**
String get verificationBack;
/// No description provided for @verificationHint.
///
/// In en, this message translates to:
/// **'No email? Check your spam folder or request another below.'**
String get verificationHint;
/// No description provided for @verificationDemo.
///
/// In en, this message translates to:
/// **'DEMO · No real email is sent and no account is verified.'**
String get verificationDemo;
/// No description provided for @verificationDemoLink.
///
/// In en, this message translates to:
/// **'Open demo confirmation link'**
String get verificationDemoLink;
/// No description provided for @verificationStep.
///
/// In en, this message translates to:
/// **'CONFIRM YOUR EMAIL'**
String get verificationStep;
}
class _AppLocalizationsDelegate
extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
Future<AppLocalizations> load(Locale locale) {
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
}
@override
bool isSupported(Locale locale) =>
<String>['en', 'ru'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
}
AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'en':
return AppLocalizationsEn();
case 'ru':
return AppLocalizationsRu();
}
throw FlutterError(
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.',
);
}
@@ -0,0 +1,222 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get appTitle => 'SXCHNGE · Your next move';
@override
String get nextChapter => 'YOUR NEXT CHAPTER';
@override
String get loginHeading => 'Hey, welcome\nback.';
@override
String get signupHeading => 'Make your\nfirst move.';
@override
String get loginIntro => 'Your next move is just a login away.';
@override
String get signupIntro => 'A new account. A world of possibility.';
@override
String get login => 'Log in';
@override
String get signup => 'Sign up';
@override
String get name => 'Your name';
@override
String get email => 'Email address';
@override
String get emailHint => 'you@example.com';
@override
String get password => 'Password';
@override
String get confirmPassword => 'Confirm password';
@override
String get passwordHint => 'Use at least 12 characters.';
@override
String get showPassword => 'Show password';
@override
String get hidePassword => 'Hide password';
@override
String get showConfirmation => 'Show confirmation';
@override
String get hideConfirmation => 'Hide confirmation';
@override
String get createAccount => 'Create account';
@override
String get letsGo => 'Let’s go';
@override
String get loginPreview =>
'Demo login completed. No real session was created. Your credentials have not been sent or saved.';
@override
String get signupPreview =>
'Demo sign-up completed. No real account was created. Your credentials have not been sent or saved.';
@override
String get alreadyMember => 'Already part of sxchnge?';
@override
String get newHere => 'New around here?';
@override
String get loginInstead => 'Log in instead';
@override
String get createAnAccount => 'Create an account';
@override
String get earlyPreview =>
'EARLY PREVIEW · Login and sign-up are not live yet.';
@override
String get brandEyebrow => 'A LITTLE CHANGE. A BIG NEXT STEP.';
@override
String get brandHeading => 'Good moves.\nStart here.';
@override
String get brandIntro => 'A fresh way forward.\nMake it yours with sxchnge.';
@override
String get brandCard => 'Less friction.\nMore possibility.';
@override
String get brandFooter => 'YOUR NEXT MOVE STARTS WITH YOU.';
@override
String get emailRequired => 'Enter your email address.';
@override
String get emailInvalid => 'Enter a valid email address.';
@override
String get nameRequired => 'Enter your name.';
@override
String get passwordRequired => 'Enter your password.';
@override
String get confirmationRequired => 'Confirm your password.';
@override
String get passwordMismatch => 'Passwords do not match.';
@override
String get language => 'Language';
@override
String get preferenceNotSaved =>
'Language changed for this session. Your preference could not be saved.';
@override
String get authWorking => 'Please wait…';
@override
String get authCompleted => 'Request completed.';
@override
String get authInvalidCredentials =>
'Email or password is incorrect. Please try again.';
@override
String get authEmailInUse =>
'This email is already registered. Try logging in.';
@override
String get authUnavailable => 'The service is unavailable. Please try again.';
@override
String get authUnexpected => 'Something went wrong. Please try again.';
@override
String get checkInbox => 'Check your inbox.';
@override
String get verificationSent =>
'We’ve sent you a verification email. Open the link in your inbox to confirm your email address.';
@override
String get verificationSending => 'Preparing your verification email…';
@override
String get verificationChecking => 'Verifying your email…';
@override
String get verificationVerified => 'Your email\nis verified.';
@override
String get verificationVerifiedBody =>
'One step done. You can now return to login. Identity verification is a separate step.';
@override
String get verificationExpired => 'This link has expired.';
@override
String get verificationExpiredBody =>
'Request a new verification email and use the latest link.';
@override
String get verificationInvalid => 'This link isn’t valid.';
@override
String get verificationInvalidBody =>
'The confirmation link is missing or invalid. Open the complete link from your email, or request a new one.';
@override
String get verificationInternal => 'Something went wrong.';
@override
String get verificationInternalBody =>
'We couldn’t complete your request because of an internal error. Please try again.';
@override
String get verificationResend => 'Resend verification email';
@override
String get verificationRequest => 'Send a new verification email';
@override
String get verificationRetry => 'Try again';
@override
String get verificationBack => 'Back to login';
@override
String get verificationHint =>
'No email? Check your spam folder or request another below.';
@override
String get verificationDemo =>
'DEMO · No real email is sent and no account is verified.';
@override
String get verificationDemoLink => 'Open demo confirmation link';
@override
String get verificationStep => 'CONFIRM YOUR EMAIL';
}
@@ -0,0 +1,223 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Russian (`ru`).
class AppLocalizationsRu extends AppLocalizations {
AppLocalizationsRu([String locale = 'ru']) : super(locale);
@override
String get appTitle => 'SXCHNGE · Ваш следующий шаг';
@override
String get nextChapter => 'НОВАЯ ГЛАВА';
@override
String get loginHeading => 'Рады видеть\nвас снова.';
@override
String get signupHeading => 'Сделайте\nпервый шаг.';
@override
String get loginIntro => 'Войдите, чтобы сделать следующий шаг.';
@override
String get signupIntro => 'Новый аккаунт. Новые возможности.';
@override
String get login => 'Войти';
@override
String get signup => 'Регистрация';
@override
String get name => 'Ваше имя';
@override
String get email => 'Электронная почта';
@override
String get emailHint => 'you@example.com';
@override
String get password => 'Пароль';
@override
String get confirmPassword => 'Повторите пароль';
@override
String get passwordHint => 'Используйте не менее 12 символов.';
@override
String get showPassword => 'Показать пароль';
@override
String get hidePassword => 'Скрыть пароль';
@override
String get showConfirmation => 'Показать повтор пароля';
@override
String get hideConfirmation => 'Скрыть повтор пароля';
@override
String get createAccount => 'Создать аккаунт';
@override
String get letsGo => 'Продолжить';
@override
String get loginPreview =>
'Демонстрационный вход выполнен. Реальная сессия не создана. Учётные данные не отправлены и не сохранены.';
@override
String get signupPreview =>
'Демонстрационная регистрация выполнена. Реальный аккаунт не создан. Учётные данные не отправлены и не сохранены.';
@override
String get alreadyMember => 'Уже есть аккаунт sxchnge?';
@override
String get newHere => 'Впервые здесь?';
@override
String get loginInstead => 'Войти в аккаунт';
@override
String get createAnAccount => 'Зарегистрироваться';
@override
String get earlyPreview =>
'ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ · Вход и регистрация пока недоступны.';
@override
String get brandEyebrow => 'МАЛЕНЬКАЯ ПЕРЕМЕНА. БОЛЬШОЙ ШАГ.';
@override
String get brandHeading => 'Всё начинается\nс перемен.';
@override
String get brandIntro =>
'Новый путь вперёд.\nОткройте его вместе со sxchnge.';
@override
String get brandCard => 'Меньше преград.\nБольше возможностей.';
@override
String get brandFooter => 'СЛЕДУЮЩИЙ ШАГ — ЗА ВАМИ.';
@override
String get emailRequired => 'Введите электронную почту.';
@override
String get emailInvalid => 'Введите корректный адрес почты.';
@override
String get nameRequired => 'Введите ваше имя.';
@override
String get passwordRequired => 'Введите пароль.';
@override
String get confirmationRequired => 'Повторите пароль.';
@override
String get passwordMismatch => 'Пароли не совпадают.';
@override
String get language => 'Язык';
@override
String get preferenceNotSaved =>
'Язык изменён для этого сеанса. Сохранить выбор не удалось.';
@override
String get authWorking => 'Подождите…';
@override
String get authCompleted => 'Запрос выполнен.';
@override
String get authInvalidCredentials =>
'Неверная почта или пароль. Попробуйте ещё раз.';
@override
String get authEmailInUse =>
'Эта почта уже зарегистрирована. Попробуйте войти.';
@override
String get authUnavailable => 'Сервис недоступен. Попробуйте ещё раз.';
@override
String get authUnexpected => 'Что-то пошло не так. Попробуйте ещё раз.';
@override
String get checkInbox => 'Проверьте почту.';
@override
String get verificationSent =>
'Мы отправили вам письмо для подтверждения. Откройте ссылку в письме, чтобы подтвердить электронную почту.';
@override
String get verificationSending => 'Готовим письмо для подтверждения…';
@override
String get verificationChecking => 'Подтверждаем вашу почту…';
@override
String get verificationVerified => 'Ваша почта\nподтверждена.';
@override
String get verificationVerifiedBody =>
'Первый шаг пройден. Теперь можно вернуться ко входу. Подтверждение личности — отдельный этап.';
@override
String get verificationExpired => 'Срок действия ссылки истёк.';
@override
String get verificationExpiredBody =>
'Запросите новое письмо и перейдите по ссылке из него.';
@override
String get verificationInvalid => 'Ссылка недействительна.';
@override
String get verificationInvalidBody =>
'Ссылка подтверждения отсутствует или недействительна. Откройте полную ссылку из письма или запросите новую.';
@override
String get verificationInternal => 'Что-то пошло не так.';
@override
String get verificationInternalBody =>
'Не удалось выполнить запрос из-за внутренней ошибки. Попробуйте ещё раз.';
@override
String get verificationResend => 'Отправить письмо ещё раз';
@override
String get verificationRequest => 'Отправить новое письмо';
@override
String get verificationRetry => 'Попробовать ещё раз';
@override
String get verificationBack => 'Вернуться ко входу';
@override
String get verificationHint =>
'Нет письма? Проверьте папку «Спам» или запросите новое ниже.';
@override
String get verificationDemo =>
'ДЕМО · Письмо не отправляется, реальный аккаунт не подтверждается.';
@override
String get verificationDemoLink => 'Открыть демоссылку подтверждения';
@override
String get verificationStep => 'ПОДТВЕРДИТЕ ПОЧТУ';
}
+69
View File
@@ -0,0 +1,69 @@
{
"@@locale": "en",
"appTitle": "SXCHNGE · Your next move",
"nextChapter": "YOUR NEXT CHAPTER",
"loginHeading": "Hey, welcome\nback.",
"signupHeading": "Make your\nfirst move.",
"loginIntro": "Your next move is just a login away.",
"signupIntro": "A new account. A world of possibility.",
"login": "Log in",
"signup": "Sign up",
"name": "Your name",
"email": "Email address",
"emailHint": "you@example.com",
"password": "Password",
"confirmPassword": "Confirm password",
"passwordHint": "Use at least 12 characters.",
"showPassword": "Show password",
"hidePassword": "Hide password",
"showConfirmation": "Show confirmation",
"hideConfirmation": "Hide confirmation",
"createAccount": "Create account",
"letsGo": "Let’s go",
"loginPreview": "Demo login completed. No real session was created. Your credentials have not been sent or saved.",
"signupPreview": "Demo sign-up completed. No real account was created. Your credentials have not been sent or saved.",
"alreadyMember": "Already part of sxchnge?",
"newHere": "New around here?",
"loginInstead": "Log in instead",
"createAnAccount": "Create an account",
"earlyPreview": "EARLY PREVIEW · Login and sign-up are not live yet.",
"brandEyebrow": "A LITTLE CHANGE. A BIG NEXT STEP.",
"brandHeading": "Good moves.\nStart here.",
"brandIntro": "A fresh way forward.\nMake it yours with sxchnge.",
"brandCard": "Less friction.\nMore possibility.",
"brandFooter": "YOUR NEXT MOVE STARTS WITH YOU.",
"emailRequired": "Enter your email address.",
"emailInvalid": "Enter a valid email address.",
"nameRequired": "Enter your name.",
"passwordRequired": "Enter your password.",
"confirmationRequired": "Confirm your password.",
"passwordMismatch": "Passwords do not match.",
"language": "Language",
"preferenceNotSaved": "Language changed for this session. Your preference could not be saved.",
"authWorking": "Please wait…",
"authCompleted": "Request completed.",
"authInvalidCredentials": "Email or password is incorrect. Please try again.",
"authEmailInUse": "This email is already registered. Try logging in.",
"authUnavailable": "The service is unavailable. Please try again.",
"authUnexpected": "Something went wrong. Please try again.",
"checkInbox": "Check your inbox.",
"verificationSent": "We’ve sent you a verification email. Open the link in your inbox to confirm your email address.",
"verificationSending": "Preparing your verification email…",
"verificationChecking": "Verifying your email…",
"verificationVerified": "Your email\nis verified.",
"verificationVerifiedBody": "One step done. You can now return to login. Identity verification is a separate step.",
"verificationExpired": "This link has expired.",
"verificationExpiredBody": "Request a new verification email and use the latest link.",
"verificationInvalid": "This link isn’t valid.",
"verificationInvalidBody": "The confirmation link is missing or invalid. Open the complete link from your email, or request a new one.",
"verificationInternal": "Something went wrong.",
"verificationInternalBody": "We couldn’t complete your request because of an internal error. Please try again.",
"verificationResend": "Resend verification email",
"verificationRequest": "Send a new verification email",
"verificationRetry": "Try again",
"verificationBack": "Back to login",
"verificationHint": "No email? Check your spam folder or request another below.",
"verificationDemo": "DEMO · No real email is sent and no account is verified.",
"verificationDemoLink": "Open demo confirmation link",
"verificationStep": "CONFIRM YOUR EMAIL"
}
+69
View File
@@ -0,0 +1,69 @@
{
"@@locale": "ru",
"appTitle": "SXCHNGE · Ваш следующий шаг",
"nextChapter": "НОВАЯ ГЛАВА",
"loginHeading": "Рады видеть\nвас снова.",
"signupHeading": "Сделайте\nпервый шаг.",
"loginIntro": "Войдите, чтобы сделать следующий шаг.",
"signupIntro": "Новый аккаунт. Новые возможности.",
"login": "Войти",
"signup": "Регистрация",
"name": "Ваше имя",
"email": "Электронная почта",
"emailHint": "you@example.com",
"password": "Пароль",
"confirmPassword": "Повторите пароль",
"passwordHint": "Используйте не менее 12 символов.",
"showPassword": "Показать пароль",
"hidePassword": "Скрыть пароль",
"showConfirmation": "Показать повтор пароля",
"hideConfirmation": "Скрыть повтор пароля",
"createAccount": "Создать аккаунт",
"letsGo": "Продолжить",
"loginPreview": "Демонстрационный вход выполнен. Реальная сессия не создана. Учётные данные не отправлены и не сохранены.",
"signupPreview": "Демонстрационная регистрация выполнена. Реальный аккаунт не создан. Учётные данные не отправлены и не сохранены.",
"alreadyMember": "Уже есть аккаунт sxchnge?",
"newHere": "Впервые здесь?",
"loginInstead": "Войти в аккаунт",
"createAnAccount": "Зарегистрироваться",
"earlyPreview": "ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ · Вход и регистрация пока недоступны.",
"brandEyebrow": "МАЛЕНЬКАЯ ПЕРЕМЕНА. БОЛЬШОЙ ШАГ.",
"brandHeading": "Всё начинается\nс перемен.",
"brandIntro": "Новый путь вперёд.\nОткройте его вместе со sxchnge.",
"brandCard": "Меньше преград.\nБольше возможностей.",
"brandFooter": "СЛЕДУЮЩИЙ ШАГ — ЗА ВАМИ.",
"emailRequired": "Введите электронную почту.",
"emailInvalid": "Введите корректный адрес почты.",
"nameRequired": "Введите ваше имя.",
"passwordRequired": "Введите пароль.",
"confirmationRequired": "Повторите пароль.",
"passwordMismatch": "Пароли не совпадают.",
"language": "Язык",
"preferenceNotSaved": "Язык изменён для этого сеанса. Сохранить выбор не удалось.",
"authWorking": "Подождите…",
"authCompleted": "Запрос выполнен.",
"authInvalidCredentials": "Неверная почта или пароль. Попробуйте ещё раз.",
"authEmailInUse": "Эта почта уже зарегистрирована. Попробуйте войти.",
"authUnavailable": "Сервис недоступен. Попробуйте ещё раз.",
"authUnexpected": "Что-то пошло не так. Попробуйте ещё раз.",
"checkInbox": "Проверьте почту.",
"verificationSent": "Мы отправили вам письмо для подтверждения. Откройте ссылку в письме, чтобы подтвердить электронную почту.",
"verificationSending": "Готовим письмо для подтверждения…",
"verificationChecking": "Подтверждаем вашу почту…",
"verificationVerified": "Ваша почта\nподтверждена.",
"verificationVerifiedBody": "Первый шаг пройден. Теперь можно вернуться ко входу. Подтверждение личности — отдельный этап.",
"verificationExpired": "Срок действия ссылки истёк.",
"verificationExpiredBody": "Запросите новое письмо и перейдите по ссылке из него.",
"verificationInvalid": "Ссылка недействительна.",
"verificationInvalidBody": "Ссылка подтверждения отсутствует или недействительна. Откройте полную ссылку из письма или запросите новую.",
"verificationInternal": "Что-то пошло не так.",
"verificationInternalBody": "Не удалось выполнить запрос из-за внутренней ошибки. Попробуйте ещё раз.",
"verificationResend": "Отправить письмо ещё раз",
"verificationRequest": "Отправить новое письмо",
"verificationRetry": "Попробовать ещё раз",
"verificationBack": "Вернуться ко входу",
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
"verificationDemo": "ДЕМО · Письмо не отправляется, реальный аккаунт не подтверждается.",
"verificationDemoLink": "Открыть демоссылку подтверждения",
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ"
}
+71
View File
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_theme/sxchnge_theme.dart';
import 'providers/locale.dart';
import 'routing/router.dart';
import 'generated/i18n/app_localizations.dart';
Future<void> main() async {
final binding = WidgetsFlutterBinding.ensureInitialized();
final locale = await LocaleProvider.load(binding.platformDispatcher.locales);
runApp(SxchngeApp(localeProvider: locale));
}
class SxchngeApp extends StatefulWidget {
const SxchngeApp({
super.key,
this.localeProvider,
this.loginService = const DummyLoginService(),
this.signupService = const DummySignupService(),
this.verificationService = const DummyVerificationService(),
});
final LocaleProvider? localeProvider;
final LoginService loginService;
final SignupService signupService;
final VerificationService verificationService;
@override
State<SxchngeApp> createState() => _SxchngeAppState();
}
class _SxchngeAppState extends State<SxchngeApp> {
late final _locale = widget.localeProvider ?? LocaleProvider();
late final _router = createAppRouter();
@override
void dispose() {
_router.dispose();
_locale.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => MultiProvider(
providers: [
ChangeNotifierProvider.value(value: _locale),
Provider<VerificationService>.value(value: widget.verificationService),
ChangeNotifierProvider(create: (_) => LoginProvider(widget.loginService)),
ChangeNotifierProvider(
create: (_) => SignupProvider(widget.signupService),
),
],
child: Consumer<LocaleProvider>(
builder: (context, locale, _) => MaterialApp.router(
onGenerateTitle: (context) => AppLocalizations.of(context)!.appTitle,
debugShowCheckedModeBanner: false,
theme: SxchngeTheme.light(),
locale: locale.locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
routerConfig: _router,
),
),
);
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class LocaleProvider extends ChangeNotifier {
static const storageKey = 'sxchnge_last_locale';
LocaleProvider({Locale initialLocale = const Locale('en'), this.preferences})
: _locale = resolve(initialLocale.languageCode, const []);
Locale _locale;
final SharedPreferences? preferences;
Future<bool> _pendingSave = Future.value(true);
Locale get locale => _locale;
static Locale resolve(String? saved, List<Locale> systemLocales) {
final code =
saved?.split(RegExp('[-_]')).first ??
(systemLocales.isEmpty ? 'en' : systemLocales.first.languageCode);
return Locale(code == 'ru' ? 'ru' : 'en');
}
static Future<LocaleProvider> load(List<Locale> systemLocales) async {
try {
final prefs = await SharedPreferences.getInstance();
return LocaleProvider(
initialLocale: resolve(prefs.getString(storageKey), systemLocales),
preferences: prefs,
);
} catch (_) {
return LocaleProvider(initialLocale: resolve(null, systemLocales));
}
}
Future<bool> setLocale(Locale value) {
final next = resolve(value.languageCode, const []);
if (next == _locale) return _pendingSave;
_locale = next;
notifyListeners();
// Serialize writes so rapid language changes persist the last selection.
_pendingSave = _pendingSave.then((_) async {
try {
return await preferences?.setString(storageKey, next.languageCode) ??
false;
} catch (_) {
return false;
}
});
return _pendingSave;
}
}
+48
View File
@@ -0,0 +1,48 @@
import 'dart:convert';
import 'package:go_router/go_router.dart';
import '../auth/page.dart';
import '../verification/page.dart';
import 'routes.dart';
GoRouter createAppRouter() => GoRouter(
// Signup email is an in-memory navigation extra, never browser-history data.
extraCodec: const _TransientExtras(),
routes: [
GoRoute(
path: '/',
name: AppRoutes.login,
builder: (context, state) => const AuthPage(),
),
GoRoute(
path: '/verification-sent',
name: AppRoutes.verificationSent,
builder: (context, state) => VerificationPage(
email: state.extra is String ? state.extra as String : null,
),
),
GoRoute(
path: '/verify-email',
name: AppRoutes.verifyEmail,
builder: (context, state) =>
VerificationPage(token: state.uri.queryParameters['token'] ?? ''),
),
],
// Preserve the existing fallback, and keep the visible URL canonical.
onException: (context, state, router) => router.goNamed(AppRoutes.login),
);
class _TransientExtras extends Codec<Object?, Object?> {
const _TransientExtras();
@override
Converter<Object?, Object?> get encoder => const _DiscardExtra();
@override
Converter<Object?, Object?> get decoder => const _DiscardExtra();
}
class _DiscardExtra extends Converter<Object?, Object?> {
const _DiscardExtra();
@override
Object? convert(Object? input) => null;
}
+5
View File
@@ -0,0 +1,5 @@
abstract final class AppRoutes {
static const login = 'login';
static const verificationSent = 'verification-sent';
static const verifyEmail = 'verify-email';
}
+279
View File
@@ -0,0 +1,279 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../auth/brand_panel.dart';
import '../auth/validation.dart';
import '../routing/routes.dart';
import '../widgets/locale.dart';
import '../generated/i18n/app_localizations.dart';
class VerificationPage extends StatefulWidget {
const VerificationPage({super.key, this.email, this.token});
final String? email;
final String? token;
@override
State<VerificationPage> createState() => _VerificationPageState();
}
class _VerificationPageState extends State<VerificationPage> {
final _form = GlobalKey<FormState>();
late final _email = TextEditingController(text: widget.email);
late final _provider = VerificationProvider(
context.read<VerificationService>(),
);
bool _initialized = false;
bool _lastWasSend = false;
bool _validated = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialized) {
_initialized = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (widget.token != null) {
_provider.confirm(widget.token!);
} else if (_email.text.isNotEmpty) {
_send();
}
});
} else if (_validated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _form.currentState?.validate();
});
}
}
@override
void didUpdateWidget(covariant VerificationPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.token == widget.token) return;
_provider.reset();
_lastWasSend = false;
_validated = false;
_email.clear();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.token != null) _provider.confirm(widget.token!);
});
}
void _send() {
if (_provider.state.isLoading) return;
_validated = true;
if (!(_form.currentState?.validate() ?? false)) return;
_lastWasSend = true;
_provider.send(
email: _email.text,
locale: Localizations.localeOf(context).languageCode,
);
}
@override
void dispose() {
_provider.dispose();
_email.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: _provider,
builder: (context, _) {
final l = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final colors = theme.colorScheme;
final state = _provider.state;
final status = state.status;
final failed = [
VerificationStatus.expired,
VerificationStatus.invalid,
VerificationStatus.error,
].contains(status);
final verified = status == VerificationStatus.verified;
final demo =
_provider.service is DummyVerificationService || state.isSimulated;
final title = switch (status) {
VerificationStatus.sent => l.checkInbox,
VerificationStatus.sending => l.verificationSending,
VerificationStatus.checking => l.verificationChecking,
VerificationStatus.verified => l.verificationVerified,
VerificationStatus.expired => l.verificationExpired,
VerificationStatus.invalid => l.verificationInvalid,
VerificationStatus.error => l.verificationInternal,
VerificationStatus.idle =>
widget.token != null ? l.verificationChecking : l.verificationRequest,
};
final body = switch (status) {
VerificationStatus.sent => l.verificationSent,
VerificationStatus.verified => l.verificationVerifiedBody,
VerificationStatus.expired => l.verificationExpiredBody,
VerificationStatus.invalid => l.verificationInvalidBody,
VerificationStatus.error => l.verificationInternalBody,
_ => null,
};
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Align(
alignment: Alignment.centerRight,
child: LocaleSelector(),
),
const Align(
alignment: Alignment.centerLeft,
child: BrandWordmark(),
),
const SizedBox(height: 48),
Align(
alignment: Alignment.centerLeft,
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: failed
? colors.errorContainer
: colors.primaryContainer,
borderRadius: BorderRadius.circular(24),
),
child: state.isLoading
? const SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(),
)
: Icon(
verified
? Icons.mark_email_read_outlined
: failed
? Icons.error_outline
: Icons.mark_email_unread_outlined,
size: 32,
color: failed
? colors.onErrorContainer
: colors.onPrimaryContainer,
),
),
),
const SizedBox(height: 28),
Text(
l.verificationStep,
style: theme.textTheme.labelLarge?.copyWith(
color: colors.primary,
),
),
const SizedBox(height: 12),
Semantics(
liveRegion: true,
child: Text(
title,
key: ValueKey('verification-${status.name}'),
style: theme.textTheme.displaySmall,
),
),
if (body != null) ...[
const SizedBox(height: 20),
Text(
body,
style: theme.textTheme.bodyLarge?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
if (status == VerificationStatus.sent) ...[
const SizedBox(height: 16),
Text(
l.verificationHint,
style: theme.textTheme.bodyMedium,
),
],
if (!verified && status != VerificationStatus.checking) ...[
const SizedBox(height: 28),
Form(
key: _form,
child: TextFormField(
key: const ValueKey('verification-email'),
controller: _email,
enabled: !state.isLoading,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
textInputAction: TextInputAction.send,
validator: (value) => AuthValidation.email(value, l),
onFieldSubmitted: (_) => _send(),
decoration: InputDecoration(
labelText: l.email,
hintText: l.emailHint,
errorMaxLines: 3,
prefixIcon: const Icon(Icons.alternate_email),
),
),
),
const SizedBox(height: 16),
FilledButton(
key: const ValueKey('verification-resend'),
onPressed: state.isLoading ? null : _send,
child: Text(
state.isLoading
? l.authWorking
: status == VerificationStatus.sent
? l.verificationResend
: l.verificationRequest,
textAlign: TextAlign.center,
),
),
],
if (status == VerificationStatus.error &&
!_lastWasSend &&
widget.token != null) ...[
const SizedBox(height: 12),
OutlinedButton(
key: const ValueKey('verification-retry'),
onPressed: () => _provider.confirm(widget.token!),
child: Text(l.verificationRetry),
),
],
const SizedBox(height: 16),
TextButton(
onPressed: () => context.goNamed(AppRoutes.login),
child: Text(l.verificationBack),
),
if (demo) ...[
const SizedBox(height: 24),
Text(
l.verificationDemo,
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
if (status == VerificationStatus.sent)
TextButton(
key: const ValueKey('demo-confirmation'),
onPressed: () => context.goNamed(
AppRoutes.verifyEmail,
queryParameters: {'token': 'demo-valid'},
),
child: Text(l.verificationDemoLink),
),
],
],
),
),
),
),
),
);
},
);
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/locale.dart';
import '../generated/i18n/app_localizations.dart';
class LocaleSelector extends StatelessWidget {
const LocaleSelector({super.key});
@override
Widget build(BuildContext context) {
final provider = context.watch<LocaleProvider>();
return PopupMenuButton<Locale>(
key: const ValueKey('locale-selector'),
tooltip: AppLocalizations.of(context)!.language,
onSelected: (locale) async {
final saved = await provider.setLocale(locale);
if (!saved && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.preferenceNotSaved),
),
);
}
},
itemBuilder: (_) => AppLocalizations.supportedLocales
.map(
(locale) => CheckedPopupMenuItem(
value: locale,
checked: locale == provider.locale,
child: Text(locale.languageCode == 'ru' ? 'Русский' : 'English'),
),
)
.toList(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.language, size: 20),
const SizedBox(width: 8),
Text(
provider.locale.languageCode.toUpperCase(),
style: Theme.of(context).textTheme.labelLarge,
),
const Icon(Icons.expand_more, size: 20),
],
),
),
);
}
}