From fd55deeeed4cc9515be75672bc6426c447fa0903 Mon Sep 17 00:00:00 2001 From: AJ-ing Date: Wed, 29 Jul 2026 11:33:03 +1000 Subject: [PATCH 1/4] Improve user-facing messages for network and API failures Add a centralized AppErrorHandler that maps network, timeout, auth, and server failures to consistent copy, and wire it through Supabase/ AI service error returns and UI SnackBars. Co-authored-by: Cursor --- lib/screens/auth/forgot_password_screen.dart | 21 +- lib/screens/auth/login_screen.dart | 22 +- lib/screens/auth/set_new_password_screen.dart | 3 +- lib/screens/auth/signup_screen.dart | 18 +- lib/screens/auth/team_selection_dialog.dart | 19 +- lib/screens/auth/verify_otp_screen.dart | 66 ++-- lib/screens/chat/chat_screen.dart | 22 +- lib/screens/home/dashboard_screen.dart | 5 +- .../meetings/create_meeting_screen.dart | 5 +- .../meetings/meeting_detail_screen.dart | 19 +- .../meetings/meeting_insights_screen.dart | 5 +- lib/screens/meetings/meeting_screen.dart | 7 +- lib/screens/profile/edit_profile_screen.dart | 3 +- lib/screens/profile/profile_screen.dart | 9 +- lib/screens/profile/team_members_screen.dart | 3 +- lib/screens/tasks/create_task_screen.dart | 5 +- lib/screens/tasks/task_detail_screen.dart | 13 +- lib/screens/tasks/task_screen.dart | 22 +- lib/screens/tickets/create_ticket_screen.dart | 5 +- lib/screens/tickets/ticket_detail_screen.dart | 19 +- lib/screens/tickets/ticket_screen.dart | 22 +- lib/services/ai_service.dart | 9 +- lib/services/supabase_service.dart | 51 +-- lib/utils/app_error_handler.dart | 348 ++++++++++++++++++ test/app_error_handler_test.dart | 83 +++++ 25 files changed, 612 insertions(+), 192 deletions(-) create mode 100644 lib/utils/app_error_handler.dart create mode 100644 test/app_error_handler_test.dart diff --git a/lib/screens/auth/forgot_password_screen.dart b/lib/screens/auth/forgot_password_screen.dart index d20ed7c9..f3b193a5 100644 --- a/lib/screens/auth/forgot_password_screen.dart +++ b/lib/screens/auth/forgot_password_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; import '../../widgets/custom_widgets.dart'; import '../../services/navigation_service.dart'; import '../../services/supabase_service.dart'; @@ -58,18 +59,16 @@ class _ForgotPasswordScreenState extends State { if (mounted) { // Show user-friendly error message setState(() { - String errorMsg = 'An error occurred. Please try again.'; - - // Parse the error message to be more user-friendly - if (e.toString().contains('Invalid email')) { - errorMsg = 'Invalid email address'; - } else if (e.toString().contains('Email not found')) { - errorMsg = 'Email address not found'; - } else if (e.toString().contains('Rate limit')) { - errorMsg = 'Too many attempts. Please try again later.'; + final raw = e.toString(); + if (raw.contains('Invalid email')) { + _errorMessage = 'Invalid email address'; + } else if (raw.contains('Email not found')) { + _errorMessage = 'Email address not found'; + } else if (raw.contains('Rate limit')) { + _errorMessage = 'Too many attempts. Please try again later.'; + } else { + _errorMessage = AppErrorHandler.messageFor(e); } - - _errorMessage = errorMsg; }); } } finally { diff --git a/lib/screens/auth/login_screen.dart b/lib/screens/auth/login_screen.dart index d96616fe..e1345fae 100644 --- a/lib/screens/auth/login_screen.dart +++ b/lib/screens/auth/login_screen.dart @@ -8,6 +8,7 @@ import '../home/home_screen.dart'; import 'signup_screen.dart'; import 'forgot_password_screen.dart'; import 'team_selection_dialog.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -94,14 +95,12 @@ class _LoginScreenState extends State backgroundColor: Colors.red, ), ); - } catch (_) { + } catch (e) { if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Connection error. Please try again.'), - backgroundColor: Colors.red, - ), + AppErrorHandler.showSnackBar( + context, + e, + fallback: AppErrorHandler.networkMessage, ); } finally { if (mounted) { @@ -135,7 +134,7 @@ class _LoginScreenState extends State } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(result['error'] ?? 'Google sign-in failed'), + content: Text(AppErrorHandler.messageFor(result['error'], fallback: 'Google sign-in failed')), backgroundColor: Colors.red, ), ); @@ -143,12 +142,7 @@ class _LoginScreenState extends State } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Error: $e'), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } finally { if (mounted) { diff --git a/lib/screens/auth/set_new_password_screen.dart b/lib/screens/auth/set_new_password_screen.dart index 8a1011e0..04f1c689 100644 --- a/lib/screens/auth/set_new_password_screen.dart +++ b/lib/screens/auth/set_new_password_screen.dart @@ -4,6 +4,7 @@ import '../../widgets/custom_widgets.dart'; import '../../services/navigation_service.dart'; import '../../services/supabase_service.dart'; import 'login_screen.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class SetNewPasswordScreen extends StatefulWidget { final String email; @@ -67,7 +68,7 @@ class _SetNewPasswordScreenState extends State { } } catch (e) { setState(() { - _errorMessage = e.toString(); + _errorMessage = AppErrorHandler.messageFor(e); }); } finally { if (mounted) { diff --git a/lib/screens/auth/signup_screen.dart b/lib/screens/auth/signup_screen.dart index 8c08cc96..3305264d 100644 --- a/lib/screens/auth/signup_screen.dart +++ b/lib/screens/auth/signup_screen.dart @@ -7,6 +7,7 @@ import '../home/home_screen.dart'; import 'login_screen.dart'; import 'verify_otp_screen.dart'; import 'team_selection_dialog.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class SignupScreen extends StatefulWidget { const SignupScreen({super.key}); @@ -84,9 +85,7 @@ class _SignupScreenState extends State } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString()), backgroundColor: Colors.red), - ); + AppErrorHandler.showSnackBar(context, e); } } finally { if (mounted) { @@ -146,9 +145,7 @@ class _SignupScreenState extends State } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString()), backgroundColor: Colors.red), - ); + AppErrorHandler.showSnackBar(context, e); } } finally { if (mounted) { @@ -182,7 +179,7 @@ class _SignupScreenState extends State } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(result['error'] ?? 'Google sign-in failed'), + content: Text(AppErrorHandler.messageFor(result['error'], fallback: 'Google sign-in failed')), backgroundColor: Colors.red, ), ); @@ -190,12 +187,7 @@ class _SignupScreenState extends State } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Error: $e'), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } finally { if (mounted) { diff --git a/lib/screens/auth/team_selection_dialog.dart b/lib/screens/auth/team_selection_dialog.dart index eefed3a6..828de0aa 100644 --- a/lib/screens/auth/team_selection_dialog.dart +++ b/lib/screens/auth/team_selection_dialog.dart @@ -3,6 +3,7 @@ import '../../services/supabase_service.dart'; import '../../services/navigation_service.dart'; import '../../widgets/custom_widgets.dart'; import '../home/home_screen.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class TeamSelectionDialog extends StatefulWidget { final String userEmail; @@ -55,7 +56,7 @@ class _TeamSelectionDialogState extends State { } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(result['error'] ?? 'Failed to join team'), + content: Text(AppErrorHandler.messageFor(result['error'], fallback: 'Failed to join team')), backgroundColor: Colors.red, ), ); @@ -63,12 +64,7 @@ class _TeamSelectionDialogState extends State { } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Error: $e'), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } finally { if (mounted) { @@ -97,7 +93,7 @@ class _TeamSelectionDialogState extends State { } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(result['error'] ?? 'Failed to create team'), + content: Text(AppErrorHandler.messageFor(result['error'], fallback: 'Failed to create team')), backgroundColor: Colors.red, ), ); @@ -105,12 +101,7 @@ class _TeamSelectionDialogState extends State { } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Error: $e'), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } finally { if (mounted) { diff --git a/lib/screens/auth/verify_otp_screen.dart b/lib/screens/auth/verify_otp_screen.dart index 400ee78e..39585ff6 100644 --- a/lib/screens/auth/verify_otp_screen.dart +++ b/lib/screens/auth/verify_otp_screen.dart @@ -6,6 +6,7 @@ import '../../services/navigation_service.dart'; import '../../services/supabase_service.dart'; import '../home/home_screen.dart'; import '../auth/set_new_password_screen.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class VerifyOTPScreen extends StatefulWidget { final String email; @@ -136,14 +137,17 @@ class _VerifyOTPScreenState extends State { } } else { setState(() { - String errorMsg = result['error'] ?? 'Verification failed'; + String errorMsg = AppErrorHandler.messageFor( + result['error'], + fallback: 'Verification failed', + ); - // Make the error message more user-friendly - if (errorMsg.contains('expired') || - errorMsg.contains('otp_expired')) { + // Make OTP failures more specific when we can detect them + final raw = (result['error'] ?? '').toString().toLowerCase(); + if (raw.contains('expired') || raw.contains('otp_expired')) { errorMsg = 'Verification code has expired or invalid. Please request a new code.'; - } else if (errorMsg.contains('invalid')) { + } else if (raw.contains('invalid')) { errorMsg = 'Invalid verification code. Please try again.'; } @@ -153,20 +157,15 @@ class _VerifyOTPScreenState extends State { } } catch (e) { setState(() { - String errorMsg = e.toString(); - - // Make the error message more user-friendly - if (errorMsg.contains('expired') || - errorMsg.contains('otp_expired')) { - errorMsg = + final raw = e.toString().toLowerCase(); + if (raw.contains('expired') || raw.contains('otp_expired')) { + _errorMessage = 'Verification code has expired. Please request a new code.'; - } else if (errorMsg.contains('invalid')) { - errorMsg = 'Invalid verification code. Please try again.'; + } else if (raw.contains('invalid')) { + _errorMessage = 'Invalid verification code. Please try again.'; } else { - errorMsg = 'An error occurred. Please try again.'; + _errorMessage = AppErrorHandler.messageFor(e); } - - _errorMessage = errorMsg; }); _showErrorSnackBar(_errorMessage!); } finally { @@ -210,13 +209,16 @@ class _VerifyOTPScreenState extends State { ); } else { setState(() { - String errorMsg = result['error'] ?? 'Failed to resend code'; + String errorMsg = AppErrorHandler.messageFor( + result['error'], + fallback: 'Failed to resend code', + ); - // Make the error message more user-friendly - if (errorMsg.contains('Rate limit')) { + final raw = (result['error'] ?? '').toString(); + if (raw.contains('Rate limit')) { errorMsg = 'Too many attempts. Please try again later.'; - } else if (errorMsg.contains('not found') || - errorMsg.contains('Invalid email')) { + } else if (raw.contains('not found') || + raw.contains('Invalid email')) { errorMsg = 'Email address not found or invalid.'; } @@ -226,21 +228,17 @@ class _VerifyOTPScreenState extends State { } } catch (e) { setState(() { - String errorMsg = e.toString(); - - // Make the error message more user-friendly - if (errorMsg.contains('Rate limit')) { - errorMsg = 'Too many attempts. Please try again later.'; - } else if (errorMsg.contains('not found') || - errorMsg.contains('Invalid email')) { - errorMsg = 'Email address not found or invalid.'; - } else if (errorMsg.contains('Assertion failed')) { - errorMsg = 'Unable to resend code. Please go back and try again.'; + final raw = e.toString(); + if (raw.contains('Rate limit')) { + _errorMessage = 'Too many attempts. Please try again later.'; + } else if (raw.contains('not found') || raw.contains('Invalid email')) { + _errorMessage = 'Email address not found or invalid.'; + } else if (raw.contains('Assertion failed')) { + _errorMessage = + 'Unable to resend code. Please go back and try again.'; } else { - errorMsg = 'An error occurred. Please try again.'; + _errorMessage = AppErrorHandler.messageFor(e); } - - _errorMessage = errorMsg; }); _showErrorSnackBar(_errorMessage!); } finally { diff --git a/lib/screens/chat/chat_screen.dart b/lib/screens/chat/chat_screen.dart index 614739b8..4b97df08 100644 --- a/lib/screens/chat/chat_screen.dart +++ b/lib/screens/chat/chat_screen.dart @@ -7,6 +7,7 @@ import '../tasks/task_detail_screen.dart'; import '../tickets/ticket_detail_screen.dart'; import '../meetings/meeting_detail_screen.dart'; import 'package:speech_to_text/speech_to_text.dart' as stt; +import 'package:ell_ena/utils/app_error_handler.dart'; class ChatScreen extends StatefulWidget { final Map? arguments; @@ -324,7 +325,7 @@ class _ChatScreenState extends State with TickerProviderStateMixin { setState(() { _messages.add( ChatMessage( - text: "Sorry, I encountered an error. Please try again later.", + text: AppErrorHandler.messageFor(e), isUser: false, timestamp: DateTime.now(), ), @@ -516,7 +517,7 @@ class _ChatScreenState extends State with TickerProviderStateMixin { return result; } catch (e) { debugPrint('Error creating task: $e'); - return {'success': false, 'error': e.toString()}; + return {'success': false, 'error': AppErrorHandler.messageFor(e)}; } } @@ -573,7 +574,7 @@ class _ChatScreenState extends State with TickerProviderStateMixin { return result; } catch (e) { debugPrint('Error creating ticket: $e'); - return {'success': false, 'error': e.toString()}; + return {'success': false, 'error': AppErrorHandler.messageFor(e)}; } } @@ -610,7 +611,7 @@ class _ChatScreenState extends State with TickerProviderStateMixin { return result; } catch (e) { debugPrint('Error creating meeting: $e'); - return {'success': false, 'error': e.toString()}; + return {'success': false, 'error': AppErrorHandler.messageFor(e)}; } } @@ -720,7 +721,7 @@ class _ChatScreenState extends State with TickerProviderStateMixin { }; } catch (e) { debugPrint('Error querying tasks: $e'); - return {'success': false, 'error': e.toString()}; + return {'success': false, 'error': AppErrorHandler.messageFor(e)}; } } @@ -833,7 +834,7 @@ class _ChatScreenState extends State with TickerProviderStateMixin { }; } catch (e) { debugPrint('Error querying tickets: $e'); - return {'success': false, 'error': e.toString()}; + return {'success': false, 'error': AppErrorHandler.messageFor(e)}; } } @@ -1027,7 +1028,7 @@ class _ChatScreenState extends State with TickerProviderStateMixin { return result; } catch (e) { debugPrint('Error modifying item: $e'); - return {'success': false, 'error': e.toString()}; + return {'success': false, 'error': AppErrorHandler.messageFor(e)}; } } @@ -1117,12 +1118,7 @@ class _ChatScreenState extends State with TickerProviderStateMixin { } } catch (e) { debugPrint('Error navigating to item: $e'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Navigation error: $e'), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } diff --git a/lib/screens/home/dashboard_screen.dart b/lib/screens/home/dashboard_screen.dart index 79a319f8..b4c9bbaa 100644 --- a/lib/screens/home/dashboard_screen.dart +++ b/lib/screens/home/dashboard_screen.dart @@ -6,6 +6,7 @@ import '../../services/supabase_service.dart'; import '../tasks/task_detail_screen.dart'; import '../tickets/ticket_detail_screen.dart'; import '../meetings/meeting_detail_screen.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @@ -150,7 +151,7 @@ class _DashboardScreenState extends State if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error switching team: ${result['error']}'), + content: Text(AppErrorHandler.messageFor(result['error'])), backgroundColor: Colors.red, ), ); @@ -165,7 +166,7 @@ class _DashboardScreenState extends State ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error switching team: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); diff --git a/lib/screens/meetings/create_meeting_screen.dart b/lib/screens/meetings/create_meeting_screen.dart index 4863a02c..9d952616 100644 --- a/lib/screens/meetings/create_meeting_screen.dart +++ b/lib/screens/meetings/create_meeting_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../../services/supabase_service.dart'; import '../../services/google_meet_service.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class CreateMeetingScreen extends StatefulWidget { CreateMeetingScreen({super.key,}); @@ -207,7 +208,7 @@ class _CreateMeetingScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to create meeting: ${result['error']}'), + content: Text(AppErrorHandler.messageFor(result['error'])), backgroundColor: Colors.red, ), ); @@ -222,7 +223,7 @@ class _CreateMeetingScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error creating meeting: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); diff --git a/lib/screens/meetings/meeting_detail_screen.dart b/lib/screens/meetings/meeting_detail_screen.dart index c5d6ee0e..d70167fc 100644 --- a/lib/screens/meetings/meeting_detail_screen.dart +++ b/lib/screens/meetings/meeting_detail_screen.dart @@ -4,6 +4,7 @@ import 'package:intl/intl.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../services/supabase_service.dart'; import '../../widgets/custom_widgets.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class MeetingDetailScreen extends StatefulWidget { final String meetingId; @@ -113,7 +114,7 @@ class _MeetingDetailScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error loading meeting details: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); @@ -131,7 +132,7 @@ class _MeetingDetailScreenState extends State { } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error deleting meeting: ${result['error']}'), + content: Text(AppErrorHandler.messageFor(result['error'])), backgroundColor: Colors.red, ), ); @@ -142,7 +143,7 @@ class _MeetingDetailScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error deleting meeting: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); @@ -230,7 +231,7 @@ class _MeetingDetailScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error updating meeting: ${result['error']}'), + content: Text(AppErrorHandler.messageFor(result['error'])), backgroundColor: Colors.red, ), ); @@ -245,7 +246,7 @@ class _MeetingDetailScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error updating meeting: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); @@ -293,14 +294,14 @@ class _MeetingDetailScreenState extends State { } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to create ticket: ${result['error']}'), + content: Text(AppErrorHandler.messageFor(result['error'])), backgroundColor: Colors.red), ); } } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red), + SnackBar(content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red), ); } } @@ -334,14 +335,14 @@ class _MeetingDetailScreenState extends State { } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to create task: ${result['error']}'), + content: Text(AppErrorHandler.messageFor(result['error'])), backgroundColor: Colors.red), ); } } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red), + SnackBar(content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red), ); } } diff --git a/lib/screens/meetings/meeting_insights_screen.dart b/lib/screens/meetings/meeting_insights_screen.dart index c28f1709..da3bc611 100644 --- a/lib/screens/meetings/meeting_insights_screen.dart +++ b/lib/screens/meetings/meeting_insights_screen.dart @@ -5,6 +5,7 @@ import 'package:pdf/pdf.dart'; import 'dart:io'; import 'package:path_provider/path_provider.dart'; import 'package:flutter/foundation.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class MeetingInsightsScreen extends StatefulWidget { final String meetingId; @@ -56,7 +57,7 @@ class _MeetingInsightsScreenState extends State setState(() => _isLoading = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to load meeting: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red), ); } @@ -290,7 +291,7 @@ class _MeetingInsightsScreenState extends State } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('PDF error: $e'), backgroundColor: Colors.red), + SnackBar(content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red), ); } } diff --git a/lib/screens/meetings/meeting_screen.dart b/lib/screens/meetings/meeting_screen.dart index d0a691ff..0baca8ee 100644 --- a/lib/screens/meetings/meeting_screen.dart +++ b/lib/screens/meetings/meeting_screen.dart @@ -6,6 +6,7 @@ import '../../widgets/custom_widgets.dart'; import 'create_meeting_screen.dart'; import 'meeting_detail_screen.dart'; import 'meeting_insights_screen.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class MeetingScreen extends StatefulWidget { static final GlobalKey<_MeetingScreenState> globalKey = @@ -76,7 +77,7 @@ class _MeetingScreenState extends State { if (mounted && !result['success']) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error deleting meeting: ${result['error']}'), + content: Text(AppErrorHandler.messageFor(result['error'])), backgroundColor: Colors.red, ), ); @@ -88,7 +89,7 @@ class _MeetingScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error deleting meeting: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); @@ -118,7 +119,7 @@ class _MeetingScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error launching URL: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); diff --git a/lib/screens/profile/edit_profile_screen.dart b/lib/screens/profile/edit_profile_screen.dart index acd49582..a46c29d9 100644 --- a/lib/screens/profile/edit_profile_screen.dart +++ b/lib/screens/profile/edit_profile_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../services/supabase_service.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class EditProfileScreen extends StatefulWidget { final Map userProfile; @@ -104,7 +105,7 @@ class _EditProfileScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error updating profile: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); diff --git a/lib/screens/profile/profile_screen.dart b/lib/screens/profile/profile_screen.dart index 00ca35d6..3f7c2e5b 100644 --- a/lib/screens/profile/profile_screen.dart +++ b/lib/screens/profile/profile_screen.dart @@ -7,6 +7,7 @@ import '../../theme/theme_controller.dart'; import '../auth/login_screen.dart'; import 'team_members_screen.dart'; import 'edit_profile_screen.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class ProfileScreen extends StatefulWidget { const ProfileScreen({super.key}); @@ -63,7 +64,7 @@ class _ProfileScreenState extends State { }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to load profile: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); @@ -131,7 +132,7 @@ class _ProfileScreenState extends State { Navigator.pop(context); // close loader ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error logging out: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); @@ -396,7 +397,7 @@ class _ProfileScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error switching team: ${result['error']}'), + content: Text(AppErrorHandler.messageFor(result['error'])), backgroundColor: Colors.red, ), ); @@ -411,7 +412,7 @@ class _ProfileScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error switching team: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); diff --git a/lib/screens/profile/team_members_screen.dart b/lib/screens/profile/team_members_screen.dart index 401af07d..c2620641 100644 --- a/lib/screens/profile/team_members_screen.dart +++ b/lib/screens/profile/team_members_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../services/supabase_service.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class TeamMembersScreen extends StatefulWidget { final String teamId; @@ -48,7 +49,7 @@ class _TeamMembersScreenState extends State { }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error loading team members: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); diff --git a/lib/screens/tasks/create_task_screen.dart b/lib/screens/tasks/create_task_screen.dart index 66e52de9..a07263b7 100644 --- a/lib/screens/tasks/create_task_screen.dart +++ b/lib/screens/tasks/create_task_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../services/supabase_service.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class CreateTaskScreen extends StatefulWidget { const CreateTaskScreen({super.key}); @@ -106,7 +107,7 @@ class _CreateTaskScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error creating task: ${result['error']}'), + content: Text(AppErrorHandler.messageFor(result['error'])), backgroundColor: Colors.red, ), ); @@ -120,7 +121,7 @@ class _CreateTaskScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error creating task: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index 3d50ab4c..86dbad24 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../services/supabase_service.dart'; import '../../widgets/custom_widgets.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class TaskDetailScreen extends StatefulWidget { final String taskId; @@ -103,7 +104,7 @@ class _TaskDetailScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error updating task status: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); @@ -138,7 +139,7 @@ class _TaskDetailScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error updating task approval: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); @@ -194,7 +195,7 @@ class _TaskDetailScreenState extends State { } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error deleting task: ${result['error']}'), + content: Text(AppErrorHandler.messageFor(result['error'])), backgroundColor: Colors.red, ), ); @@ -205,7 +206,7 @@ class _TaskDetailScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error deleting task: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); @@ -228,7 +229,7 @@ class _TaskDetailScreenState extends State { } else if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error adding comment: ${result['error']}'), + content: Text(AppErrorHandler.messageFor(result['error'])), backgroundColor: Colors.red, ), ); @@ -238,7 +239,7 @@ class _TaskDetailScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error adding comment: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); diff --git a/lib/screens/tasks/task_screen.dart b/lib/screens/tasks/task_screen.dart index f0cdbdda..4db40953 100644 --- a/lib/screens/tasks/task_screen.dart +++ b/lib/screens/tasks/task_screen.dart @@ -3,6 +3,7 @@ import '../../services/supabase_service.dart'; import '../../widgets/custom_widgets.dart'; import 'task_detail_screen.dart'; import 'create_task_screen.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class TaskScreen extends StatefulWidget { // Create a static key that can be used to access the state @@ -107,7 +108,7 @@ class _TaskScreenState extends State { debugPrint('Error updating task status: $e'); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error updating task status: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); @@ -127,7 +128,7 @@ class _TaskScreenState extends State { debugPrint('Error updating task approval: $e'); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error updating task approval: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); @@ -748,8 +749,13 @@ class _TaskCard extends StatelessWidget { ScaffoldMessenger.of(context) .showSnackBar( SnackBar( - content: Text(result['error'] ?? - 'Failed to delete task'), + content: Text( + AppErrorHandler.messageFor( + result['error'], + fallback: + 'Failed to delete task', + ), + ), backgroundColor: Colors.red, ), ); @@ -757,13 +763,7 @@ class _TaskCard extends StatelessWidget { } } catch (e) { if (context.mounted) { - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: Text('Error: $e'), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } diff --git a/lib/screens/tickets/create_ticket_screen.dart b/lib/screens/tickets/create_ticket_screen.dart index 2455b465..4ae9be26 100644 --- a/lib/screens/tickets/create_ticket_screen.dart +++ b/lib/screens/tickets/create_ticket_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../services/supabase_service.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class CreateTicketScreen extends StatefulWidget { const CreateTicketScreen({super.key}); @@ -96,7 +97,7 @@ class _CreateTicketScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to create ticket: ${result['error']}'), + content: Text(AppErrorHandler.messageFor(result['error'])), backgroundColor: Colors.red, ), ); @@ -111,7 +112,7 @@ class _CreateTicketScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error creating ticket: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); diff --git a/lib/screens/tickets/ticket_detail_screen.dart b/lib/screens/tickets/ticket_detail_screen.dart index e6c07548..09ed712e 100644 --- a/lib/screens/tickets/ticket_detail_screen.dart +++ b/lib/screens/tickets/ticket_detail_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../services/supabase_service.dart'; import '../../widgets/custom_widgets.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class TicketDetailScreen extends StatefulWidget { final String ticketId; @@ -79,7 +80,7 @@ class _TicketDetailScreenState extends State { }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error loading ticket details: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); @@ -137,7 +138,7 @@ class _TicketDetailScreenState extends State { } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error deleting ticket: ${result['error']}'), + content: Text(AppErrorHandler.messageFor(result['error'])), backgroundColor: Colors.red, ), ); @@ -148,7 +149,7 @@ class _TicketDetailScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error deleting ticket: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); @@ -174,7 +175,7 @@ class _TicketDetailScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to add comment: ${result['error']}'), + content: Text(AppErrorHandler.messageFor(result['error'])), backgroundColor: Colors.red, ), ); @@ -185,7 +186,7 @@ class _TicketDetailScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error adding comment: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); @@ -231,7 +232,7 @@ class _TicketDetailScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error updating ticket status: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); @@ -277,7 +278,7 @@ class _TicketDetailScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error updating ticket priority: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); @@ -308,7 +309,7 @@ class _TicketDetailScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Failed to assign ticket: ${result['error']}'), + content: Text(AppErrorHandler.messageFor(result['error'])), backgroundColor: Colors.red, ), ); @@ -319,7 +320,7 @@ class _TicketDetailScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error assigning ticket: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); diff --git a/lib/screens/tickets/ticket_screen.dart b/lib/screens/tickets/ticket_screen.dart index 33cb38b9..2f2573ce 100644 --- a/lib/screens/tickets/ticket_screen.dart +++ b/lib/screens/tickets/ticket_screen.dart @@ -3,6 +3,7 @@ import '../../services/supabase_service.dart'; import '../../widgets/custom_widgets.dart'; import 'ticket_detail_screen.dart'; import 'create_ticket_screen.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class TicketScreen extends StatefulWidget { static final GlobalKey<_TicketScreenState> globalKey = @@ -93,7 +94,7 @@ class _TicketScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error updating ticket status: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); @@ -116,7 +117,7 @@ class _TicketScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Error updating ticket approval: $e'), + content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red, ), ); @@ -763,8 +764,13 @@ class _TicketCard extends StatelessWidget { ScaffoldMessenger.of(context) .showSnackBar( SnackBar( - content: Text(result['error'] ?? - 'Failed to delete ticket'), + content: Text( + AppErrorHandler.messageFor( + result['error'], + fallback: + 'Failed to delete ticket', + ), + ), backgroundColor: Colors.red, ), ); @@ -772,13 +778,7 @@ class _TicketCard extends StatelessWidget { } } catch (e) { if (context.mounted) { - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: Text('Error: $e'), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } diff --git a/lib/services/ai_service.dart b/lib/services/ai_service.dart index 151cfc33..ac4ad8e5 100644 --- a/lib/services/ai_service.dart +++ b/lib/services/ai_service.dart @@ -5,6 +5,7 @@ import 'package:http/http.dart' as http; import 'package:intl/intl.dart'; import 'package:ell_ena/services/supabase_service.dart'; import 'package:ell_ena/services/meeting_formatter.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class AIService { static final AIService _instance = AIService._internal(); @@ -427,14 +428,18 @@ class AIService { debugPrint('Error from Gemini API: ${response.statusCode} ${response.body}'); return { 'type': 'error', - 'content': 'Sorry, I encountered an error while processing your request.', + 'content': AppErrorHandler.messageFor( + 'Gemini API error', + statusCode: response.statusCode, + fallback: AppErrorHandler.serverMessage, + ), }; } } catch (e) { debugPrint('Error generating chat response: $e'); return { 'type': 'error', - 'content': 'Sorry, I encountered an error while processing your request.', + 'content': AppErrorHandler.messageFor(e), }; } } diff --git a/lib/services/supabase_service.dart b/lib/services/supabase_service.dart index d361b341..84c01a2f 100644 --- a/lib/services/supabase_service.dart +++ b/lib/services/supabase_service.dart @@ -5,6 +5,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'dart:async'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:ell_ena/utils/app_error_handler.dart'; class SupabaseService { static final SupabaseService _instance = SupabaseService._internal(); @@ -156,7 +157,7 @@ class SupabaseService { debugPrint('Error getting user teams: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), 'teams': [], }; } @@ -228,7 +229,7 @@ class SupabaseService { debugPrint('Error switching team: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } @@ -482,7 +483,7 @@ class SupabaseService { debugPrint('Error joining team: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } @@ -564,7 +565,7 @@ class SupabaseService { authSubscription?.cancel(); return { 'success': false, - 'error': 'Authentication timed out', + 'error': AppErrorHandler.timeoutMessage, }; }, ); @@ -572,7 +573,7 @@ class SupabaseService { debugPrint('Error signing in with Google: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } @@ -695,7 +696,7 @@ class SupabaseService { debugPrint('Error joining team with Google: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } @@ -1000,7 +1001,7 @@ class SupabaseService { debugPrint('Error creating team after verification: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } else if (type == 'signup_join' && userData.isNotEmpty) { @@ -1037,7 +1038,7 @@ class SupabaseService { debugPrint('Error joining team after verification: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } else if (type == 'reset_password') { @@ -1055,7 +1056,7 @@ class SupabaseService { debugPrint('Error verifying OTP: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } @@ -1101,7 +1102,7 @@ class SupabaseService { debugPrint('Error resending verification email: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } @@ -1340,7 +1341,7 @@ class SupabaseService { debugPrint('Error creating task: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } @@ -1376,7 +1377,7 @@ class SupabaseService { debugPrint('Error updating task status: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } @@ -1423,7 +1424,7 @@ class SupabaseService { debugPrint('Error updating task approval: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } @@ -1573,7 +1574,7 @@ class SupabaseService { debugPrint('Error adding task comment: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } @@ -1625,7 +1626,7 @@ class SupabaseService { debugPrint('Error deleting task: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } @@ -1830,7 +1831,7 @@ class SupabaseService { debugPrint('Error creating ticket: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } @@ -1871,7 +1872,7 @@ class SupabaseService { debugPrint('Error updating ticket status: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } @@ -1912,7 +1913,7 @@ class SupabaseService { debugPrint('Error updating ticket priority: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } @@ -1962,7 +1963,7 @@ class SupabaseService { debugPrint('Error updating ticket approval: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } @@ -2084,7 +2085,7 @@ class SupabaseService { debugPrint('Error adding ticket comment: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } @@ -2139,7 +2140,7 @@ class SupabaseService { debugPrint('Error deleting ticket: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } @@ -2180,7 +2181,7 @@ class SupabaseService { debugPrint('Error assigning ticket: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } @@ -2310,7 +2311,7 @@ class SupabaseService { debugPrint('Error creating meeting: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } @@ -2423,7 +2424,7 @@ class SupabaseService { debugPrint('Error updating meeting: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } @@ -2459,7 +2460,7 @@ class SupabaseService { debugPrint('Error deleting meeting: $e'); return { 'success': false, - 'error': e.toString(), + 'error': AppErrorHandler.messageFor(e), }; } } diff --git a/lib/utils/app_error_handler.dart b/lib/utils/app_error_handler.dart new file mode 100644 index 00000000..27619896 --- /dev/null +++ b/lib/utils/app_error_handler.dart @@ -0,0 +1,348 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:supabase_flutter/supabase_flutter.dart'; + +/// Categories of failures we surface to users with consistent copy. +enum AppErrorKind { + network, + timeout, + authentication, + server, + unknown, +} + +/// User-facing error details derived from an exception or status code. +class AppErrorInfo { + final AppErrorKind kind; + final String message; + + const AppErrorInfo({ + required this.kind, + required this.message, + }); +} + +/// Maps network / auth / API failures to clear, consistent messages. +/// +/// Prefer [messageFor] when returning `error` fields from services, and +/// [showSnackBar] when presenting failures in the UI. +class AppErrorHandler { + AppErrorHandler._(); + + static const String networkMessage = + 'No internet connection. Please check your network and try again.'; + static const String timeoutMessage = + 'The request is taking longer than expected. Please try again.'; + static const String authenticationMessage = + 'Your session has expired. Please sign in again.'; + static const String serverMessage = + 'Something went wrong on our end. Please try again later.'; + static const String unknownMessage = + 'Something went wrong. Please try again.'; + + /// Classify [error] (and optional HTTP [statusCode]) into a user message. + static AppErrorInfo classify(Object? error, {int? statusCode}) { + if (statusCode != null) { + final fromStatus = _fromStatusCode(statusCode); + if (fromStatus != null) return fromStatus; + } + + if (error == null) { + return const AppErrorInfo( + kind: AppErrorKind.unknown, + message: unknownMessage, + ); + } + + if (error is TimeoutException) { + return const AppErrorInfo( + kind: AppErrorKind.timeout, + message: timeoutMessage, + ); + } + + if (error is http.ClientException) { + return const AppErrorInfo( + kind: AppErrorKind.network, + message: networkMessage, + ); + } + + if (error is AuthException) { + return _fromAuthException(error); + } + + if (error is PostgrestException) { + return _fromPostgrestException(error); + } + + return _fromMessage(error.toString()); + } + + /// Returns a user-friendly message for [error]. + /// + /// Short, already-friendly domain messages (e.g. "Team not found") are kept. + /// Technical exception dumps are replaced with the mapped copy above. + static String messageFor( + Object? error, { + int? statusCode, + String? fallback, + }) { + if (error is String) { + final trimmed = error.trim(); + if (trimmed.isEmpty) { + return fallback ?? unknownMessage; + } + + final classified = _fromMessage(trimmed); + if (classified.kind != AppErrorKind.unknown) { + return classified.message; + } + + if (_looksUserFacing(trimmed)) { + return trimmed; + } + + return fallback ?? unknownMessage; + } + + final info = classify(error, statusCode: statusCode); + if (info.kind == AppErrorKind.unknown && fallback != null) { + return fallback; + } + return info.message; + } + + /// Shows a consistent floating error [SnackBar] for [error]. + static void showSnackBar( + BuildContext context, + Object? error, { + int? statusCode, + String? fallback, + Color backgroundColor = Colors.red, + }) { + if (!context.mounted) return; + + final message = messageFor( + error, + statusCode: statusCode, + fallback: fallback, + ); + + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: backgroundColor, + behavior: SnackBarBehavior.floating, + ), + ); + } + + static AppErrorInfo? _fromStatusCode(int statusCode) { + if (statusCode == 401 || statusCode == 403) { + return const AppErrorInfo( + kind: AppErrorKind.authentication, + message: authenticationMessage, + ); + } + if (statusCode == 408 || statusCode == 504) { + return const AppErrorInfo( + kind: AppErrorKind.timeout, + message: timeoutMessage, + ); + } + if (statusCode >= 500) { + return const AppErrorInfo( + kind: AppErrorKind.server, + message: serverMessage, + ); + } + if (statusCode == 0) { + return const AppErrorInfo( + kind: AppErrorKind.network, + message: networkMessage, + ); + } + return null; + } + + static AppErrorInfo _fromAuthException(AuthException error) { + final lower = error.message.toLowerCase(); + + if (_isNetworkMessage(lower) || _isTimeoutMessage(lower)) { + return _fromMessage(error.message); + } + + if (_isAuthSessionMessage(lower) || + lower.contains('jwt') || + lower.contains('refresh token') || + lower.contains('not authenticated')) { + return const AppErrorInfo( + kind: AppErrorKind.authentication, + message: authenticationMessage, + ); + } + + if (_looksUserFacing(error.message)) { + return AppErrorInfo( + kind: AppErrorKind.authentication, + message: error.message, + ); + } + + return const AppErrorInfo( + kind: AppErrorKind.authentication, + message: authenticationMessage, + ); + } + + static AppErrorInfo _fromPostgrestException(PostgrestException error) { + final code = int.tryParse(error.code ?? ''); + if (code != null) { + final fromStatus = _fromStatusCode(code); + if (fromStatus != null) return fromStatus; + } + + final lower = '${error.message} ${error.code ?? ''}'.toLowerCase(); + if (_isNetworkMessage(lower)) { + return const AppErrorInfo( + kind: AppErrorKind.network, + message: networkMessage, + ); + } + if (_isTimeoutMessage(lower)) { + return const AppErrorInfo( + kind: AppErrorKind.timeout, + message: timeoutMessage, + ); + } + if (_isAuthSessionMessage(lower)) { + return const AppErrorInfo( + kind: AppErrorKind.authentication, + message: authenticationMessage, + ); + } + + if (_looksUserFacing(error.message)) { + return AppErrorInfo( + kind: AppErrorKind.server, + message: error.message, + ); + } + + return const AppErrorInfo( + kind: AppErrorKind.server, + message: serverMessage, + ); + } + + static AppErrorInfo _fromMessage(String raw) { + final cleaned = raw + .replaceFirst(RegExp(r'^(Exception|Error):\s*', caseSensitive: false), '') + .trim(); + final lower = cleaned.toLowerCase(); + + if (_isNetworkMessage(lower)) { + return const AppErrorInfo( + kind: AppErrorKind.network, + message: networkMessage, + ); + } + if (_isTimeoutMessage(lower)) { + return const AppErrorInfo( + kind: AppErrorKind.timeout, + message: timeoutMessage, + ); + } + if (_isAuthSessionMessage(lower)) { + return const AppErrorInfo( + kind: AppErrorKind.authentication, + message: authenticationMessage, + ); + } + if (_isServerMessage(lower)) { + return const AppErrorInfo( + kind: AppErrorKind.server, + message: serverMessage, + ); + } + + if (_looksUserFacing(cleaned)) { + return AppErrorInfo( + kind: AppErrorKind.unknown, + message: cleaned, + ); + } + + return const AppErrorInfo( + kind: AppErrorKind.unknown, + message: unknownMessage, + ); + } + + static bool _isNetworkMessage(String lower) { + return lower.contains('socketexception') || + lower.contains('failed host lookup') || + lower.contains('network is unreachable') || + lower.contains('network error') || + lower.contains('connection refused') || + lower.contains('connection reset') || + lower.contains('connection abort') || + lower.contains('clientexception') || + lower.contains('no internet') || + lower.contains('offline') || + lower.contains('failed to connect') || + lower.contains('xmlhttprequest error') || + lower.contains('software caused connection abort'); + } + + static bool _isTimeoutMessage(String lower) { + return lower.contains('timeout') || + lower.contains('timed out') || + lower.contains('timeoutexception') || + lower.contains('taking longer'); + } + + static bool _isAuthSessionMessage(String lower) { + return lower.contains('session has expired') || + lower.contains('session expired') || + lower.contains('jwt expired') || + lower.contains('invalid jwt') || + lower.contains('invalid claim') || + lower.contains('refresh_token') || + lower.contains('not authenticated') || + lower.contains('unauthorized') || + lower.contains('user not authenticated'); + } + + static bool _isServerMessage(String lower) { + return lower.contains('internal server error') || + lower.contains('bad gateway') || + lower.contains('service unavailable') || + lower.contains('500') || + lower.contains('502') || + lower.contains('503'); + } + + static bool _looksUserFacing(String message) { + final trimmed = message.trim(); + if (trimmed.isEmpty || trimmed.length > 160) return false; + + final lower = trimmed.toLowerCase(); + if (lower.contains('exception') || + lower.contains('socket') || + lower.contains('stacktrace') || + lower.contains('http://') || + lower.contains('https://') || + lower.startsWith('error:') || + RegExp(r'\n').hasMatch(trimmed)) { + return false; + } + + return true; + } +} diff --git a/test/app_error_handler_test.dart b/test/app_error_handler_test.dart new file mode 100644 index 00000000..76da6bd6 --- /dev/null +++ b/test/app_error_handler_test.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:ell_ena/utils/app_error_handler.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:supabase_flutter/supabase_flutter.dart'; + +void main() { + group('AppErrorHandler', () { + test('maps network failures', () { + final info = AppErrorHandler.classify( + http.ClientException('Connection refused'), + ); + expect(info.kind, AppErrorKind.network); + expect(info.message, AppErrorHandler.networkMessage); + + expect( + AppErrorHandler.messageFor( + Exception('SocketException: Failed host lookup'), + ), + AppErrorHandler.networkMessage, + ); + }); + + test('maps timeouts', () { + final info = AppErrorHandler.classify( + TimeoutException('Timed out'), + ); + expect(info.kind, AppErrorKind.timeout); + expect(info.message, AppErrorHandler.timeoutMessage); + + expect( + AppErrorHandler.messageFor('Authentication timed out'), + AppErrorHandler.timeoutMessage, + ); + }); + + test('maps authentication / session failures', () { + final info = AppErrorHandler.classify( + AuthException('JWT expired'), + ); + expect(info.kind, AppErrorKind.authentication); + expect(info.message, AppErrorHandler.authenticationMessage); + + expect( + AppErrorHandler.messageFor(null, statusCode: 401), + AppErrorHandler.authenticationMessage, + ); + }); + + test('maps server / HTTP 5xx failures', () { + expect( + AppErrorHandler.messageFor('upstream failed', statusCode: 503), + AppErrorHandler.serverMessage, + ); + expect( + AppErrorHandler.messageFor('Internal Server Error'), + AppErrorHandler.serverMessage, + ); + }); + + test('preserves short domain messages', () { + expect( + AppErrorHandler.messageFor('Team not found'), + 'Team not found', + ); + expect( + AppErrorHandler.messageFor('User not authenticated'), + AppErrorHandler.authenticationMessage, + ); + }); + + test('uses fallback for unknown technical dumps', () { + expect( + AppErrorHandler.messageFor( + 'Exception: Instance of \'_HttpClientConnection\'\n#0 ...', + fallback: 'Could not complete the request', + ), + 'Could not complete the request', + ); + }); + }); +} From 1086fa7e7bf76f65a3a6de295dd0ac40979d9e6c Mon Sep 17 00:00:00 2001 From: AJ-ing Date: Wed, 29 Jul 2026 11:48:43 +1000 Subject: [PATCH 2/4] Fix statusCode mapping when error payload is a string Honor HTTP status codes in AppErrorHandler.messageFor before treating string errors as domain messages so 5xx responses surface server copy. Co-authored-by: Cursor --- lib/utils/app_error_handler.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/utils/app_error_handler.dart b/lib/utils/app_error_handler.dart index 27619896..81a21fd3 100644 --- a/lib/utils/app_error_handler.dart +++ b/lib/utils/app_error_handler.dart @@ -90,6 +90,13 @@ class AppErrorHandler { int? statusCode, String? fallback, }) { + if (statusCode != null) { + final fromStatus = _fromStatusCode(statusCode); + if (fromStatus != null) { + return fromStatus.message; + } + } + if (error is String) { final trimmed = error.trim(); if (trimmed.isEmpty) { From 83e2d984280df714d91c846cea30c9a6ec511a6a Mon Sep 17 00:00:00 2001 From: AJ-ing Date: Thu, 30 Jul 2026 00:10:25 +1000 Subject: [PATCH 3/4] Address CodeRabbit feedback on error-message UX Use login-specific fallbacks, route mapped errors through AppErrorHandler.showSnackBar, avoid leaking PostgREST details, add Gemini HTTP timeouts, and keep 401/403 as API-key errors not session expiry. Co-authored-by: Cursor --- lib/screens/auth/login_screen.dart | 11 ++-- lib/screens/auth/signup_screen.dart | 9 ++- lib/screens/auth/team_selection_dialog.dart | 18 +++--- lib/screens/home/dashboard_screen.dart | 14 +---- .../meetings/create_meeting_screen.dart | 14 +---- .../meetings/meeting_detail_screen.dart | 55 +++------------- .../meetings/meeting_insights_screen.dart | 10 +-- lib/screens/meetings/meeting_screen.dart | 21 +------ lib/screens/profile/edit_profile_screen.dart | 7 +-- lib/screens/profile/profile_screen.dart | 28 ++------- lib/screens/profile/team_members_screen.dart | 7 +-- lib/screens/tasks/create_task_screen.dart | 14 +---- lib/screens/tasks/task_detail_screen.dart | 42 ++----------- lib/screens/tasks/task_screen.dart | 14 +---- lib/screens/tickets/create_ticket_screen.dart | 14 +---- lib/screens/tickets/ticket_detail_screen.dart | 63 +++---------------- lib/screens/tickets/ticket_screen.dart | 14 +---- lib/services/ai_service.dart | 22 ++++--- lib/utils/app_error_handler.dart | 8 +-- test/app_error_handler_test.dart | 12 ++++ 20 files changed, 92 insertions(+), 305 deletions(-) diff --git a/lib/screens/auth/login_screen.dart b/lib/screens/auth/login_screen.dart index e1345fae..c47d9f09 100644 --- a/lib/screens/auth/login_screen.dart +++ b/lib/screens/auth/login_screen.dart @@ -100,7 +100,7 @@ class _LoginScreenState extends State AppErrorHandler.showSnackBar( context, e, - fallback: AppErrorHandler.networkMessage, + fallback: 'Unable to sign in. Please try again.', ); } finally { if (mounted) { @@ -132,11 +132,10 @@ class _LoginScreenState extends State NavigationService().navigateToReplacement(const HomeScreen()); } } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(result['error'], fallback: 'Google sign-in failed')), - backgroundColor: Colors.red, - ), + AppErrorHandler.showSnackBar( + context, + result['error'], + fallback: 'Google sign-in failed', ); } } diff --git a/lib/screens/auth/signup_screen.dart b/lib/screens/auth/signup_screen.dart index 3305264d..df590c4c 100644 --- a/lib/screens/auth/signup_screen.dart +++ b/lib/screens/auth/signup_screen.dart @@ -177,11 +177,10 @@ class _SignupScreenState extends State NavigationService().navigateToReplacement(const HomeScreen()); } } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(result['error'], fallback: 'Google sign-in failed')), - backgroundColor: Colors.red, - ), + AppErrorHandler.showSnackBar( + context, + result['error'], + fallback: 'Google sign-in failed', ); } } diff --git a/lib/screens/auth/team_selection_dialog.dart b/lib/screens/auth/team_selection_dialog.dart index 828de0aa..04ff3a8e 100644 --- a/lib/screens/auth/team_selection_dialog.dart +++ b/lib/screens/auth/team_selection_dialog.dart @@ -54,11 +54,10 @@ class _TeamSelectionDialogState extends State { Navigator.of(context).pop(); NavigationService().navigateToReplacement(const HomeScreen()); } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(result['error'], fallback: 'Failed to join team')), - backgroundColor: Colors.red, - ), + AppErrorHandler.showSnackBar( + context, + result['error'], + fallback: 'Failed to join team', ); } } @@ -91,11 +90,10 @@ class _TeamSelectionDialogState extends State { // Show team ID dialog _showTeamIdDialog(result['teamId']); } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(result['error'], fallback: 'Failed to create team')), - backgroundColor: Colors.red, - ), + AppErrorHandler.showSnackBar( + context, + result['error'], + fallback: 'Failed to create team', ); } } diff --git a/lib/screens/home/dashboard_screen.dart b/lib/screens/home/dashboard_screen.dart index b4c9bbaa..39c7390c 100644 --- a/lib/screens/home/dashboard_screen.dart +++ b/lib/screens/home/dashboard_screen.dart @@ -149,12 +149,7 @@ class _DashboardScreenState extends State await _loadData(); } else { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(result['error'])), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, result['error']); } } } catch (e) { @@ -164,12 +159,7 @@ class _DashboardScreenState extends State _isLoading = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } diff --git a/lib/screens/meetings/create_meeting_screen.dart b/lib/screens/meetings/create_meeting_screen.dart index 9d952616..258439ce 100644 --- a/lib/screens/meetings/create_meeting_screen.dart +++ b/lib/screens/meetings/create_meeting_screen.dart @@ -206,12 +206,7 @@ class _CreateMeetingScreenState extends State { _isLoading = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(result['error'])), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, result['error']); } } } catch (e) { @@ -221,12 +216,7 @@ class _CreateMeetingScreenState extends State { _isLoading = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } diff --git a/lib/screens/meetings/meeting_detail_screen.dart b/lib/screens/meetings/meeting_detail_screen.dart index d70167fc..1caa3879 100644 --- a/lib/screens/meetings/meeting_detail_screen.dart +++ b/lib/screens/meetings/meeting_detail_screen.dart @@ -112,12 +112,7 @@ class _MeetingDetailScreenState extends State { _isLoading = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } @@ -130,23 +125,13 @@ class _MeetingDetailScreenState extends State { if (result['success']) { Navigator.pop(context, true); } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(result['error'])), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, result['error']); } } } catch (e) { debugPrint('Error deleting meeting: $e'); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } @@ -229,12 +214,7 @@ class _MeetingDetailScreenState extends State { _isLoading = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(result['error'])), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, result['error']); } } } catch (e) { @@ -244,12 +224,7 @@ class _MeetingDetailScreenState extends State { _isLoading = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } @@ -292,17 +267,11 @@ class _MeetingDetailScreenState extends State { content: Text('Ticket created'), backgroundColor: Colors.green), ); } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(result['error'])), - backgroundColor: Colors.red), - ); + AppErrorHandler.showSnackBar(context, result['error']); } } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red), - ); + AppErrorHandler.showSnackBar(context, e); } } @@ -333,17 +302,11 @@ class _MeetingDetailScreenState extends State { content: Text('Task created'), backgroundColor: Colors.green), ); } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(result['error'])), - backgroundColor: Colors.red), - ); + AppErrorHandler.showSnackBar(context, result['error']); } } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red), - ); + AppErrorHandler.showSnackBar(context, e); } } diff --git a/lib/screens/meetings/meeting_insights_screen.dart b/lib/screens/meetings/meeting_insights_screen.dart index da3bc611..bd6fbf84 100644 --- a/lib/screens/meetings/meeting_insights_screen.dart +++ b/lib/screens/meetings/meeting_insights_screen.dart @@ -55,11 +55,7 @@ class _MeetingInsightsScreenState extends State } catch (e) { if (mounted) { setState(() => _isLoading = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red), - ); + AppErrorHandler.showSnackBar(context, e); } } } @@ -290,9 +286,7 @@ class _MeetingInsightsScreenState extends State ); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppErrorHandler.messageFor(e)), backgroundColor: Colors.red), - ); + AppErrorHandler.showSnackBar(context, e); } } diff --git a/lib/screens/meetings/meeting_screen.dart b/lib/screens/meetings/meeting_screen.dart index 0baca8ee..ac04dda7 100644 --- a/lib/screens/meetings/meeting_screen.dart +++ b/lib/screens/meetings/meeting_screen.dart @@ -75,24 +75,14 @@ class _MeetingScreenState extends State { final result = await _supabaseService.deleteMeeting(meetingId); if (mounted && !result['success']) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(result['error'])), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, result['error']); } else { _loadInitialData(); } } catch (e) { debugPrint('Error deleting meeting: $e'); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } @@ -117,12 +107,7 @@ class _MeetingScreenState extends State { } catch (e) { debugPrint('Error launching URL: $e'); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } diff --git a/lib/screens/profile/edit_profile_screen.dart b/lib/screens/profile/edit_profile_screen.dart index a46c29d9..0e1456f1 100644 --- a/lib/screens/profile/edit_profile_screen.dart +++ b/lib/screens/profile/edit_profile_screen.dart @@ -103,12 +103,7 @@ class _EditProfileScreenState extends State { } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } finally { if (mounted) { diff --git a/lib/screens/profile/profile_screen.dart b/lib/screens/profile/profile_screen.dart index 3f7c2e5b..8ba31d4b 100644 --- a/lib/screens/profile/profile_screen.dart +++ b/lib/screens/profile/profile_screen.dart @@ -62,12 +62,7 @@ class _ProfileScreenState extends State { setState(() { _isLoading = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } @@ -130,12 +125,7 @@ class _ProfileScreenState extends State { } catch (e) { if (mounted) { Navigator.pop(context); // close loader - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } @@ -395,12 +385,7 @@ class _ProfileScreenState extends State { _isLoading = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(result['error'])), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, result['error']); } } } catch (e) { @@ -410,12 +395,7 @@ class _ProfileScreenState extends State { _isLoading = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } diff --git a/lib/screens/profile/team_members_screen.dart b/lib/screens/profile/team_members_screen.dart index c2620641..a34952fc 100644 --- a/lib/screens/profile/team_members_screen.dart +++ b/lib/screens/profile/team_members_screen.dart @@ -47,12 +47,7 @@ class _TeamMembersScreenState extends State { setState(() { _isLoading = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } diff --git a/lib/screens/tasks/create_task_screen.dart b/lib/screens/tasks/create_task_screen.dart index a07263b7..2f47d389 100644 --- a/lib/screens/tasks/create_task_screen.dart +++ b/lib/screens/tasks/create_task_screen.dart @@ -105,12 +105,7 @@ class _CreateTaskScreenState extends State { _isLoading = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(result['error'])), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, result['error']); } } catch (e) { debugPrint('Error creating task: $e'); @@ -119,12 +114,7 @@ class _CreateTaskScreenState extends State { _isLoading = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index 86dbad24..0ed35049 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -102,12 +102,7 @@ class _TaskDetailScreenState extends State { } catch (e) { debugPrint('Error updating task status: $e'); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } @@ -137,12 +132,7 @@ class _TaskDetailScreenState extends State { } catch (e) { debugPrint('Error updating task approval: $e'); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } @@ -193,23 +183,13 @@ class _TaskDetailScreenState extends State { ); Navigator.of(context).pop(true); // Return true to trigger refresh } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(result['error'])), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, result['error']); } } } catch (e) { debugPrint('Error deleting task: $e'); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } @@ -227,22 +207,12 @@ class _TaskDetailScreenState extends State { _commentController.clear(); _loadTaskDetails(); } else if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(result['error'])), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, result['error']); } } catch (e) { debugPrint('Error adding comment: $e'); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } diff --git a/lib/screens/tasks/task_screen.dart b/lib/screens/tasks/task_screen.dart index 4db40953..ed45e2c1 100644 --- a/lib/screens/tasks/task_screen.dart +++ b/lib/screens/tasks/task_screen.dart @@ -106,12 +106,7 @@ class _TaskScreenState extends State { _loadTasks(); } catch (e) { debugPrint('Error updating task status: $e'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } @@ -126,12 +121,7 @@ class _TaskScreenState extends State { _loadTasks(); } catch (e) { debugPrint('Error updating task approval: $e'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } diff --git a/lib/screens/tickets/create_ticket_screen.dart b/lib/screens/tickets/create_ticket_screen.dart index 4ae9be26..d45d72c3 100644 --- a/lib/screens/tickets/create_ticket_screen.dart +++ b/lib/screens/tickets/create_ticket_screen.dart @@ -95,12 +95,7 @@ class _CreateTicketScreenState extends State { _isLoading = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(result['error'])), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, result['error']); } } } catch (e) { @@ -110,12 +105,7 @@ class _CreateTicketScreenState extends State { _isLoading = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } diff --git a/lib/screens/tickets/ticket_detail_screen.dart b/lib/screens/tickets/ticket_detail_screen.dart index 09ed712e..33f6c605 100644 --- a/lib/screens/tickets/ticket_detail_screen.dart +++ b/lib/screens/tickets/ticket_detail_screen.dart @@ -78,12 +78,7 @@ class _TicketDetailScreenState extends State { setState(() { _isLoading = false; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } @@ -136,23 +131,13 @@ class _TicketDetailScreenState extends State { ); Navigator.of(context).pop(true); // Return true to trigger refresh } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(result['error'])), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, result['error']); } } } catch (e) { debugPrint('Error deleting ticket: $e'); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } @@ -173,23 +158,13 @@ class _TicketDetailScreenState extends State { }); } else { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(result['error'])), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, result['error']); } } } catch (e) { debugPrint('Error adding comment: $e'); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } @@ -230,12 +205,7 @@ class _TicketDetailScreenState extends State { } catch (e) { debugPrint('Error updating ticket status: $e'); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } @@ -276,12 +246,7 @@ class _TicketDetailScreenState extends State { } catch (e) { debugPrint('Error updating ticket priority: $e'); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } @@ -307,23 +272,13 @@ class _TicketDetailScreenState extends State { } } else { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(result['error'])), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, result['error']); } } } catch (e) { debugPrint('Error assigning ticket: $e'); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } diff --git a/lib/screens/tickets/ticket_screen.dart b/lib/screens/tickets/ticket_screen.dart index 2f2573ce..2f574c27 100644 --- a/lib/screens/tickets/ticket_screen.dart +++ b/lib/screens/tickets/ticket_screen.dart @@ -92,12 +92,7 @@ class _TicketScreenState extends State { } catch (e) { debugPrint('Error updating ticket status: $e'); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } @@ -115,12 +110,7 @@ class _TicketScreenState extends State { } catch (e) { debugPrint('Error updating ticket approval: $e'); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppErrorHandler.messageFor(e)), - backgroundColor: Colors.red, - ), - ); + AppErrorHandler.showSnackBar(context, e); } } } diff --git a/lib/services/ai_service.dart b/lib/services/ai_service.dart index ac4ad8e5..ee70b57e 100644 --- a/lib/services/ai_service.dart +++ b/lib/services/ai_service.dart @@ -387,7 +387,7 @@ class AIService { 'Content-Type': 'application/json', }, body: jsonEncode(requestBody), - ); + ).timeout(const Duration(seconds: 15)); if (response.statusCode == 200) { final responseData = jsonDecode(response.body); @@ -426,13 +426,21 @@ class AIService { }; } else { debugPrint('Error from Gemini API: ${response.statusCode} ${response.body}'); - return { - 'type': 'error', - 'content': AppErrorHandler.messageFor( + final statusCode = response.statusCode; + final String content; + if (statusCode == 401 || statusCode == 403) { + content = + 'AI service credentials are invalid or missing. Please check your API key configuration.'; + } else { + content = AppErrorHandler.messageFor( 'Gemini API error', - statusCode: response.statusCode, + statusCode: statusCode, fallback: AppErrorHandler.serverMessage, - ), + ); + } + return { + 'type': 'error', + 'content': content, }; } } catch (e) { @@ -529,7 +537,7 @@ class AIService { 'Content-Type': 'application/json', }, body: jsonEncode(requestBody), - ); + ).timeout(const Duration(seconds: 15)); if (response.statusCode == 200) { final responseData = jsonDecode(response.body); diff --git a/lib/utils/app_error_handler.dart b/lib/utils/app_error_handler.dart index 81a21fd3..cd29c804 100644 --- a/lib/utils/app_error_handler.dart +++ b/lib/utils/app_error_handler.dart @@ -234,13 +234,7 @@ class AppErrorHandler { ); } - if (_looksUserFacing(error.message)) { - return AppErrorInfo( - kind: AppErrorKind.server, - message: error.message, - ); - } - + // Never surface raw PostgREST/database details to users. return const AppErrorInfo( kind: AppErrorKind.server, message: serverMessage, diff --git a/test/app_error_handler_test.dart b/test/app_error_handler_test.dart index 76da6bd6..91471831 100644 --- a/test/app_error_handler_test.dart +++ b/test/app_error_handler_test.dart @@ -79,5 +79,17 @@ void main() { 'Could not complete the request', ); }); + + test('does not leak raw PostgREST error messages', () { + final info = AppErrorHandler.classify( + const PostgrestException( + message: + 'duplicate key value violates unique constraint users_email_key', + code: '23505', + ), + ); + expect(info.kind, AppErrorKind.server); + expect(info.message, AppErrorHandler.serverMessage); + }); }); } From 4812da4c3d61f3e8fbffa718c259551a988f6ae5 Mon Sep 17 00:00:00 2001 From: AJ-ing Date: Thu, 30 Jul 2026 12:24:45 +1000 Subject: [PATCH 4/4] Address remaining CodeRabbit feedback on error UX Guard meeting delete refresh against unmounted widgets, and route task/ticket failure SnackBars through AppErrorHandler so raw backend errors are not shown. Co-authored-by: Cursor --- lib/screens/meetings/meeting_screen.dart | 7 +++-- lib/screens/tasks/task_detail_screen.dart | 9 +++--- lib/screens/tickets/ticket_detail_screen.dart | 29 ++++++++----------- 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/lib/screens/meetings/meeting_screen.dart b/lib/screens/meetings/meeting_screen.dart index ac04dda7..8e74f7fb 100644 --- a/lib/screens/meetings/meeting_screen.dart +++ b/lib/screens/meetings/meeting_screen.dart @@ -74,10 +74,11 @@ class _MeetingScreenState extends State { try { final result = await _supabaseService.deleteMeeting(meetingId); - if (mounted && !result['success']) { - AppErrorHandler.showSnackBar(context, result['error']); + if (!mounted) return; + if (result['success'] == true) { + await _loadInitialData(); } else { - _loadInitialData(); + AppErrorHandler.showSnackBar(context, result['error']); } } catch (e) { debugPrint('Error deleting meeting: $e'); diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index 0ed35049..9a9e43b4 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -91,11 +91,10 @@ class _TaskDetailScreenState extends State { ), ); } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Failed to update task status'), - backgroundColor: Colors.red, - ), + AppErrorHandler.showSnackBar( + context, + null, + fallback: 'Failed to update task status', ); } } diff --git a/lib/screens/tickets/ticket_detail_screen.dart b/lib/screens/tickets/ticket_detail_screen.dart index 33f6c605..12e4ecdc 100644 --- a/lib/screens/tickets/ticket_detail_screen.dart +++ b/lib/screens/tickets/ticket_detail_screen.dart @@ -65,11 +65,10 @@ class _TicketDetailScreenState extends State { setState(() { _isLoading = false; }); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Failed to load ticket details'), - backgroundColor: Colors.red, - ), + AppErrorHandler.showSnackBar( + context, + null, + fallback: 'Failed to load ticket details', ); } } catch (e) { @@ -193,12 +192,10 @@ class _TicketDetailScreenState extends State { } } else { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: - Text('Failed to update ticket status: ${result['error']}'), - backgroundColor: Colors.red, - ), + AppErrorHandler.showSnackBar( + context, + result['error'], + fallback: 'Failed to update ticket status', ); } } @@ -234,12 +231,10 @@ class _TicketDetailScreenState extends State { } } else { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: - Text('Failed to update ticket priority: ${result['error']}'), - backgroundColor: Colors.red, - ), + AppErrorHandler.showSnackBar( + context, + result['error'], + fallback: 'Failed to update ticket priority', ); } }