fix: ignore raw Dio blobs in API responses and replace email change dialog with a scrollable bottom sheet.
This commit is contained in:
@@ -255,20 +255,31 @@ class ApiClient {
|
|||||||
: rawMessage?.toString();
|
: rawMessage?.toString();
|
||||||
|
|
||||||
if (message != null && message.isNotEmpty) {
|
if (message != null && message.isNotEmpty) {
|
||||||
final errors = (dataMap['errors'] as List<dynamic>?)
|
// Defensive: if the backend echoed back the raw Dio exception
|
||||||
?.map((e) => FieldError.fromJson(e as Map<String, dynamic>))
|
// toString (e.g. "ApiException: This exception was thrown..."),
|
||||||
.toList() ??
|
// ignore it and fall through to the friendly status-code message.
|
||||||
[];
|
final looksLikeRawDioBlob =
|
||||||
|
message.startsWith('ApiException:') ||
|
||||||
|
message.contains('RequestOptions.validateStatus') ||
|
||||||
|
message.contains('This exception was thrown because');
|
||||||
|
|
||||||
return DioException(
|
if (!looksLikeRawDioBlob) {
|
||||||
requestOptions: error.requestOptions,
|
final errors = (dataMap['errors'] as List<dynamic>?)
|
||||||
response: error.response,
|
?.map(
|
||||||
error: ApiException(
|
(e) => FieldError.fromJson(e as Map<String, dynamic>))
|
||||||
message: message,
|
.toList() ??
|
||||||
statusCode: response.statusCode,
|
[];
|
||||||
fieldErrors: errors,
|
|
||||||
),
|
return DioException(
|
||||||
);
|
requestOptions: error.requestOptions,
|
||||||
|
response: error.response,
|
||||||
|
error: ApiException(
|
||||||
|
message: message,
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
fieldErrors: errors,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -435,103 +435,206 @@ class _ProfileSettingsTabState extends ConsumerState<ProfileSettingsTab> {
|
|||||||
final passwordCtl = TextEditingController();
|
final passwordCtl = TextEditingController();
|
||||||
String? localError;
|
String? localError;
|
||||||
bool submitting = false;
|
bool submitting = false;
|
||||||
|
bool obscurePw = true;
|
||||||
|
|
||||||
await showDialog<void>(
|
await showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogCtx) {
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (sheetCtx) {
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (dialogCtx, setDialogState) {
|
builder: (sheetCtx, setSheetState) {
|
||||||
Future<void> submit() async {
|
Future<void> submit() async {
|
||||||
final email = newEmailCtl.text.trim();
|
final email = newEmailCtl.text.trim();
|
||||||
final pw = passwordCtl.text;
|
final pw = passwordCtl.text;
|
||||||
if (email.isEmpty || pw.isEmpty) {
|
if (email.isEmpty || pw.isEmpty) {
|
||||||
setDialogState(() => localError =
|
setSheetState(() => localError =
|
||||||
'Please enter a new email and your current password');
|
'Please enter a new email and your current password');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDialogState(() {
|
setSheetState(() {
|
||||||
submitting = true;
|
submitting = true;
|
||||||
localError = null;
|
localError = null;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
final repo = ref.read(profileRepositoryProvider);
|
final repo = ref.read(profileRepositoryProvider);
|
||||||
final message = await repo.requestEmailChange(email, pw);
|
final message = await repo.requestEmailChange(email, pw);
|
||||||
if (dialogCtx.mounted) Navigator.of(dialogCtx).pop();
|
if (sheetCtx.mounted) Navigator.of(sheetCtx).pop();
|
||||||
if (mounted) _showSnackBar(message);
|
if (mounted) _showSnackBar(message);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setDialogState(() {
|
setSheetState(() {
|
||||||
submitting = false;
|
submitting = false;
|
||||||
localError = e.toString().replaceFirst('Exception: ', '');
|
localError = e.toString().replaceFirst('Exception: ', '');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return AlertDialog(
|
// Add bottom inset so the sheet rises above the keyboard.
|
||||||
scrollable: true,
|
final bottomInset = MediaQuery.of(sheetCtx).viewInsets.bottom;
|
||||||
title: const Text('Change Email Address'),
|
|
||||||
content: Column(
|
return Padding(
|
||||||
mainAxisSize: MainAxisSize.min,
|
padding: EdgeInsets.only(bottom: bottomInset),
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: Container(
|
||||||
children: [
|
decoration: const BoxDecoration(
|
||||||
const Text(
|
color: Colors.white,
|
||||||
'A verification link will be sent to your new email. Your email changes only after you click the link.',
|
borderRadius: BorderRadius.vertical(
|
||||||
style: TextStyle(fontSize: 13),
|
top: Radius.circular(24),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
TextField(
|
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
|
||||||
controller: newEmailCtl,
|
child: SafeArea(
|
||||||
keyboardType: TextInputType.emailAddress,
|
top: false,
|
||||||
autocorrect: false,
|
child: SingleChildScrollView(
|
||||||
enableSuggestions: false,
|
child: Column(
|
||||||
textCapitalization: TextCapitalization.none,
|
mainAxisSize: MainAxisSize.min,
|
||||||
autofillHints: const [AutofillHints.email],
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
decoration: const InputDecoration(
|
children: [
|
||||||
labelText: 'New Email',
|
// Drag handle
|
||||||
hintText: 'new@example.com',
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
TextField(
|
|
||||||
controller: passwordCtl,
|
|
||||||
obscureText: true,
|
|
||||||
autocorrect: false,
|
|
||||||
enableSuggestions: false,
|
|
||||||
// Empty autofillHints prevents iOS from showing the
|
|
||||||
// "Strong Password" suggestion bar, which was adding
|
|
||||||
// extra bottom inset and shifting the dialog upward.
|
|
||||||
autofillHints: const <String>[],
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Current Password',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (localError != null) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
localError!,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.red,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: submitting
|
|
||||||
? null
|
|
||||||
: () => Navigator.of(dialogCtx).pop(),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: submitting ? null : submit,
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: AppColors.accentOrange,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
),
|
|
||||||
child: Text(submitting ? 'Sending...' : 'Send Link'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -541,4 +644,47 @@ class _ProfileSettingsTabState extends ConsumerState<ProfileSettingsTab> {
|
|||||||
newEmailCtl.dispose();
|
newEmailCtl.dispose();
|
||||||
passwordCtl.dispose();
|
passwordCtl.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSheetLabel(String text) {
|
||||||
|
return Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _sheetInputDecoration(String hint, {Widget? suffixIcon}) {
|
||||||
|
return InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: const TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Color(0xFF9CA3AF),
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: const Color(0xFFF9FAFB),
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFFE5E7EB)),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFFE5E7EB)),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(
|
||||||
|
color: AppColors.accentOrange,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
suffixIcon: suffixIcon,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user