Files
sxchnge/frontend/app/lib/auth/form.dart
T
Stephan D f1cbf64282 Fix onboarding UX and add replaceable journey analytics
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.
2026-09-12 08:36:45 +02:00

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),
),
],
),
],
);
}
}