fix: ignore raw Dio blobs in API responses and replace email change dialog with a scrollable bottom sheet.

This commit is contained in:
pradeepkumar
2026-05-06 14:35:35 +05:30
parent 8845e77274
commit ff71a50229
2 changed files with 239 additions and 82 deletions

View File

@@ -255,8 +255,18 @@ class ApiClient {
: rawMessage?.toString(); : rawMessage?.toString();
if (message != null && message.isNotEmpty) { if (message != null && message.isNotEmpty) {
// Defensive: if the backend echoed back the raw Dio exception
// toString (e.g. "ApiException: This exception was thrown..."),
// 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');
if (!looksLikeRawDioBlob) {
final errors = (dataMap['errors'] as List<dynamic>?) final errors = (dataMap['errors'] as List<dynamic>?)
?.map((e) => FieldError.fromJson(e as Map<String, dynamic>)) ?.map(
(e) => FieldError.fromJson(e as Map<String, dynamic>))
.toList() ?? .toList() ??
[]; [];
@@ -272,6 +282,7 @@ class ApiClient {
} }
} }
} }
}
// 2. Fallback — build a user-friendly message from the status code or // 2. Fallback — build a user-friendly message from the status code or
// Dio exception type. NEVER use `error.message` because Dio emits a // Dio exception type. NEVER use `error.message` because Dio emits a

View File

@@ -435,49 +435,94 @@ 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(
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, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ 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( const Text(
'A verification link will be sent to your new email. Your email changes only after you click the link.', 'A verification link will be sent to your new email. Your email changes only after you click the link.',
style: TextStyle(fontSize: 13), style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
color: Color(0xFF6B7280),
height: 1.4,
), ),
const SizedBox(height: 16), ),
const SizedBox(height: 20),
_buildSheetLabel('New Email'),
const SizedBox(height: 6),
TextField( TextField(
controller: newEmailCtl, controller: newEmailCtl,
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
@@ -485,23 +530,36 @@ class _ProfileSettingsTabState extends ConsumerState<ProfileSettingsTab> {
enableSuggestions: false, enableSuggestions: false,
textCapitalization: TextCapitalization.none, textCapitalization: TextCapitalization.none,
autofillHints: const [AutofillHints.email], autofillHints: const [AutofillHints.email],
decoration: const InputDecoration( decoration: _sheetInputDecoration(
labelText: 'New Email', 'new@example.com',
hintText: 'new@example.com',
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 14),
_buildSheetLabel('Current Password'),
const SizedBox(height: 6),
TextField( TextField(
controller: passwordCtl, controller: passwordCtl,
obscureText: true, obscureText: obscurePw,
autocorrect: false, autocorrect: false,
enableSuggestions: false, enableSuggestions: false,
// Empty autofillHints prevents iOS from showing the // Empty autofillHints prevents iOS from showing the
// "Strong Password" suggestion bar, which was adding // "Strong Password" suggestion bar, which was adding
// extra bottom inset and shifting the dialog upward. // extra bottom inset.
autofillHints: const <String>[], autofillHints: const <String>[],
decoration: const InputDecoration( decoration: _sheetInputDecoration(
labelText: 'Current Password', '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) ...[ if (localError != null) ...[
@@ -509,29 +567,74 @@ class _ProfileSettingsTabState extends ConsumerState<ProfileSettingsTab> {
Text( Text(
localError!, localError!,
style: const TextStyle( style: const TextStyle(
fontFamily: 'SourceSerif4',
color: Colors.red, color: Colors.red,
fontSize: 12, fontSize: 12,
), ),
), ),
], ],
], const SizedBox(height: 24),
), Row(
actions: [ children: [
TextButton( Expanded(
child: OutlinedButton(
onPressed: submitting onPressed: submitting
? null ? null
: () => Navigator.of(dialogCtx).pop(), : () => Navigator.of(sheetCtx).pop(),
child: const Text('Cancel'), style: OutlinedButton.styleFrom(
side: const BorderSide(
color: Color(0xFFE5E7EB),
), ),
ElevatedButton( 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, onPressed: submitting ? null : submit,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.accentOrange, backgroundColor: AppColors.accentOrange,
foregroundColor: Colors.white, 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,
),
),
), ),
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,
);
}
} }