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;
}