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

@@ -5,7 +5,9 @@ import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/bank_account_repository.dart';
/// Implementation of [BankAccountRepository] that integrates with Data Connect.
class BankAccountRepositoryImpl implements BankAccountRepository {
class BankAccountRepositoryImpl
with DataErrorHandler
implements BankAccountRepository {
/// Creates a [BankAccountRepositoryImpl].
const BankAccountRepositoryImpl({
required this.dataConnect,
@@ -19,60 +21,65 @@ class BankAccountRepositoryImpl implements BankAccountRepository {
@override
Future<List<BankAccount>> getAccounts() async {
final String staffId = _getStaffId();
final QueryResult<GetAccountsByOwnerIdData, GetAccountsByOwnerIdVariables>
result = await dataConnect
.getAccountsByOwnerId(ownerId: staffId)
.execute();
return result.data.accounts.map((GetAccountsByOwnerIdAccounts account) {
return BankAccountAdapter.fromPrimitives(
id: account.id,
userId: account.ownerId,
bankName: account.bank,
accountNumber: account.accountNumber,
last4: account.last4,
sortCode: account.routeNumber,
type: account.type is Known<AccountType> ? (account.type as Known<AccountType>).value.name : null,
isPrimary: account.isPrimary,
);
}).toList();
return executeProtected(() async {
final String staffId = _getStaffId();
final QueryResult<GetAccountsByOwnerIdData, GetAccountsByOwnerIdVariables>
result = await dataConnect
.getAccountsByOwnerId(ownerId: staffId)
.execute();
return result.data.accounts.map((GetAccountsByOwnerIdAccounts account) {
return BankAccountAdapter.fromPrimitives(
id: account.id,
userId: account.ownerId,
bankName: account.bank,
accountNumber: account.accountNumber,
last4: account.last4,
sortCode: account.routeNumber,
type: account.type is Known<AccountType> ? (account.type as Known<AccountType>).value.name : null,
isPrimary: account.isPrimary,
);
}).toList();
});
}
@override
Future<void> addAccount(BankAccount account) async {
final String staffId = _getStaffId();
return executeProtected(() async {
final String staffId = _getStaffId();
final QueryResult<GetAccountsByOwnerIdData, GetAccountsByOwnerIdVariables>
existingAccounts = await dataConnect
.getAccountsByOwnerId(ownerId: staffId)
.execute();
final bool hasAccounts = existingAccounts.data.accounts.isNotEmpty;
final bool isPrimary = !hasAccounts;
final QueryResult<GetAccountsByOwnerIdData, GetAccountsByOwnerIdVariables>
existingAccounts = await dataConnect
.getAccountsByOwnerId(ownerId: staffId)
.execute();
final bool hasAccounts = existingAccounts.data.accounts.isNotEmpty;
final bool isPrimary = !hasAccounts;
await dataConnect.createAccount(
bank: account.bankName,
type: AccountType.values.byName(BankAccountAdapter.typeToString(account.type)),
last4: _safeLast4(account.last4, account.accountNumber),
ownerId: staffId,
)
.isPrimary(isPrimary)
.accountNumber(account.accountNumber)
.routeNumber(account.sortCode)
.execute();
await dataConnect.createAccount(
bank: account.bankName,
type: AccountType.values.byName(BankAccountAdapter.typeToString(account.type)),
last4: _safeLast4(account.last4, account.accountNumber),
ownerId: staffId,
)
.isPrimary(isPrimary)
.accountNumber(account.accountNumber)
.routeNumber(account.sortCode)
.execute();
});
}
/// Helper to get the logged-in staff ID.
String _getStaffId() {
final auth.User? user = firebaseAuth.currentUser;
if (user == null) {
throw Exception('User not authenticated');
throw const NotAuthenticatedException(
technicalMessage: 'User not authenticated');
}
final String? staffId = StaffSessionStore.instance.session?.staff?.id;
if (staffId == null || staffId.isEmpty) {
throw Exception('Staff profile is missing or session not initialized.');
throw const ServerException(technicalMessage: 'Staff profile is missing or session not initialized.');
}
return staffId;
}

View File

@@ -59,6 +59,17 @@ class BankAccountPage extends StatelessWidget {
duration: const Duration(seconds: 3),
),
);
} else if (state.status == BankAccountStatus.error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'An error occurred',
),
behavior: SnackBarBehavior.floating,
),
);
}
},
builder: (BuildContext context, BankAccountState state) {
@@ -67,7 +78,18 @@ class BankAccountPage extends StatelessWidget {
}
if (state.status == BankAccountStatus.error) {
return Center(child: Text(state.errorMessage ?? 'Error'));
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'Error',
textAlign: TextAlign.center,
style: UiTypography.body1m.copyWith(color: UiColors.textSecondary),
),
),
);
}
return Column(

View File

@@ -9,7 +9,9 @@ import 'package:krow_core/core.dart';
import '../../domain/repositories/time_card_repository.dart';
/// Implementation of [TimeCardRepository] using Firebase Data Connect.
class TimeCardRepositoryImpl implements TimeCardRepository {
class TimeCardRepositoryImpl
with dc.DataErrorHandler
implements TimeCardRepository {
final dc.ExampleConnector _dataConnect;
final firebase.FirebaseAuth _firebaseAuth;
@@ -22,57 +24,62 @@ class TimeCardRepositoryImpl implements TimeCardRepository {
Future<String> _getStaffId() async {
final firebase.User? user = _firebaseAuth.currentUser;
if (user == null) throw Exception('User not authenticated');
if (user == null) {
throw const NotAuthenticatedException(
technicalMessage: 'User not authenticated');
}
final fdc.QueryResult<dc.GetStaffByUserIdData, dc.GetStaffByUserIdVariables> 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<TimeCard>> getTimeCards(DateTime month) async {
final String staffId = await _getStaffId();
// Fetch applications. Limit can be adjusted, assuming 100 is safe for now.
final fdc.QueryResult<dc.GetApplicationsByStaffIdData, dc.GetApplicationsByStaffIdVariables> result =
await _dataConnect.getApplicationsByStaffId(staffId: staffId).limit(100).execute();
return executeProtected(() async {
final String staffId = await _getStaffId();
// Fetch applications. Limit can be adjusted, assuming 100 is safe for now.
final fdc.QueryResult<dc.GetApplicationsByStaffIdData, dc.GetApplicationsByStaffIdVariables> result =
await _dataConnect.getApplicationsByStaffId(staffId: staffId).limit(100).execute();
return result.data.applications
.where((dc.GetApplicationsByStaffIdApplications app) {
final DateTime? shiftDate = app.shift.date == null
? null
: DateTimeUtils.toDeviceTime(app.shift.date!.toDateTime());
if (shiftDate == null) return false;
return shiftDate.year == month.year && shiftDate.month == month.month;
})
.map((dc.GetApplicationsByStaffIdApplications app) {
final DateTime shiftDate =
DateTimeUtils.toDeviceTime(app.shift.date!.toDateTime());
final String startTime = _formatTime(app.checkInTime) ?? _formatTime(app.shift.startTime) ?? '';
final String endTime = _formatTime(app.checkOutTime) ?? _formatTime(app.shift.endTime) ?? '';
return result.data.applications
.where((dc.GetApplicationsByStaffIdApplications app) {
final DateTime? shiftDate = app.shift.date == null
? null
: DateTimeUtils.toDeviceTime(app.shift.date!.toDateTime());
if (shiftDate == null) return false;
return shiftDate.year == month.year && shiftDate.month == month.month;
})
.map((dc.GetApplicationsByStaffIdApplications app) {
final DateTime shiftDate =
DateTimeUtils.toDeviceTime(app.shift.date!.toDateTime());
final String startTime = _formatTime(app.checkInTime) ?? _formatTime(app.shift.startTime) ?? '';
final String endTime = _formatTime(app.checkOutTime) ?? _formatTime(app.shift.endTime) ?? '';
// Prefer shiftRole values for pay/hours
final double hours = app.shiftRole.hours ?? 0.0;
final double rate = app.shiftRole.role.costPerHour;
final double pay = app.shiftRole.totalValue ?? 0.0;
// Prefer shiftRole values for pay/hours
final double hours = app.shiftRole.hours ?? 0.0;
final double rate = app.shiftRole.role.costPerHour;
final double pay = app.shiftRole.totalValue ?? 0.0;
return TimeCardAdapter.fromPrimitives(
id: app.id,
shiftTitle: app.shift.title,
clientName: app.shift.order.business.businessName,
date: shiftDate,
startTime: startTime,
endTime: endTime,
totalHours: hours,
hourlyRate: rate,
totalPay: pay,
status: app.status.stringValue,
location: app.shift.location,
);
})
.toList();
return TimeCardAdapter.fromPrimitives(
id: app.id,
shiftTitle: app.shift.title,
clientName: app.shift.order.business.businessName,
date: shiftDate,
startTime: startTime,
endTime: endTime,
totalHours: hours,
hourlyRate: rate,
totalPay: pay,
status: app.status.stringValue,
location: app.shift.location,
);
})
.toList();
});
}
String? _formatTime(fdc.Timestamp? timestamp) {

View File

@@ -27,6 +27,7 @@ class _TimeCardPageState extends State<TimeCardPage> {
@override
Widget build(BuildContext context) {
final t = Translations.of(context);
return BlocProvider.value(
value: _bloc,
child: Scaffold(
@@ -49,12 +50,33 @@ class _TimeCardPageState extends State<TimeCardPage> {
child: Container(color: UiColors.border, height: 1.0),
),
),
body: BlocBuilder<TimeCardBloc, TimeCardState>(
body: BlocConsumer<TimeCardBloc, TimeCardState>(
listener: (context, state) {
if (state is TimeCardError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
translateErrorKey(state.message),
),
behavior: SnackBarBehavior.floating,
),
);
}
},
builder: (context, state) {
if (state is TimeCardLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is TimeCardError) {
return Center(child: Text('Error: ${state.message}'));
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
translateErrorKey(state.message),
textAlign: TextAlign.center,
style: UiTypography.body1m.copyWith(color: UiColors.textSecondary),
),
),
);
} else if (state is TimeCardLoaded) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(