feat: complete centralized error handling system with documentation

This commit is contained in:
2026-02-11 10:36:08 +05:30
parent 7570ffa3b9
commit 3e212220c7
43 changed files with 1144 additions and 2858 deletions

View File

@@ -60,9 +60,17 @@ class AuthRepositoryImpl
},
verificationFailed: (FirebaseAuthException e) {
if (!completer.isCompleted) {
completer.completeError(
Exception(e.message ?? 'Phone verification failed.'),
);
// Map Firebase network errors to NetworkException
if (e.code == 'network-request-failed' ||
e.message?.contains('Unable to resolve host') == true) {
completer.completeError(
const domain.NetworkException(technicalMessage: 'Auth network failure'),
);
} else {
completer.completeError(
domain.SignInFailedException(technicalMessage: 'Firebase ${e.code}: ${e.message}'),
);
}
}
},
codeSent: (String verificationId, _) {
@@ -107,10 +115,25 @@ class AuthRepositoryImpl
verificationId: verificationId,
smsCode: smsCode,
);
final UserCredential userCredential = await firebaseAuth.signInWithCredential(credential);
final UserCredential userCredential = await executeProtected(
() async {
try {
return await firebaseAuth.signInWithCredential(credential);
} on FirebaseAuthException catch (e) {
if (e.code == 'invalid-verification-code') {
throw const domain.InvalidCredentialsException(
technicalMessage: 'Invalid OTP code entered.',
);
}
rethrow;
}
},
);
final User? firebaseUser = userCredential.user;
if (firebaseUser == null) {
throw Exception('Phone verification failed, no Firebase user received.');
throw const domain.SignInFailedException(
technicalMessage: 'Phone verification failed, no Firebase user received.',
);
}
final QueryResult<GetUserByIdData, GetUserByIdVariables> response =
@@ -135,7 +158,9 @@ class AuthRepositoryImpl
} else {
if (user.userRole != 'STAFF') {
await firebaseAuth.signOut();
throw Exception('User is not authorized for this app.');
throw const domain.UnauthorizedAppException(
technicalMessage: 'User is not authorized for this app.',
);
}
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
staffResponse = await executeProtected(() => dataConnect
@@ -145,19 +170,23 @@ class AuthRepositoryImpl
.execute());
if (staffResponse.data.staffs.isNotEmpty) {
await firebaseAuth.signOut();
throw Exception(
'This user already has a staff profile. Please log in.',
throw const domain.AccountExistsException(
technicalMessage: 'This user already has a staff profile. Please log in.',
);
}
}
} else {
if (user == null) {
await firebaseAuth.signOut();
throw Exception('Authenticated user profile not found in database.');
throw const domain.UserNotFoundException(
technicalMessage: 'Authenticated user profile not found in database.',
);
}
if (user.userRole != 'STAFF') {
await firebaseAuth.signOut();
throw Exception('User is not authorized for this app.');
throw const domain.UnauthorizedAppException(
technicalMessage: 'User is not authorized for this app.',
);
}
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
@@ -168,8 +197,8 @@ class AuthRepositoryImpl
.execute());
if (staffResponse.data.staffs.isEmpty) {
await firebaseAuth.signOut();
throw Exception(
'Your account is not registered yet. Please register first.',
throw const domain.UserNotFoundException(
technicalMessage: 'Your account is not registered yet. Please register first.',
);
}
staffRecord = staffResponse.data.staffs.first;

View File

@@ -38,7 +38,9 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
@override
void dispose() {
_authBloc.add(AuthResetRequested(mode: widget.mode));
if (!_authBloc.isClosed) {
_authBloc.add(AuthResetRequested(mode: widget.mode));
}
super.dispose();
}
@@ -105,14 +107,15 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
}
} else if (state.status == AuthStatus.error &&
state.mode == AuthMode.signup) {
final String message = state.errorMessage ?? '';
if (message.contains('staff profile')) {
final String messageKey = state.errorMessage ?? '';
// Handle specific business logic errors for signup
if (messageKey == 'errors.auth.account_exists') {
final ScaffoldMessengerState messenger =
ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
SnackBar(
content: Text(message),
content: Text(translateErrorKey(messageKey)),
duration: const Duration(seconds: 5),
),
);
@@ -120,7 +123,7 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
if (!mounted) return;
Modular.to.navigate('/');
});
} else if (message.contains('not authorized')) {
} else if (messageKey == 'errors.auth.unauthorized_app') {
Modular.to.pop();
}
}

View File

@@ -1,9 +1,16 @@
import 'package:flutter/material.dart';
import 'package:design_system/design_system.dart';
class GetStartedBackground extends StatelessWidget {
class GetStartedBackground extends StatefulWidget {
const GetStartedBackground({super.key});
@override
State<GetStartedBackground> createState() => _GetStartedBackgroundState();
}
class _GetStartedBackgroundState extends State<GetStartedBackground> {
bool _hasError = false;
@override
Widget build(BuildContext context) {
return Container(
@@ -26,12 +33,48 @@ class GetStartedBackground extends StatelessWidget {
),
padding: const EdgeInsets.all(8.0),
child: ClipOval(
child: Image.network(
'https://images.unsplash.com/photo-1577219491135-ce391730fb2c?w=400&h=400&fit=crop&crop=faces',
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Image.asset(UiImageAssets.logoBlue);
},
child: Stack(
fit: StackFit.expand,
children: [
// Layer 1: The Fallback Logo (Always visible until image loads)
Padding(
padding: const EdgeInsets.all(48.0),
child: Image.asset(UiImageAssets.logoBlue),
),
// Layer 2: The Network Image (Only visible on success)
if (!_hasError)
Image.network(
'https://images.unsplash.com/photo-1577219491135-ce391730fb2c?w=400&h=400&fit=crop&crop=faces',
fit: BoxFit.cover,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded) return child;
// Only animate opacity if we have a frame
return AnimatedOpacity(
opacity: frame == null ? 0 : 1,
duration: const Duration(milliseconds: 300),
child: child,
);
},
loadingBuilder: (context, child, loadingProgress) {
// While loading, show nothing (transparent) so layer 1 shows
if (loadingProgress == null) return child;
return const SizedBox.shrink();
},
errorBuilder: (context, error, stackTrace) {
// On error, show nothing (transparent) so layer 1 shows
// Also schedule a state update to prevent retries if needed
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_hasError) {
setState(() {
_hasError = true;
});
}
});
return const SizedBox.shrink();
},
),
],
),
),
),

View File

@@ -1,6 +1,7 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:core_localization/core_localization.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../blocs/auth_event.dart';
import '../../../blocs/auth_bloc.dart';
@@ -118,7 +119,10 @@ class _OtpInputFieldState extends State<OtpInputField> {
Padding(
padding: const EdgeInsets.only(top: UiConstants.space4),
child: Center(
child: Text(widget.error, style: UiTypography.body2r.textError),
child: Text(
translateErrorKey(widget.error),
style: UiTypography.body2r.textError,
),
),
),
],

View File

@@ -1,6 +1,7 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:core_localization/core_localization.dart';
import 'package:staff_authentication/staff_authentication.dart';
/// A widget that displays the phone number input field with country code.
@@ -100,7 +101,10 @@ class _PhoneInputFormFieldState extends State<PhoneInputFormField> {
if (widget.error.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: UiConstants.space2),
child: Text(widget.error, style: UiTypography.body2r.textError),
child: Text(
translateErrorKey(widget.error),
style: UiTypography.body2r.textError,
),
),
],
);