feat(core_localization): add error translation utility and new error messages

feat(client_auth): implement error handling with localized messages
feat(client_hubs): implement error handling with localized messages
feat(client_billing): navigate to home after billing
feat(client_coverage): navigate to home after coverage
feat(client_create_order): navigate to home after create order
feat(client_settings): navigate to home after settings
feat(client_view_orders): show hub name in order card
fix(client_auth): handle existing firebase accounts during sign-up

This commit introduces a new utility function, `translateErrorKey`,
to translate error message keys to localized strings. It also adds
new error messages to the localization files for both English and
Spanish.

The commit also implements error handling with localized messages in
the client authentication and hubs features. This makes it easier for
users to understand what went wrong and how to fix it.

Additionally, the commit updates the navigation flow for the billing,
coverage, create order, and settings features to navigate to the home
page after the user completes the action.

Finally, the commit fixes a bug where the hub name was not being
displayed in the order card.
This commit is contained in:
bwnyasse
2026-01-31 18:56:48 -05:00
parent 9517606e7a
commit caac050ac9
30 changed files with 1396 additions and 105 deletions

View File

@@ -1,7 +1,20 @@
import 'dart:developer' as developer;
import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart' as domain;
import 'package:krow_domain/krow_domain.dart'
show
InvalidCredentialsException,
SignInFailedException,
SignUpFailedException,
WeakPasswordException,
AccountExistsException,
UserNotFoundException,
UnauthorizedAppException,
PasswordMismatchException,
GoogleOnlyAccountException;
import '../../domain/repositories/auth_repository_interface.dart';
@@ -33,7 +46,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) {
throw Exception('Sign-in failed, no Firebase user received.');
throw const SignInFailedException(
technicalMessage: 'No Firebase user received after sign-in',
);
}
return _getUserProfile(
@@ -44,12 +59,20 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
} on firebase.FirebaseAuthException catch (e) {
if (e.code == 'invalid-credential' || e.code == 'wrong-password') {
throw Exception('Incorrect email or password.');
throw InvalidCredentialsException(
technicalMessage: 'Firebase error code: ${e.code}',
);
} else {
throw Exception('Authentication error: ${e.message}');
throw SignInFailedException(
technicalMessage: 'Firebase auth error: ${e.message}',
);
}
} on domain.AppException {
rethrow;
} catch (e) {
throw Exception('Failed to sign in and fetch user data: ${e.toString()}');
throw SignInFailedException(
technicalMessage: 'Unexpected error: $e',
);
}
}
@@ -59,63 +82,225 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
required String email,
required String password,
}) async {
firebase.User? firebaseUser;
String? createdBusinessId;
try {
// Step 1: Try to create Firebase Auth user
final firebase.UserCredential credential = await _firebaseAuth.createUserWithEmailAndPassword(
email: email,
password: password,
);
firebaseUser = credential.user;
if (firebaseUser == null) {
throw const SignUpFailedException(
technicalMessage: 'Firebase user could not be created',
);
}
// New user created successfully, proceed to create PostgreSQL entities
return await _createBusinessAndUser(
firebaseUser: firebaseUser,
companyName: companyName,
email: email,
onBusinessCreated: (String businessId) => createdBusinessId = businessId,
);
} on firebase.FirebaseAuthException catch (e) {
if (e.code == 'weak-password') {
throw WeakPasswordException(
technicalMessage: 'Firebase: ${e.message}',
);
} else if (e.code == 'email-already-in-use') {
// Email exists in Firebase Auth - try to sign in and complete registration
return await _handleExistingFirebaseAccount(
email: email,
password: password,
companyName: companyName,
);
} else {
throw SignUpFailedException(
technicalMessage: 'Firebase auth error: ${e.message}',
);
}
} on domain.AppException {
// Rollback for our known exceptions
await _rollbackSignUp(firebaseUser: firebaseUser, businessId: createdBusinessId);
rethrow;
} catch (e) {
// Rollback: Clean up any partially created resources
await _rollbackSignUp(firebaseUser: firebaseUser, businessId: createdBusinessId);
throw SignUpFailedException(
technicalMessage: 'Unexpected error: $e',
);
}
}
/// Handles the case where email already exists in Firebase Auth.
///
/// This can happen when:
/// 1. User signed up with Google in another app sharing the same Firebase project
/// 2. User already has a KROW account
///
/// The flow:
/// 1. Try to sign in with provided password
/// 2. If sign-in succeeds, check if BUSINESS user exists in PostgreSQL
/// 3. If not, create Business + User (user is new to KROW)
/// 4. If yes, they already have a KROW account
Future<domain.User> _handleExistingFirebaseAccount({
required String email,
required String password,
required String companyName,
}) async {
developer.log('Email exists in Firebase, attempting sign-in: $email', name: 'AuthRepository');
try {
// Try to sign in with the provided password
final firebase.UserCredential credential = await _firebaseAuth.signInWithEmailAndPassword(
email: email,
password: password,
);
final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) {
throw Exception('Sign-up failed, Firebase user could not be created.');
throw const SignUpFailedException(
technicalMessage: 'Sign-in succeeded but no user returned',
);
}
// Client-specific business logic:
// 1. Create a `Business` entity.
// 2. Create a `User` entity associated with the business.
final OperationResult<dc.CreateBusinessData, dc.CreateBusinessVariables> createBusinessResponse = await _dataConnect.createBusiness(
businessName: companyName,
userId: firebaseUser.uid,
rateGroup: dc.BusinessRateGroup.STANDARD,
status: dc.BusinessStatus.PENDING,
).execute();
// Sign-in succeeded! Check if user already has a BUSINESS account in PostgreSQL
final bool hasBusinessAccount = await _checkBusinessUserExists(firebaseUser.uid);
final dc.CreateBusinessBusinessInsert? businessData = createBusinessResponse.data?.business_insert;
if (businessData == null) {
await firebaseUser.delete(); // Rollback if business creation fails
throw Exception('Business creation failed after Firebase user registration.');
if (hasBusinessAccount) {
// User already has a KROW Client account
developer.log('User already has BUSINESS account: ${firebaseUser.uid}', name: 'AuthRepository');
throw AccountExistsException(
technicalMessage: 'User ${firebaseUser.uid} already has BUSINESS role',
);
}
final OperationResult<dc.CreateUserData, dc.CreateUserVariables> createUserResponse = await _dataConnect.createUser(
id: firebaseUser.uid,
role: dc.UserBaseRole.USER,
)
.email(email)
.userRole('BUSINESS')
.execute();
final dc.CreateUserUserInsert? newUserData = createUserResponse.data?.user_insert;
if (newUserData == null) {
await firebaseUser.delete(); // Rollback if user profile creation fails
// TO-DO: Also delete the created Business if this fails
throw Exception('User profile creation failed after Firebase user registration.');
}
return _getUserProfile(
firebaseUserId: firebaseUser.uid,
fallbackEmail: firebaseUser.email ?? email,
// User exists in Firebase but not in KROW PostgreSQL - create the entities
developer.log('Creating BUSINESS account for existing Firebase user: ${firebaseUser.uid}', name: 'AuthRepository');
return await _createBusinessAndUser(
firebaseUser: firebaseUser,
companyName: companyName,
email: email,
onBusinessCreated: (_) {}, // No rollback needed for existing Firebase user
);
} on firebase.FirebaseAuthException catch (e) {
if (e.code == 'weak-password') {
throw Exception('The password provided is too weak.');
} else if (e.code == 'email-already-in-use') {
throw Exception('An account already exists for that email address.');
// Sign-in failed - check why
developer.log('Sign-in failed with code: ${e.code}', name: 'AuthRepository');
if (e.code == 'wrong-password' || e.code == 'invalid-credential') {
// Password doesn't match - check what providers are available
return await _handlePasswordMismatch(email);
} else {
throw Exception('Sign-up error: ${e.message}');
throw SignUpFailedException(
technicalMessage: 'Firebase sign-in error: ${e.message}',
);
}
} on domain.AppException {
rethrow;
}
}
/// Handles the case where the password doesn't match the existing account.
///
/// Note: fetchSignInMethodsForEmail was deprecated by Firebase for security
/// reasons (email enumeration). We show a combined message that covers both
/// cases: wrong password OR account uses different sign-in method (Google).
Future<Never> _handlePasswordMismatch(String email) async {
// We can't distinguish between "wrong password" and "no password provider"
// due to Firebase deprecating fetchSignInMethodsForEmail.
// The PasswordMismatchException message covers both scenarios.
developer.log('Password mismatch or different provider for: $email', name: 'AuthRepository');
throw PasswordMismatchException(
technicalMessage: 'Email $email: password mismatch or different auth provider',
);
}
/// Checks if a user with BUSINESS role exists in PostgreSQL.
Future<bool> _checkBusinessUserExists(String firebaseUserId) async {
try {
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response =
await _dataConnect.getUserById(id: firebaseUserId).execute();
final dc.GetUserByIdUser? user = response.data?.user;
return user != null && user.userRole == 'BUSINESS';
} catch (e) {
throw Exception('Failed to sign up and create user data: ${e.toString()}');
developer.log('Error checking business user: $e', name: 'AuthRepository');
return false;
}
}
/// Creates Business and User entities in PostgreSQL for a Firebase user.
Future<domain.User> _createBusinessAndUser({
required firebase.User firebaseUser,
required String companyName,
required String email,
required void Function(String businessId) onBusinessCreated,
}) async {
// Create Business entity in PostgreSQL
final OperationResult<dc.CreateBusinessData, dc.CreateBusinessVariables> createBusinessResponse =
await _dataConnect.createBusiness(
businessName: companyName,
userId: firebaseUser.uid,
rateGroup: dc.BusinessRateGroup.STANDARD,
status: dc.BusinessStatus.PENDING,
).execute();
final dc.CreateBusinessBusinessInsert? businessData = createBusinessResponse.data?.business_insert;
if (businessData == null) {
throw const SignUpFailedException(
technicalMessage: 'Business creation failed in PostgreSQL',
);
}
onBusinessCreated(businessData.id);
// Create User entity in PostgreSQL
final OperationResult<dc.CreateUserData, dc.CreateUserVariables> createUserResponse =
await _dataConnect.createUser(
id: firebaseUser.uid,
role: dc.UserBaseRole.USER,
)
.email(email)
.userRole('BUSINESS')
.execute();
final dc.CreateUserUserInsert? newUserData = createUserResponse.data?.user_insert;
if (newUserData == null) {
throw const SignUpFailedException(
technicalMessage: 'User profile creation failed in PostgreSQL',
);
}
return _getUserProfile(
firebaseUserId: firebaseUser.uid,
fallbackEmail: firebaseUser.email ?? email,
);
}
/// Rollback helper to clean up partially created resources during sign-up.
Future<void> _rollbackSignUp({
firebase.User? firebaseUser,
String? businessId,
}) async {
// Delete business first (if created)
if (businessId != null) {
try {
await _dataConnect.deleteBusiness(id: businessId).execute();
} catch (_) {
// Log but don't throw - we're already in error recovery
}
}
// Delete Firebase user (if created)
if (firebaseUser != null) {
try {
await firebaseUser.delete();
} catch (_) {
// Log but don't throw - we're already in error recovery
}
}
}
@@ -142,17 +327,23 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response = await _dataConnect.getUserById(id: firebaseUserId).execute();
final dc.GetUserByIdUser? user = response.data?.user;
if (user == null) {
throw Exception('Authenticated user profile not found in database.');
throw UserNotFoundException(
technicalMessage: 'Firebase UID $firebaseUserId not found in users table',
);
}
if (requireBusinessRole && user.userRole != 'BUSINESS') {
await _firebaseAuth.signOut();
dc.ClientSessionStore.instance.clear();
throw Exception('User is not authorized for this app.');
throw UnauthorizedAppException(
technicalMessage: 'User role is ${user.userRole}, expected BUSINESS',
);
}
final String? email = user.email ?? fallbackEmail;
if (email == null || email.isEmpty) {
throw Exception('User email is missing in profile data.');
throw UserNotFoundException(
technicalMessage: 'User email missing for UID $firebaseUserId',
);
}
final domain.User domainUser = domain.User(

View File

@@ -1,3 +1,5 @@
import 'dart:developer' as developer;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
@@ -56,11 +58,20 @@ class ClientAuthBloc extends Bloc<ClientAuthEvent, ClientAuthState> {
SignInWithEmailArguments(email: event.email, password: event.password),
);
emit(state.copyWith(status: ClientAuthStatus.authenticated, user: user));
} catch (e) {
} on AppException catch (e) {
developer.log('Error ${e.code}: ${e.technicalMessage}', name: 'ClientAuthBloc');
emit(
state.copyWith(
status: ClientAuthStatus.error,
errorMessage: e.toString(),
errorMessage: e.messageKey,
),
);
} catch (e) {
developer.log('Unexpected error: $e', name: 'ClientAuthBloc');
emit(
state.copyWith(
status: ClientAuthStatus.error,
errorMessage: 'errors.generic.unknown',
),
);
}
@@ -81,11 +92,20 @@ class ClientAuthBloc extends Bloc<ClientAuthEvent, ClientAuthState> {
),
);
emit(state.copyWith(status: ClientAuthStatus.authenticated, user: user));
} catch (e) {
} on AppException catch (e) {
developer.log('Error ${e.code}: ${e.technicalMessage}', name: 'ClientAuthBloc');
emit(
state.copyWith(
status: ClientAuthStatus.error,
errorMessage: e.toString(),
errorMessage: e.messageKey,
),
);
} catch (e) {
developer.log('Unexpected error: $e', name: 'ClientAuthBloc');
emit(
state.copyWith(
status: ClientAuthStatus.error,
errorMessage: 'errors.generic.unknown',
),
);
}
@@ -102,11 +122,20 @@ class ClientAuthBloc extends Bloc<ClientAuthEvent, ClientAuthState> {
SignInWithSocialArguments(provider: event.provider),
);
emit(state.copyWith(status: ClientAuthStatus.authenticated, user: user));
} catch (e) {
} on AppException catch (e) {
developer.log('Error ${e.code}: ${e.technicalMessage}', name: 'ClientAuthBloc');
emit(
state.copyWith(
status: ClientAuthStatus.error,
errorMessage: e.toString(),
errorMessage: e.messageKey,
),
);
} catch (e) {
developer.log('Unexpected error: $e', name: 'ClientAuthBloc');
emit(
state.copyWith(
status: ClientAuthStatus.error,
errorMessage: 'errors.generic.unknown',
),
);
}
@@ -121,11 +150,20 @@ class ClientAuthBloc extends Bloc<ClientAuthEvent, ClientAuthState> {
try {
await _signOut();
emit(state.copyWith(status: ClientAuthStatus.signedOut, user: null));
} catch (e) {
} on AppException catch (e) {
developer.log('Error ${e.code}: ${e.technicalMessage}', name: 'ClientAuthBloc');
emit(
state.copyWith(
status: ClientAuthStatus.error,
errorMessage: e.toString(),
errorMessage: e.messageKey,
),
);
} catch (e) {
developer.log('Unexpected error: $e', name: 'ClientAuthBloc');
emit(
state.copyWith(
status: ClientAuthStatus.error,
errorMessage: 'errors.generic.unknown',
),
);
}

View File

@@ -45,10 +45,11 @@ class ClientSignInPage extends StatelessWidget {
if (state.status == ClientAuthStatus.authenticated) {
Modular.to.navigateClientHome();
} else if (state.status == ClientAuthStatus.error) {
final String errorMessage = state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: t.errors.generic.unknown;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'Authentication Error'),
),
SnackBar(content: Text(errorMessage)),
);
}
},

View File

@@ -49,10 +49,11 @@ class ClientSignUpPage extends StatelessWidget {
if (state.status == ClientAuthStatus.authenticated) {
Modular.to.navigateClientHome();
} else if (state.status == ClientAuthStatus.error) {
final String errorMessage = state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: t.errors.generic.unknown;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'Authentication Error'),
),
SnackBar(content: Text(errorMessage)),
);
}
},