Make recipient changes explicit, prioritize verification retry, and label simulated outcomes prominently. Add shared typed analytics with bounded dummy capture, correlated service events and documented PostHog adapter contract. Validate privacy, retries, abandonment, keyboard recovery and accessibility. Related to #12.
331 lines
11 KiB
Dart
331 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import 'package:provider/provider.dart';
|
|
|
|
import 'package:sxchnge_analytics/sxchnge_analytics.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<JourneyRecorder>().abandon(JourneyExit.modeChange);
|
|
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: 16),
|
|
Text(
|
|
l.earlyPreview,
|
|
style: text.bodySmall!.copyWith(color: colors.onSurfaceVariant),
|
|
),
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|