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

@@ -7,6 +7,7 @@ import '../../domain/repositories/emergency_contact_repository_interface.dart';
///
/// This repository delegates data operations to Firebase Data Connect.
class EmergencyContactRepositoryImpl
with dc.DataErrorHandler
implements EmergencyContactRepositoryInterface {
final dc.ExampleConnector _dataConnect;
final FirebaseAuth _firebaseAuth;
@@ -20,64 +21,81 @@ class EmergencyContactRepositoryImpl
Future<String> _getStaffId() async {
final user = _firebaseAuth.currentUser;
if (user == null) throw Exception('User not authenticated');
if (user == null) {
throw const NotAuthenticatedException(
technicalMessage: 'User not authenticated');
}
final result =
await _dataConnect.getStaffByUserId(userId: user.uid).execute();
if (result.data.staffs.isEmpty) {
throw Exception('Staff profile not found');
throw const ServerException(technicalMessage: 'Staff profile not found');
}
return result.data.staffs.first.id;
}
@override
Future<List<EmergencyContact>> getContacts() async {
final staffId = await _getStaffId();
final result =
await _dataConnect.getEmergencyContactsByStaffId(staffId: staffId).execute();
return executeProtected(() async {
final staffId = await _getStaffId();
final result = await _dataConnect
.getEmergencyContactsByStaffId(staffId: staffId)
.execute();
return result.data.emergencyContacts.map((dto) {
return EmergencyContactAdapter.fromPrimitives(
id: dto.id,
name: dto.name,
phone: dto.phone,
relationship: dto.relationship.stringValue,
);
}).toList();
return result.data.emergencyContacts.map((dto) {
return EmergencyContactAdapter.fromPrimitives(
id: dto.id,
name: dto.name,
phone: dto.phone,
relationship: dto.relationship.stringValue,
);
}).toList();
});
}
@override
Future<void> saveContacts(List<EmergencyContact> contacts) async {
final staffId = await _getStaffId();
return executeProtected(() async {
final staffId = await _getStaffId();
// 1. Get existing to delete
final existingResult =
await _dataConnect.getEmergencyContactsByStaffId(staffId: staffId).execute();
final existingIds =
existingResult.data.emergencyContacts.map((e) => e.id).toList();
// 2. Delete all existing
await Future.wait(existingIds.map(
(id) => _dataConnect.deleteEmergencyContact(id: id).execute()));
// 3. Create new
await Future.wait(contacts.map((contact) {
dc.RelationshipType rel = dc.RelationshipType.OTHER;
switch(contact.relationship) {
case RelationshipType.family: rel = dc.RelationshipType.FAMILY; break;
case RelationshipType.spouse: rel = dc.RelationshipType.SPOUSE; break;
case RelationshipType.friend: rel = dc.RelationshipType.FRIEND; break;
case RelationshipType.other: rel = dc.RelationshipType.OTHER; break;
}
return _dataConnect
.createEmergencyContact(
name: contact.name,
phone: contact.phone,
relationship: rel,
staffId: staffId,
)
// 1. Get existing to delete
final existingResult = await _dataConnect
.getEmergencyContactsByStaffId(staffId: staffId)
.execute();
}));
final existingIds =
existingResult.data.emergencyContacts.map((e) => e.id).toList();
// 2. Delete all existing
await Future.wait(existingIds.map(
(id) => _dataConnect.deleteEmergencyContact(id: id).execute()));
// 3. Create new
await Future.wait(contacts.map((contact) {
dc.RelationshipType rel = dc.RelationshipType.OTHER;
switch (contact.relationship) {
case RelationshipType.family:
rel = dc.RelationshipType.FAMILY;
break;
case RelationshipType.spouse:
rel = dc.RelationshipType.SPOUSE;
break;
case RelationshipType.friend:
rel = dc.RelationshipType.FRIEND;
break;
case RelationshipType.other:
rel = dc.RelationshipType.OTHER;
break;
}
return _dataConnect
.createEmergencyContact(
name: contact.name,
phone: contact.phone,
relationship: rel,
staffId: staffId,
)
.execute();
}));
});
}
}

View File

@@ -1,3 +1,4 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -8,7 +9,6 @@ import '../widgets/emergency_contact_form_item.dart';
import '../widgets/emergency_contact_info_banner.dart';
import '../widgets/emergency_contact_save_button.dart';
/// The Staff Emergency Contact screen.
///
/// This screen allows staff to manage their emergency contacts during onboarding.
@@ -19,6 +19,7 @@ class EmergencyContactScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
Translations.of(context); // Force rebuild on locale change
return Scaffold(
appBar: AppBar(
elevation: 0,
@@ -38,10 +39,18 @@ class EmergencyContactScreen extends StatelessWidget {
body: BlocProvider(
create: (context) => Modular.get<EmergencyContactBloc>(),
child: BlocConsumer<EmergencyContactBloc, EmergencyContactState>(
listener: (context, state) {
if (state.status == EmergencyContactStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.errorMessage ?? 'An error occurred')),
SnackBar(
content: Text(
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'An error occurred',
),
behavior: SnackBarBehavior.floating,
),
);
}
},

View File

@@ -2,13 +2,17 @@ import 'package:firebase_auth/firebase_auth.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import '../../domain/repositories/experience_repository_interface.dart';
import 'package:krow_domain/krow_domain.dart';
/// Implementation of [ExperienceRepositoryInterface] that delegates to Data Connect.
class ExperienceRepositoryImpl implements ExperienceRepositoryInterface {
class ExperienceRepositoryImpl
with dc.DataErrorHandler
implements ExperienceRepositoryInterface {
final dc.ExampleConnector _dataConnect;
// ignore: unused_field
final FirebaseAuth _firebaseAuth;
/// Creates a [ExperienceRepositoryImpl] using Da a Connect and Auth.
/// Creates a [ExperienceRepositoryImpl] using Data Connect and Auth.
ExperienceRepositoryImpl({
required dc.ExampleConnector dataConnect,
required FirebaseAuth firebaseAuth,
@@ -17,26 +21,33 @@ class ExperienceRepositoryImpl implements ExperienceRepositoryInterface {
Future<dc.GetStaffByUserIdStaffs> _getStaff() async {
final user = _firebaseAuth.currentUser;
if (user == null) throw Exception('User not authenticated');
if (user == null) {
throw const NotAuthenticatedException(
technicalMessage: 'User not authenticated');
}
final result =
await _dataConnect.getStaffByUserId(userId: user.uid).execute();
if (result.data.staffs.isEmpty) {
throw Exception('Staff profile not found');
throw const ServerException(technicalMessage: 'Staff profile not found');
}
return result.data.staffs.first;
}
@override
Future<List<String>> getIndustries() async {
final staff = await _getStaff();
return staff.industries ?? [];
return executeProtected(() async {
final staff = await _getStaff();
return staff.industries ?? [];
});
}
@override
Future<List<String>> getSkills() async {
final staff = await _getStaff();
return staff.skills ?? [];
return executeProtected(() async {
final staff = await _getStaff();
return staff.skills ?? [];
});
}
@override
@@ -44,15 +55,13 @@ class ExperienceRepositoryImpl implements ExperienceRepositoryInterface {
List<String> industries,
List<String> skills,
) async {
try {
final staff = await _getStaff();
await _dataConnect
.updateStaff(id: staff.id)
.industries(industries)
.skills(skills)
.execute();
} catch (e) {
throw Exception('Failed to save experience: $e');
}
return executeProtected(() async {
final staff = await _getStaff();
await _dataConnect
.updateStaff(id: staff.id)
.industries(industries)
.skills(skills)
.execute();
});
}
}

View File

@@ -46,7 +46,7 @@ class ExperiencePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final i18n = t.staff.onboarding.experience;
final i18n = Translations.of(context).staff.onboarding.experience;
return Scaffold(
appBar: UiAppBar(
@@ -64,7 +64,14 @@ class ExperiencePage extends StatelessWidget {
Modular.to.pop();
} else if (state.status == ExperienceStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.errorMessage ?? 'An error occurred')),
SnackBar(
content: Text(
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'An error occurred',
),
behavior: SnackBarBehavior.floating,
),
);
}
},

View File

@@ -13,7 +13,9 @@ import '../../domain/repositories/personal_info_repository_interface.dart';
/// - Delegating all data access to the data_connect layer
/// - Mapping between data_connect DTOs and domain entities
/// - Containing no business logic
class PersonalInfoRepositoryImpl implements PersonalInfoRepositoryInterface {
class PersonalInfoRepositoryImpl
with DataErrorHandler
implements PersonalInfoRepositoryInterface {
/// Creates a [PersonalInfoRepositoryImpl].
///
@@ -28,58 +30,63 @@ class PersonalInfoRepositoryImpl implements PersonalInfoRepositoryInterface {
@override
Future<Staff> getStaffProfile() async {
final firebase_auth.User? user = _firebaseAuth.currentUser;
if (user == null) {
throw Exception('User not authenticated');
}
return executeProtected(() async {
final firebase_auth.User? user = _firebaseAuth.currentUser;
if (user == null) {
throw NotAuthenticatedException(
technicalMessage: 'User not authenticated');
}
// Query staff data from Firebase Data Connect
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables> result =
await _dataConnect.getStaffByUserId(userId: user.uid).execute();
// Query staff data from Firebase Data Connect
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables> result =
await _dataConnect.getStaffByUserId(userId: user.uid).execute();
if (result.data.staffs.isEmpty) {
throw Exception('Staff profile not found for User ID: ${user.uid}');
}
if (result.data.staffs.isEmpty) {
throw const ServerException(technicalMessage: 'Staff profile not found');
}
final GetStaffByUserIdStaffs rawStaff = result.data.staffs.first;
final GetStaffByUserIdStaffs rawStaff = result.data.staffs.first;
// Map from data_connect DTO to domain entity
return _mapToStaffEntity(rawStaff);
// Map from data_connect DTO to domain entity
return _mapToStaffEntity(rawStaff);
});
}
@override
Future<Staff> updateStaffProfile({required String staffId, required Map<String, dynamic> data}) async {
// Start building the update mutation
UpdateStaffVariablesBuilder updateBuilder = _dataConnect.updateStaff(id: staffId);
return executeProtected(() async {
// Start building the update mutation
UpdateStaffVariablesBuilder updateBuilder = _dataConnect.updateStaff(id: staffId);
// Apply updates from map if present
if (data.containsKey('name')) {
updateBuilder = updateBuilder.fullName(data['name'] as String);
}
if (data.containsKey('email')) {
updateBuilder = updateBuilder.email(data['email'] as String);
}
if (data.containsKey('phone')) {
updateBuilder = updateBuilder.phone(data['phone'] as String?);
}
if (data.containsKey('avatar')) {
updateBuilder = updateBuilder.photoUrl(data['avatar'] as String?);
}
if (data.containsKey('preferredLocations')) {
// After schema update and SDK regeneration, preferredLocations accepts List<String>
updateBuilder = updateBuilder.preferredLocations(data['preferredLocations'] as List<String>);
}
// Apply updates from map if present
if (data.containsKey('name')) {
updateBuilder = updateBuilder.fullName(data['name'] as String);
}
if (data.containsKey('email')) {
updateBuilder = updateBuilder.email(data['email'] as String);
}
if (data.containsKey('phone')) {
updateBuilder = updateBuilder.phone(data['phone'] as String?);
}
if (data.containsKey('avatar')) {
updateBuilder = updateBuilder.photoUrl(data['avatar'] as String?);
}
if (data.containsKey('preferredLocations')) {
// After schema update and SDK regeneration, preferredLocations accepts List<String>
updateBuilder = updateBuilder.preferredLocations(data['preferredLocations'] as List<String>);
}
// Execute the update
final OperationResult<UpdateStaffData, UpdateStaffVariables> result =
await updateBuilder.execute();
// Execute the update
final OperationResult<UpdateStaffData, UpdateStaffVariables> result =
await updateBuilder.execute();
if (result.data.staff_update == null) {
throw Exception('Failed to update staff profile');
}
if (result.data.staff_update == null) {
throw const ServerException(technicalMessage: 'Failed to update staff profile');
}
// Fetch the updated staff profile to return complete entity
return getStaffProfile();
// Fetch the updated staff profile to return complete entity
return getStaffProfile();
});
}
@override

View File

@@ -23,7 +23,7 @@ class PersonalInfoPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final TranslationsStaffOnboardingPersonalInfoEn i18n = t.staff.onboarding.personal_info;
final i18n = Translations.of(context).staff.onboarding.personal_info;
return BlocProvider<PersonalInfoBloc>(
create: (BuildContext context) => Modular.get<PersonalInfoBloc>(),
child: BlocListener<PersonalInfoBloc, PersonalInfoState>(
@@ -39,8 +39,12 @@ class PersonalInfoPage extends StatelessWidget {
} else if (state.status == PersonalInfoStatus.error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'An error occurred'),
backgroundColor: UiColors.destructive,
content: Text(
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'An error occurred',
),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 3),
),
);