refactor: extract email change modal into a dedicated StatefulWidget to improve state management and lifecycle handling
This commit is contained in:
@@ -431,233 +431,251 @@ class _ProfileSettingsTabState extends ConsumerState<ProfileSettingsTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openChangeEmailDialog() async {
|
Future<void> _openChangeEmailDialog() async {
|
||||||
final newEmailCtl = TextEditingController();
|
|
||||||
final passwordCtl = TextEditingController();
|
|
||||||
String? localError;
|
|
||||||
bool submitting = false;
|
|
||||||
bool obscurePw = true;
|
|
||||||
|
|
||||||
await showModalBottomSheet<void>(
|
await showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
builder: (sheetCtx) {
|
builder: (sheetCtx) {
|
||||||
return StatefulBuilder(
|
return _ChangeEmailSheet(
|
||||||
builder: (sheetCtx, setSheetState) {
|
onSubmit: (email, password) async {
|
||||||
Future<void> submit() async {
|
final repo = ref.read(profileRepositoryProvider);
|
||||||
final email = newEmailCtl.text.trim();
|
return repo.requestEmailChange(email, password);
|
||||||
final pw = passwordCtl.text;
|
},
|
||||||
if (email.isEmpty || pw.isEmpty) {
|
onSuccess: (message) {
|
||||||
setSheetState(() => localError =
|
if (mounted) _showSnackBar(message);
|
||||||
'Please enter a new email and your current password');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSheetState(() {
|
|
||||||
submitting = true;
|
|
||||||
localError = null;
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
final repo = ref.read(profileRepositoryProvider);
|
|
||||||
final message = await repo.requestEmailChange(email, pw);
|
|
||||||
if (sheetCtx.mounted) Navigator.of(sheetCtx).pop();
|
|
||||||
if (mounted) _showSnackBar(message);
|
|
||||||
} catch (e) {
|
|
||||||
setSheetState(() {
|
|
||||||
submitting = false;
|
|
||||||
localError = e.toString().replaceFirst('Exception: ', '');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add bottom inset so the sheet rises above the keyboard.
|
|
||||||
final bottomInset = MediaQuery.of(sheetCtx).viewInsets.bottom;
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: EdgeInsets.only(bottom: bottomInset),
|
|
||||||
child: Container(
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.vertical(
|
|
||||||
top: Radius.circular(24),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
|
|
||||||
child: SafeArea(
|
|
||||||
top: false,
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
// Drag handle
|
|
||||||
Center(
|
|
||||||
child: Container(
|
|
||||||
width: 40,
|
|
||||||
height: 4,
|
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFE5E7EB),
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Text(
|
|
||||||
'Change Email Address',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: 'Fractul',
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.primaryDark,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const Text(
|
|
||||||
'A verification link will be sent to your new email. Your email changes only after you click the link.',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: 'SourceSerif4',
|
|
||||||
fontSize: 13,
|
|
||||||
color: Color(0xFF6B7280),
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
_buildSheetLabel('New Email'),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
TextField(
|
|
||||||
controller: newEmailCtl,
|
|
||||||
keyboardType: TextInputType.emailAddress,
|
|
||||||
autocorrect: false,
|
|
||||||
enableSuggestions: false,
|
|
||||||
textCapitalization: TextCapitalization.none,
|
|
||||||
autofillHints: const [AutofillHints.email],
|
|
||||||
decoration: _sheetInputDecoration(
|
|
||||||
'new@example.com',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
_buildSheetLabel('Current Password'),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
TextField(
|
|
||||||
controller: passwordCtl,
|
|
||||||
obscureText: obscurePw,
|
|
||||||
autocorrect: false,
|
|
||||||
enableSuggestions: false,
|
|
||||||
// Empty autofillHints prevents iOS from showing the
|
|
||||||
// "Strong Password" suggestion bar, which was adding
|
|
||||||
// extra bottom inset.
|
|
||||||
autofillHints: const <String>[],
|
|
||||||
decoration: _sheetInputDecoration(
|
|
||||||
'Enter your current password',
|
|
||||||
suffixIcon: IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
obscurePw
|
|
||||||
? Icons.visibility_off_outlined
|
|
||||||
: Icons.visibility_outlined,
|
|
||||||
color: const Color(0xFF6B7280),
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
onPressed: () => setSheetState(
|
|
||||||
() => obscurePw = !obscurePw,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (localError != null) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
localError!,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontFamily: 'SourceSerif4',
|
|
||||||
color: Colors.red,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton(
|
|
||||||
onPressed: submitting
|
|
||||||
? null
|
|
||||||
: () => Navigator.of(sheetCtx).pop(),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
side: const BorderSide(
|
|
||||||
color: Color(0xFFE5E7EB),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 14,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'Cancel',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: 'Fractul',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.primaryDark,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: submitting ? null : submit,
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: AppColors.accentOrange,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 14,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
elevation: 0,
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
submitting ? 'Sending...' : 'Send Link',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontFamily: 'Fractul',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
newEmailCtl.dispose();
|
/// Bottom-sheet widget for changing email. Owns its own controllers so they
|
||||||
passwordCtl.dispose();
|
/// remain valid throughout the dismiss animation, then disposes cleanly.
|
||||||
|
class _ChangeEmailSheet extends StatefulWidget {
|
||||||
|
final Future<String> Function(String email, String password) onSubmit;
|
||||||
|
final void Function(String message) onSuccess;
|
||||||
|
|
||||||
|
const _ChangeEmailSheet({
|
||||||
|
required this.onSubmit,
|
||||||
|
required this.onSuccess,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ChangeEmailSheet> createState() => _ChangeEmailSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChangeEmailSheetState extends State<_ChangeEmailSheet> {
|
||||||
|
final _newEmailCtl = TextEditingController();
|
||||||
|
final _passwordCtl = TextEditingController();
|
||||||
|
String? _localError;
|
||||||
|
bool _submitting = false;
|
||||||
|
bool _obscurePw = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_newEmailCtl.dispose();
|
||||||
|
_passwordCtl.dispose();
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSheetLabel(String text) {
|
Future<void> _submit() async {
|
||||||
return Text(
|
final email = _newEmailCtl.text.trim();
|
||||||
text,
|
final pw = _passwordCtl.text;
|
||||||
style: const TextStyle(
|
if (email.isEmpty || pw.isEmpty) {
|
||||||
fontFamily: 'Fractul',
|
setState(() => _localError =
|
||||||
fontSize: 13,
|
'Please enter a new email and your current password');
|
||||||
fontWeight: FontWeight.w500,
|
return;
|
||||||
color: AppColors.primaryDark,
|
}
|
||||||
|
setState(() {
|
||||||
|
_submitting = true;
|
||||||
|
_localError = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final message = await widget.onSubmit(email, pw);
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
widget.onSuccess(message);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_submitting = false;
|
||||||
|
_localError = e.toString().replaceFirst('Exception: ', '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: bottomInset),
|
||||||
|
child: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
|
||||||
|
child: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Drag handle
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFE5E7EB),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'Change Email Address',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'A verification link will be sent to your new email. Your email changes only after you click the link.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 13,
|
||||||
|
color: Color(0xFF6B7280),
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_label('New Email'),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
TextField(
|
||||||
|
controller: _newEmailCtl,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
textCapitalization: TextCapitalization.none,
|
||||||
|
autofillHints: const [AutofillHints.email],
|
||||||
|
decoration: _decoration('new@example.com'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_label('Current Password'),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
TextField(
|
||||||
|
controller: _passwordCtl,
|
||||||
|
obscureText: _obscurePw,
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
// Empty autofillHints prevents iOS from showing the
|
||||||
|
// "Strong Password" suggestion bar.
|
||||||
|
autofillHints: const <String>[],
|
||||||
|
decoration: _decoration(
|
||||||
|
'Enter your current password',
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_obscurePw
|
||||||
|
? Icons.visibility_off_outlined
|
||||||
|
: Icons.visibility_outlined,
|
||||||
|
color: const Color(0xFF6B7280),
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
onPressed: () =>
|
||||||
|
setState(() => _obscurePw = !_obscurePw),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_localError != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_localError!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
color: Colors.red,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: _submitting
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(context).pop(),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: const BorderSide(color: Color(0xFFE5E7EB)),
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Cancel',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _submitting ? null : _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.accentOrange,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_submitting ? 'Sending...' : 'Send Link',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
InputDecoration _sheetInputDecoration(String hint, {Widget? suffixIcon}) {
|
Widget _label(String text) => Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
InputDecoration _decoration(String hint, {Widget? suffixIcon}) {
|
||||||
return InputDecoration(
|
return InputDecoration(
|
||||||
hintText: hint,
|
hintText: hint,
|
||||||
hintStyle: const TextStyle(
|
hintStyle: const TextStyle(
|
||||||
|
|||||||
Reference in New Issue
Block a user