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

@@ -180,9 +180,16 @@ class _BillingViewState extends State<BillingView> {
}
if (state.status == BillingStatus.failure) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(translateErrorKey(state.errorMessage!))),
);
});
return Center(
child: Text(
state.errorMessage ?? 'An error occurred',
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'An error occurred',
style: UiTypography.body1r.textError,
),
);

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,
),
),
],
);

View File

@@ -9,7 +9,9 @@ import '../../domain/repositories/availability_repository.dart';
/// Note: The backend schema supports recurring availablity (Weekly/DayOfWeek),
/// not specific date availability. Therefore, updating availability for a specific
/// date will update the availability for that Day of Week globally (Recurring).
class AvailabilityRepositoryImpl implements AvailabilityRepository {
class AvailabilityRepositoryImpl
with dc.DataErrorHandler
implements AvailabilityRepository {
final dc.ExampleConnector _dataConnect;
final firebase.FirebaseAuth _firebaseAuth;
@@ -21,84 +23,89 @@ class AvailabilityRepositoryImpl implements AvailabilityRepository {
Future<String> _getStaffId() async {
final firebase.User? user = _firebaseAuth.currentUser;
if (user == null) throw Exception('User not authenticated');
if (user == null) {
throw NotAuthenticatedException(
technicalMessage: 'User not authenticated');
}
final 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<DayAvailability>> getAvailability(DateTime start, DateTime end) async {
final String staffId = await _getStaffId();
// 1. Fetch Weekly recurring availability
final QueryResult<dc.ListStaffAvailabilitiesByStaffIdData, dc.ListStaffAvailabilitiesByStaffIdVariables> result =
await _dataConnect.listStaffAvailabilitiesByStaffId(staffId: staffId).limit(100).execute();
final List<dc.ListStaffAvailabilitiesByStaffIdStaffAvailabilities> items = result.data.staffAvailabilities;
// 2. Map to lookup: DayOfWeek -> Map<SlotName, IsAvailable>
final Map<dc.DayOfWeek, Map<dc.AvailabilitySlot, bool>> weeklyMap = {};
for (final item in items) {
dc.DayOfWeek day;
try {
day = dc.DayOfWeek.values.byName(item.day.stringValue);
} catch (_) {
continue;
}
dc.AvailabilitySlot slot;
try {
slot = dc.AvailabilitySlot.values.byName(item.slot.stringValue);
} catch (_) {
continue;
}
bool isAvailable = false;
try {
final dc.AvailabilityStatus status = dc.AvailabilityStatus.values.byName(item.status.stringValue);
isAvailable = _statusToBool(status);
} catch (_) {
isAvailable = false;
}
if (!weeklyMap.containsKey(day)) {
weeklyMap[day] = {};
}
weeklyMap[day]![slot] = isAvailable;
}
// 3. Generate DayAvailability for requested range
final List<DayAvailability> days = [];
final int dayCount = end.difference(start).inDays;
for (int i = 0; i <= dayCount; i++) {
final DateTime date = start.add(Duration(days: i));
final dc.DayOfWeek dow = _toBackendDay(date.weekday);
final Map<dc.AvailabilitySlot, bool> daySlots = weeklyMap[dow] ?? {};
return executeProtected(() async {
final String staffId = await _getStaffId();
// We define 3 standard slots for every day
final List<AvailabilitySlot> slots = [
_createSlot(date, dow, daySlots, dc.AvailabilitySlot.MORNING),
_createSlot(date, dow, daySlots, dc.AvailabilitySlot.AFTERNOON),
_createSlot(date, dow, daySlots, dc.AvailabilitySlot.EVENING),
];
// 1. Fetch Weekly recurring availability
final QueryResult<dc.ListStaffAvailabilitiesByStaffIdData, dc.ListStaffAvailabilitiesByStaffIdVariables> result =
await _dataConnect.listStaffAvailabilitiesByStaffId(staffId: staffId).limit(100).execute();
final bool isDayAvailable = slots.any((s) => s.isAvailable);
final List<dc.ListStaffAvailabilitiesByStaffIdStaffAvailabilities> items = result.data.staffAvailabilities;
days.add(DayAvailability(
date: date,
isAvailable: isDayAvailable,
slots: slots,
));
}
return days;
// 2. Map to lookup: DayOfWeek -> Map<SlotName, IsAvailable>
final Map<dc.DayOfWeek, Map<dc.AvailabilitySlot, bool>> weeklyMap = {};
for (final item in items) {
dc.DayOfWeek day;
try {
day = dc.DayOfWeek.values.byName(item.day.stringValue);
} catch (_) {
continue;
}
dc.AvailabilitySlot slot;
try {
slot = dc.AvailabilitySlot.values.byName(item.slot.stringValue);
} catch (_) {
continue;
}
bool isAvailable = false;
try {
final dc.AvailabilityStatus status = dc.AvailabilityStatus.values.byName(item.status.stringValue);
isAvailable = _statusToBool(status);
} catch (_) {
isAvailable = false;
}
if (!weeklyMap.containsKey(day)) {
weeklyMap[day] = {};
}
weeklyMap[day]![slot] = isAvailable;
}
// 3. Generate DayAvailability for requested range
final List<DayAvailability> days = [];
final int dayCount = end.difference(start).inDays;
for (int i = 0; i <= dayCount; i++) {
final DateTime date = start.add(Duration(days: i));
final dc.DayOfWeek dow = _toBackendDay(date.weekday);
final Map<dc.AvailabilitySlot, bool> daySlots = weeklyMap[dow] ?? {};
// We define 3 standard slots for every day
final List<AvailabilitySlot> slots = [
_createSlot(date, dow, daySlots, dc.AvailabilitySlot.MORNING),
_createSlot(date, dow, daySlots, dc.AvailabilitySlot.AFTERNOON),
_createSlot(date, dow, daySlots, dc.AvailabilitySlot.EVENING),
];
final bool isDayAvailable = slots.any((s) => s.isAvailable);
days.add(DayAvailability(
date: date,
isAvailable: isDayAvailable,
slots: slots,
));
}
return days;
});
}
AvailabilitySlot _createSlot(
@@ -113,75 +120,79 @@ class AvailabilityRepositoryImpl implements AvailabilityRepository {
@override
Future<DayAvailability> updateDayAvailability(DayAvailability availability) async {
final String staffId = await _getStaffId();
final dc.DayOfWeek dow = _toBackendDay(availability.date.weekday);
return executeProtected(() async {
final String staffId = await _getStaffId();
final dc.DayOfWeek dow = _toBackendDay(availability.date.weekday);
// Update each slot in the backend.
// This updates the recurring rule for this DayOfWeek.
for (final AvailabilitySlot slot in availability.slots) {
final dc.AvailabilitySlot slotEnum = _toBackendSlot(slot.id);
final dc.AvailabilityStatus status = _boolToStatus(slot.isAvailable);
// Update each slot in the backend.
// This updates the recurring rule for this DayOfWeek.
for (final AvailabilitySlot slot in availability.slots) {
final dc.AvailabilitySlot slotEnum = _toBackendSlot(slot.id);
final dc.AvailabilityStatus status = _boolToStatus(slot.isAvailable);
await _upsertSlot(staffId, dow, slotEnum, status);
}
await _upsertSlot(staffId, dow, slotEnum, status);
}
return availability;
return availability;
});
}
@override
Future<List<DayAvailability>> applyQuickSet(DateTime start, DateTime end, String type) async {
final String staffId = await _getStaffId();
// QuickSet updates the Recurring schedule for all days involved.
// However, if the user selects a range that covers e.g. Mon-Fri, we update Mon-Fri.
final int dayCount = end.difference(start).inDays;
final Set<dc.DayOfWeek> processedDays = {};
final List<DayAvailability> resultDays = [];
return executeProtected(() async {
final String staffId = await _getStaffId();
// QuickSet updates the Recurring schedule for all days involved.
// However, if the user selects a range that covers e.g. Mon-Fri, we update Mon-Fri.
final int dayCount = end.difference(start).inDays;
final Set<dc.DayOfWeek> processedDays = {};
final List<DayAvailability> resultDays = [];
for (int i = 0; i <= dayCount; i++) {
final DateTime date = start.add(Duration(days: i));
final dc.DayOfWeek dow = _toBackendDay(date.weekday);
// Logic to determine if enabled based on type
bool enableDay = false;
if (type == 'all') enableDay = true;
else if (type == 'clear') enableDay = false;
else if (type == 'weekdays') {
enableDay = (dow != dc.DayOfWeek.SATURDAY && dow != dc.DayOfWeek.SUNDAY);
} else if (type == 'weekends') {
enableDay = (dow == dc.DayOfWeek.SATURDAY || dow == dc.DayOfWeek.SUNDAY);
}
for (int i = 0; i <= dayCount; i++) {
final DateTime date = start.add(Duration(days: i));
final dc.DayOfWeek dow = _toBackendDay(date.weekday);
// Logic to determine if enabled based on type
bool enableDay = false;
if (type == 'all') enableDay = true;
else if (type == 'clear') enableDay = false;
else if (type == 'weekdays') {
enableDay = (dow != dc.DayOfWeek.SATURDAY && dow != dc.DayOfWeek.SUNDAY);
} else if (type == 'weekends') {
enableDay = (dow == dc.DayOfWeek.SATURDAY || dow == dc.DayOfWeek.SUNDAY);
}
// Only update backend once per DayOfWeek (since it's recurring)
// to avoid redundant calls if range > 1 week.
if (!processedDays.contains(dow)) {
processedDays.add(dow);
final dc.AvailabilityStatus status = _boolToStatus(enableDay);
await Future.wait([
_upsertSlot(staffId, dow, dc.AvailabilitySlot.MORNING, status),
_upsertSlot(staffId, dow, dc.AvailabilitySlot.AFTERNOON, status),
_upsertSlot(staffId, dow, dc.AvailabilitySlot.EVENING, status),
]);
}
// Prepare return object
final slots = [
AvailabilityAdapter.fromPrimitive('MORNING', isAvailable: enableDay),
AvailabilityAdapter.fromPrimitive('AFTERNOON', isAvailable: enableDay),
AvailabilityAdapter.fromPrimitive('EVENING', isAvailable: enableDay),
];
// Only update backend once per DayOfWeek (since it's recurring)
// to avoid redundant calls if range > 1 week.
if (!processedDays.contains(dow)) {
processedDays.add(dow);
final dc.AvailabilityStatus status = _boolToStatus(enableDay);
await Future.wait([
_upsertSlot(staffId, dow, dc.AvailabilitySlot.MORNING, status),
_upsertSlot(staffId, dow, dc.AvailabilitySlot.AFTERNOON, status),
_upsertSlot(staffId, dow, dc.AvailabilitySlot.EVENING, status),
]);
}
// Prepare return object
final slots = [
AvailabilityAdapter.fromPrimitive('MORNING', isAvailable: enableDay),
AvailabilityAdapter.fromPrimitive('AFTERNOON', isAvailable: enableDay),
AvailabilityAdapter.fromPrimitive('EVENING', isAvailable: enableDay),
];
resultDays.add(DayAvailability(
date: date,
isAvailable: enableDay,
slots: slots,
));
}
resultDays.add(DayAvailability(
date: date,
isAvailable: enableDay,
slots: slots,
));
}
return resultDays;
return resultDays;
});
}
Future<void> _upsertSlot(String staffId, dc.DayOfWeek day, dc.AvailabilitySlot slot, dc.AvailabilityStatus status) async {

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';
@@ -41,6 +42,7 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
@override
Widget build(BuildContext context) {
Translations.of(context);
return BlocProvider.value(
value: _bloc,
child: Scaffold(
@@ -61,6 +63,14 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
behavior: SnackBarBehavior.floating,
),
);
} else if (state is AvailabilityError) {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(translateErrorKey(state.message)),
behavior: SnackBarBehavior.floating,
),
);
}
},
child: BlocBuilder<AvailabilityBloc, AvailabilityState>(
@@ -105,7 +115,24 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
],
);
} else if (state is AvailabilityError) {
return Center(child: Text('Error: ${state.message}'));
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
translateErrorKey(state.message),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
color: AppColors.krowMuted,
),
),
],
),
),
);
}
return const SizedBox.shrink();
},

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';
@@ -41,7 +42,9 @@ class _ClockInPageState extends State<ClockInPage> {
state.errorMessage != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(state.errorMessage!)));
).showSnackBar(
SnackBar(content: Text(translateErrorKey(state.errorMessage!))),
);
}
},
builder: (BuildContext context, ClockInState state) {

View File

@@ -32,6 +32,7 @@ class WorkerHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = Translations.of(context);
final i18n = t.staff.home;
final bannersI18n = i18n.banners;
final quickI18n = i18n.quick_actions;

View File

@@ -6,7 +6,9 @@ import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
import 'package:krow_core/core.dart';
import '../../domain/repositories/payments_repository.dart';
class PaymentsRepositoryImpl implements PaymentsRepository {
class PaymentsRepositoryImpl
with dc.DataErrorHandler
implements PaymentsRepository {
PaymentsRepositoryImpl() : _dataConnect = dc.ExampleConnector.instance;
final dc.ExampleConnector _dataConnect;
@@ -27,17 +29,18 @@ class PaymentsRepositoryImpl implements PaymentsRepository {
// 3. Fetch from Data Connect using Firebase UID
final firebase_auth.User? user = _auth.currentUser;
if (user == null) {
throw Exception('User is not authenticated');
throw const NotAuthenticatedException(
technicalMessage: 'User is not authenticated',
);
}
try {
final QueryResult<dc.GetStaffByUserIdData, dc.GetStaffByUserIdVariables> response = await _dataConnect.getStaffByUserId(userId: user.uid).execute();
if (response.data.staffs.isNotEmpty) {
_cachedStaffId = response.data.staffs.first.id;
return _cachedStaffId!;
}
} catch (e) {
// Log or handle error
// This call is protected by parent execution context if called within executeProtected,
// otherwise we might need to wrap it if called standalone.
// For now we assume it's called from public methods which are protected.
final QueryResult<dc.GetStaffByUserIdData, dc.GetStaffByUserIdVariables> response = await _dataConnect.getStaffByUserId(userId: user.uid).execute();
if (response.data.staffs.isNotEmpty) {
_cachedStaffId = response.data.staffs.first.id;
return _cachedStaffId!;
}
// 4. Fallback
@@ -78,55 +81,57 @@ class PaymentsRepositoryImpl implements PaymentsRepository {
@override
Future<PaymentSummary> getPaymentSummary() async {
final String currentStaffId = await _getStaffId();
return executeProtected(() async {
final String currentStaffId = await _getStaffId();
// Fetch recent payments with a limit
// Note: limit is chained on the query builder
final QueryResult<dc.ListRecentPaymentsByStaffIdData, dc.ListRecentPaymentsByStaffIdVariables> result =
await _dataConnect.listRecentPaymentsByStaffId(
staffId: currentStaffId,
).limit(100).execute();
// Fetch recent payments with a limit
// Note: limit is chained on the query builder
final QueryResult<dc.ListRecentPaymentsByStaffIdData, dc.ListRecentPaymentsByStaffIdVariables> result =
await _dataConnect.listRecentPaymentsByStaffId(
staffId: currentStaffId,
).limit(100).execute();
final List<dc.ListRecentPaymentsByStaffIdRecentPayments> payments = result.data.recentPayments;
final List<dc.ListRecentPaymentsByStaffIdRecentPayments> payments = result.data.recentPayments;
double weekly = 0;
double monthly = 0;
double pending = 0;
double total = 0;
double weekly = 0;
double monthly = 0;
double pending = 0;
double total = 0;
final DateTime now = DateTime.now();
final DateTime startOfWeek = now.subtract(const Duration(days: 7));
final DateTime startOfMonth = DateTime(now.year, now.month, 1);
final DateTime now = DateTime.now();
final DateTime startOfWeek = now.subtract(const Duration(days: 7));
final DateTime startOfMonth = DateTime(now.year, now.month, 1);
for (final dc.ListRecentPaymentsByStaffIdRecentPayments p in payments) {
final DateTime? date = _toDateTime(p.invoice.issueDate) ?? _toDateTime(p.createdAt);
final double amount = p.invoice.amount;
final String? status = p.status?.stringValue;
for (final dc.ListRecentPaymentsByStaffIdRecentPayments p in payments) {
final DateTime? date = _toDateTime(p.invoice.issueDate) ?? _toDateTime(p.createdAt);
final double amount = p.invoice.amount;
final String? status = p.status?.stringValue;
if (status == 'PENDING') {
pending += amount;
} else if (status == 'PAID') {
total += amount;
if (date != null) {
if (date.isAfter(startOfWeek)) weekly += amount;
if (date.isAfter(startOfMonth)) monthly += amount;
}
}
}
if (status == 'PENDING') {
pending += amount;
} else if (status == 'PAID') {
total += amount;
if (date != null) {
if (date.isAfter(startOfWeek)) weekly += amount;
if (date.isAfter(startOfMonth)) monthly += amount;
}
}
}
return PaymentSummary(
weeklyEarnings: weekly,
monthlyEarnings: monthly,
pendingEarnings: pending,
totalEarnings: total,
);
return PaymentSummary(
weeklyEarnings: weekly,
monthlyEarnings: monthly,
pendingEarnings: pending,
totalEarnings: total,
);
});
}
@override
Future<List<StaffPayment>> getPaymentHistory(String period) async {
final String currentStaffId = await _getStaffId();
try {
return executeProtected(() async {
final String currentStaffId = await _getStaffId();
final QueryResult<dc.ListRecentPaymentsByStaffIdData, dc.ListRecentPaymentsByStaffIdVariables> response =
await _dataConnect
.listRecentPaymentsByStaffId(staffId: currentStaffId)
@@ -142,9 +147,7 @@ class PaymentsRepositoryImpl implements PaymentsRepository {
paidAt: _toDateTime(payment.invoice.issueDate),
);
}).toList();
} catch (e) {
return <StaffPayment>[];
}
});
}
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter_modular/flutter_modular.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:core_localization/core_localization.dart';
import '../blocs/payments/payments_bloc.dart';
import '../blocs/payments/payments_event.dart';
import '../blocs/payments/payments_state.dart';
@@ -30,16 +31,37 @@ class _PaymentsPageState extends State<PaymentsPage> {
@override
Widget build(BuildContext context) {
Translations.of(context);
return BlocProvider<PaymentsBloc>.value(
value: _bloc,
child: Scaffold(
backgroundColor: const Color(0xFFF8FAFC),
body: BlocBuilder<PaymentsBloc, PaymentsState>(
body: BlocConsumer<PaymentsBloc, PaymentsState>(
listener: (context, state) {
if (state is PaymentsError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(translateErrorKey(state.message)),
behavior: SnackBarBehavior.floating,
),
);
}
},
builder: (BuildContext context, PaymentsState state) {
if (state is PaymentsLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is PaymentsError) {
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: const TextStyle(color: Color(0xFF64748B)), // TextSecondary
),
),
);
} else if (state is PaymentsLoaded) {
return _buildContent(context, state);
}

View File

@@ -14,7 +14,9 @@ import '../../domain/repositories/profile_repository.dart';
///
/// Currently uses [ProfileRepositoryMock] from data_connect.
/// When Firebase Data Connect is ready, this will be swapped with a real implementation.
class ProfileRepositoryImpl implements ProfileRepositoryInterface {
class ProfileRepositoryImpl
with DataErrorHandler
implements ProfileRepositoryInterface {
/// Creates a [ProfileRepositoryImpl].
///
/// Requires a [ExampleConnector] from the data_connect package and [FirebaseAuth].
@@ -31,37 +33,39 @@ class ProfileRepositoryImpl implements ProfileRepositoryInterface {
@override
Future<Staff> getStaffProfile() async {
final user = firebaseAuth.currentUser;
if (user == null) {
throw Exception('User not authenticated');
}
return executeProtected(() async {
final user = firebaseAuth.currentUser;
if (user == null) {
throw NotAuthenticatedException(
technicalMessage: 'User not authenticated');
}
final response = await connector.getStaffByUserId(userId: user.uid).execute();
if (response.data.staffs.isEmpty) {
// TODO: Handle user not found properly with domain exception
throw Exception('Staff not found');
}
final response = await connector.getStaffByUserId(userId: user.uid).execute();
if (response.data.staffs.isEmpty) {
throw const ServerException(technicalMessage: 'Staff not found');
}
final GetStaffByUserIdStaffs rawStaff = response.data.staffs.first;
final GetStaffByUserIdStaffs rawStaff = response.data.staffs.first;
// Map the raw data connect object to the Domain Entity
return Staff(
id: rawStaff.id,
authProviderId: rawStaff.userId,
name: rawStaff.fullName,
email: rawStaff.email ?? '',
phone: rawStaff.phone,
avatar: rawStaff.photoUrl,
status: StaffStatus.active,
address: rawStaff.addres,
totalShifts: rawStaff.totalShifts,
averageRating: rawStaff.averageRating,
onTimeRate: rawStaff.onTimeRate,
noShowCount: rawStaff.noShowCount,
cancellationCount: rawStaff.cancellationCount,
reliabilityScore: rawStaff.reliabilityScore,
);
// Map the raw data connect object to the Domain Entity
return Staff(
id: rawStaff.id,
authProviderId: rawStaff.userId,
name: rawStaff.fullName,
email: rawStaff.email ?? '',
phone: rawStaff.phone,
avatar: rawStaff.photoUrl,
status: StaffStatus.active,
address: rawStaff.addres,
totalShifts: rawStaff.totalShifts,
averageRating: rawStaff.averageRating,
onTimeRate: rawStaff.onTimeRate,
noShowCount: rawStaff.noShowCount,
cancellationCount: rawStaff.cancellationCount,
reliabilityScore: rawStaff.reliabilityScore,
);
});
}
@override

View File

@@ -50,7 +50,7 @@ class StaffProfilePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final TranslationsStaffProfileEn i18n = t.staff.profile;
final i18n = Translations.of(context).staff.profile;
final ProfileCubit cubit = Modular.get<ProfileCubit>();
// Load profile data on first build
@@ -64,6 +64,14 @@ class StaffProfilePage extends StatelessWidget {
listener: (context, state) {
if (state.status == ProfileStatus.signedOut) {
Modular.to.toGetStarted();
} else if (state.status == ProfileStatus.error &&
state.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(translateErrorKey(state.errorMessage!)),
behavior: SnackBarBehavior.floating,
),
);
}
},
builder: (context, state) {
@@ -74,10 +82,16 @@ class StaffProfilePage extends StatelessWidget {
if (state.status == ProfileStatus.error) {
return Center(
child: Text(
state.errorMessage ?? 'An error occurred',
style: UiTypography.body1r.copyWith(
color: UiColors.destructive,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'An error occurred',
textAlign: TextAlign.center,
style: UiTypography.body1r.copyWith(
color: UiColors.textSecondary,
),
),
),
);

View File

@@ -20,29 +20,31 @@ class LanguageSelectorBottomSheet extends StatelessWidget {
color: UiColors.background,
borderRadius: BorderRadius.vertical(top: Radius.circular(UiConstants.radiusBase)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
t.settings.change_language,
style: UiTypography.headline4m,
textAlign: TextAlign.center,
),
SizedBox(height: UiConstants.space6),
_buildLanguageOption(
context,
label: 'English',
locale: AppLocale.en,
),
SizedBox(height: UiConstants.space4),
_buildLanguageOption(
context,
label: 'Español',
locale: AppLocale.es,
),
SizedBox(height: UiConstants.space6),
],
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
t.settings.change_language,
style: UiTypography.headline4m,
textAlign: TextAlign.center,
),
SizedBox(height: UiConstants.space6),
_buildLanguageOption(
context,
label: 'English',
locale: AppLocale.en,
),
SizedBox(height: UiConstants.space4),
_buildLanguageOption(
context,
label: 'Español',
locale: AppLocale.es,
),
SizedBox(height: UiConstants.space6),
],
),
),
);
}

View File

@@ -10,7 +10,9 @@ import '../../domain/repositories/certificates_repository.dart';
///
/// This class handles the communication with the backend via [ExampleConnector].
/// It maps raw generated data types to clean [domain.StaffDocument] entities.
class CertificatesRepositoryImpl implements CertificatesRepository {
class CertificatesRepositoryImpl
with DataErrorHandler
implements CertificatesRepository {
/// The generated Data Connect SDK client.
final ExampleConnector _dataConnect;
@@ -24,16 +26,17 @@ class CertificatesRepositoryImpl implements CertificatesRepository {
required ExampleConnector dataConnect,
required FirebaseAuth firebaseAuth,
}) : _dataConnect = dataConnect,
_firebaseAuth = firebaseAuth;
_firebaseAuth = firebaseAuth;
@override
Future<List<domain.StaffDocument>> getCertificates() async {
final User? currentUser = _firebaseAuth.currentUser;
if (currentUser == null) {
throw Exception('User not authenticated');
}
return executeProtected(() async {
final User? currentUser = _firebaseAuth.currentUser;
if (currentUser == null) {
throw domain.NotAuthenticatedException(
technicalMessage: 'User not authenticated');
}
try {
// Execute the query via DataConnect generated SDK
final QueryResult<ListStaffDocumentsByStaffIdData,
ListStaffDocumentsByStaffIdVariables> result =
@@ -46,10 +49,7 @@ class CertificatesRepositoryImpl implements CertificatesRepository {
.map((ListStaffDocumentsByStaffIdStaffDocuments doc) =>
_mapToDomain(doc))
.toList();
} catch (e) {
// In a real app, we would map specific exceptions to domain Failures here.
throw Exception('Failed to fetch certificates: $e');
}
});
}
/// Maps the Data Connect [ListStaffDocumentsByStaffIdStaffDocuments] to a domain [domain.StaffDocument].

View File

@@ -35,7 +35,19 @@ class CertificatesPage extends StatelessWidget {
if (state.status == CertificatesStatus.failure) {
return Scaffold(
body: Center(child: Text('Error: ${state.errorMessage}')),
appBar: AppBar(title: const Text('Certificates')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'Error loading certificates',
textAlign: TextAlign.center,
style: const TextStyle(color: UiColors.textSecondary),
),
),
),
);
}

View File

@@ -7,7 +7,9 @@ import 'package:krow_core/core.dart';
import '../../domain/repositories/documents_repository.dart';
/// Implementation of [DocumentsRepository] using Data Connect.
class DocumentsRepositoryImpl implements DocumentsRepository {
class DocumentsRepositoryImpl
with DataErrorHandler
implements DocumentsRepository {
final ExampleConnector _dataConnect;
final FirebaseAuth _firebaseAuth;
@@ -19,10 +21,12 @@ class DocumentsRepositoryImpl implements DocumentsRepository {
@override
Future<List<domain.StaffDocument>> getDocuments() async {
final User? currentUser = _firebaseAuth.currentUser;
if (currentUser == null) {
throw Exception('User not authenticated');
}
return executeProtected(() async {
final User? currentUser = _firebaseAuth.currentUser;
if (currentUser == null) {
throw domain.NotAuthenticatedException(
technicalMessage: 'User not authenticated');
}
/// MOCK IMPLEMENTATION
/// To be replaced with real data connect query when available
@@ -49,22 +53,7 @@ class DocumentsRepositoryImpl implements DocumentsRepository {
),
];
/*
try {
final QueryResult<ListStaffDocumentsByStaffIdData,
ListStaffDocumentsByStaffIdVariables> result =
await _dataConnect
.listStaffDocumentsByStaffId(staffId: currentUser.uid)
.execute();
return result.data.staffDocuments
.map((ListStaffDocumentsByStaffIdStaffDocuments doc) =>
_mapToDomain(doc))
.toList();
} catch (e) {
throw Exception('Failed to fetch documents: $e');
}
*/
});
}
domain.StaffDocument _mapToDomain(

View File

@@ -52,11 +52,17 @@ class DocumentsPage extends StatelessWidget {
}
if (state.status == DocumentsStatus.failure) {
return Center(
child: Text(
t.staff_documents.list.error(
message: state.errorMessage ?? 'Unknown',
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
state.errorMessage != null
? (state.errorMessage!.contains('errors.')
? translateErrorKey(state.errorMessage!)
: t.staff_documents.list.error(message: state.errorMessage!))
: t.staff_documents.list.error(message: 'Unknown'),
textAlign: TextAlign.center,
style: UiTypography.body1m.copyWith(color: UiColors.textSecondary),
),
style: UiTypography.body1m.copyWith(color: UiColors.textError),
),
);
}

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:firebase_auth/firebase_auth.dart' as auth;
import 'package:firebase_core/firebase_core.dart';
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';
@@ -8,7 +9,9 @@ import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/tax_forms_repository.dart';
import '../mappers/tax_form_mapper.dart';
class TaxFormsRepositoryImpl implements TaxFormsRepository {
class TaxFormsRepositoryImpl
with dc.DataErrorHandler
implements TaxFormsRepository {
TaxFormsRepositoryImpl({
required this.firebaseAuth,
required this.dataConnect,
@@ -21,46 +24,58 @@ class TaxFormsRepositoryImpl implements TaxFormsRepository {
String _getStaffId() {
final auth.User? user = firebaseAuth.currentUser;
if (user == null) {
throw Exception('User not authenticated');
throw const NotAuthenticatedException(
technicalMessage: 'Firebase User is null',
);
}
final String? staffId = dc.StaffSessionStore.instance.session?.staff?.id;
if (staffId == null || staffId.isEmpty) {
throw Exception('Staff profile is missing or session not initialized.');
throw const StaffProfileNotFoundException(
technicalMessage: 'Staff ID missing in SessionStore',
);
}
return staffId;
}
@override
Future<List<TaxForm>> getTaxForms() async {
final String staffId = _getStaffId();
final QueryResult<dc.GetTaxFormsByStaffIdData, dc.GetTaxFormsByStaffIdVariables>
result =
await dataConnect.getTaxFormsByStaffId(staffId: staffId).execute();
final List<TaxForm> forms = result.data.taxForms.map(TaxFormMapper.fromDataConnect).toList();
// Check if required forms exist, create if not.
final Set<TaxFormType> typesPresent = forms.map((TaxForm f) => f.type).toSet();
bool createdNew = false;
if (!typesPresent.contains(TaxFormType.i9)) {
await _createInitialForm(staffId, TaxFormType.i9);
createdNew = true;
}
if (!typesPresent.contains(TaxFormType.w4)) {
await _createInitialForm(staffId, TaxFormType.w4);
createdNew = true;
}
if (createdNew) {
return executeProtected(() async {
final String staffId = _getStaffId();
final QueryResult<dc.GetTaxFormsByStaffIdData, dc.GetTaxFormsByStaffIdVariables>
result2 =
await dataConnect.getTaxFormsByStaffId(staffId: staffId).execute();
return result2.data.taxForms.map(TaxFormMapper.fromDataConnect).toList();
}
result = await dataConnect
.getTaxFormsByStaffId(staffId: staffId)
.execute();
return forms;
final List<TaxForm> forms =
result.data.taxForms.map(TaxFormMapper.fromDataConnect).toList();
// Check if required forms exist, create if not.
final Set<TaxFormType> typesPresent =
forms.map((TaxForm f) => f.type).toSet();
bool createdNew = false;
if (!typesPresent.contains(TaxFormType.i9)) {
await _createInitialForm(staffId, TaxFormType.i9);
createdNew = true;
}
if (!typesPresent.contains(TaxFormType.w4)) {
await _createInitialForm(staffId, TaxFormType.w4);
createdNew = true;
}
if (createdNew) {
final QueryResult<
dc.GetTaxFormsByStaffIdData,
dc.GetTaxFormsByStaffIdVariables> result2 =
await dataConnect.getTaxFormsByStaffId(staffId: staffId).execute();
return result2.data.taxForms
.map(TaxFormMapper.fromDataConnect)
.toList();
}
return forms;
});
}
Future<void> _createInitialForm(String staffId, TaxFormType type) async {
@@ -80,45 +95,62 @@ class TaxFormsRepositoryImpl implements TaxFormsRepository {
@override
Future<void> updateI9Form(I9TaxForm form) async {
final Map<String, dynamic> data = form.formData;
final dc.UpdateTaxFormVariablesBuilder builder = dataConnect.updateTaxForm(id: form.id);
_mapCommonFields(builder, data);
_mapI9Fields(builder, data);
await builder.execute();
return executeProtected(() async {
final Map<String, dynamic> data = form.formData;
final dc.UpdateTaxFormVariablesBuilder builder =
dataConnect.updateTaxForm(id: form.id);
_mapCommonFields(builder, data);
_mapI9Fields(builder, data);
await builder.execute();
});
}
@override
Future<void> submitI9Form(I9TaxForm form) async {
final Map<String, dynamic> data = form.formData;
final dc.UpdateTaxFormVariablesBuilder builder = dataConnect.updateTaxForm(id: form.id);
_mapCommonFields(builder, data);
_mapI9Fields(builder, data);
await builder.status(dc.TaxFormStatus.SUBMITTED).execute();
return executeProtected(() async {
final Map<String, dynamic> data = form.formData;
final dc.UpdateTaxFormVariablesBuilder builder =
dataConnect.updateTaxForm(id: form.id);
_mapCommonFields(builder, data);
_mapI9Fields(builder, data);
await builder.status(dc.TaxFormStatus.SUBMITTED).execute();
});
}
@override
Future<void> updateW4Form(W4TaxForm form) async {
final Map<String, dynamic> data = form.formData;
final dc.UpdateTaxFormVariablesBuilder builder = dataConnect.updateTaxForm(id: form.id);
_mapCommonFields(builder, data);
_mapW4Fields(builder, data);
await builder.execute();
return executeProtected(() async {
final Map<String, dynamic> data = form.formData;
final dc.UpdateTaxFormVariablesBuilder builder =
dataConnect.updateTaxForm(id: form.id);
_mapCommonFields(builder, data);
_mapW4Fields(builder, data);
await builder.execute();
});
}
@override
Future<void> submitW4Form(W4TaxForm form) async {
final Map<String, dynamic> data = form.formData;
final dc.UpdateTaxFormVariablesBuilder builder = dataConnect.updateTaxForm(id: form.id);
_mapCommonFields(builder, data);
_mapW4Fields(builder, data);
await builder.status(dc.TaxFormStatus.SUBMITTED).execute();
return executeProtected(() async {
final Map<String, dynamic> data = form.formData;
final dc.UpdateTaxFormVariablesBuilder builder =
dataConnect.updateTaxForm(id: form.id);
_mapCommonFields(builder, data);
_mapW4Fields(builder, data);
await builder.status(dc.TaxFormStatus.SUBMITTED).execute();
});
}
void _mapCommonFields(dc.UpdateTaxFormVariablesBuilder builder, Map<String, dynamic> data) {
if (data.containsKey('firstName')) builder.firstName(data['firstName'] as String?);
if (data.containsKey('lastName')) builder.lastName(data['lastName'] as String?);
if (data.containsKey('middleInitial')) builder.mInitial(data['middleInitial'] as String?);
if (data.containsKey('otherLastNames')) builder.oLastName(data['otherLastNames'] as String?);
void _mapCommonFields(
dc.UpdateTaxFormVariablesBuilder builder, Map<String, dynamic> data) {
if (data.containsKey('firstName'))
builder.firstName(data['firstName'] as String?);
if (data.containsKey('lastName'))
builder.lastName(data['lastName'] as String?);
if (data.containsKey('middleInitial'))
builder.mInitial(data['middleInitial'] as String?);
if (data.containsKey('otherLastNames'))
builder.oLastName(data['otherLastNames'] as String?);
if (data.containsKey('dob')) {
final String dob = data['dob'] as String;
// Handle both ISO string and MM/dd/yyyy manual entry
@@ -131,8 +163,8 @@ class TaxFormsRepositoryImpl implements TaxFormsRepository {
final List<String> parts = dob.split('/');
if (parts.length == 3) {
date = DateTime(
int.parse(parts[2]),
int.parse(parts[0]),
int.parse(parts[2]),
int.parse(parts[0]),
int.parse(parts[1]),
);
}
@@ -145,70 +177,90 @@ class TaxFormsRepositoryImpl implements TaxFormsRepository {
}
}
if (data.containsKey('ssn') && data['ssn']?.toString().isNotEmpty == true) {
builder.socialSN(int.tryParse(data['ssn'].toString().replaceAll(RegExp(r'\D'), '')) ?? 0);
builder.socialSN(
int.tryParse(data['ssn'].toString().replaceAll(RegExp(r'\D'), '')) ??
0);
}
if (data.containsKey('email')) builder.email(data['email'] as String?);
if (data.containsKey('phone')) builder.phone(data['phone'] as String?);
if (data.containsKey('address')) builder.address(data['address'] as String?);
if (data.containsKey('aptNumber')) builder.apt(data['aptNumber'] as String?);
if (data.containsKey('address'))
builder.address(data['address'] as String?);
if (data.containsKey('aptNumber'))
builder.apt(data['aptNumber'] as String?);
if (data.containsKey('city')) builder.city(data['city'] as String?);
if (data.containsKey('state')) builder.state(data['state'] as String?);
if (data.containsKey('zipCode')) builder.zipCode(data['zipCode'] as String?);
if (data.containsKey('zipCode'))
builder.zipCode(data['zipCode'] as String?);
}
void _mapI9Fields(dc.UpdateTaxFormVariablesBuilder builder, Map<String, dynamic> data) {
void _mapI9Fields(
dc.UpdateTaxFormVariablesBuilder builder, Map<String, dynamic> data) {
if (data.containsKey('citizenshipStatus')) {
final String status = data['citizenshipStatus'] as String;
// Map string to enum if possible, or handle otherwise.
// Generated enum: CITIZEN, NONCITIZEN_NATIONAL, PERMANENT_RESIDENT, ALIEN_AUTHORIZED
try {
builder.citizen(dc.CitizenshipStatus.values.byName(status.toUpperCase()));
builder.citizen(
dc.CitizenshipStatus.values.byName(status.toUpperCase()));
} catch (_) {}
}
if (data.containsKey('uscisNumber')) builder.uscis(data['uscisNumber'] as String?);
if (data.containsKey('passportNumber')) builder.passportNumber(data['passportNumber'] as String?);
if (data.containsKey('countryIssuance')) builder.countryIssue(data['countryIssuance'] as String?);
if (data.containsKey('preparerUsed')) builder.prepartorOrTranslator(data['preparerUsed'] as bool?);
if (data.containsKey('signature')) builder.signature(data['signature'] as String?);
if (data.containsKey('uscisNumber'))
builder.uscis(data['uscisNumber'] as String?);
if (data.containsKey('passportNumber'))
builder.passportNumber(data['passportNumber'] as String?);
if (data.containsKey('countryIssuance'))
builder.countryIssue(data['countryIssuance'] as String?);
if (data.containsKey('preparerUsed'))
builder.prepartorOrTranslator(data['preparerUsed'] as bool?);
if (data.containsKey('signature'))
builder.signature(data['signature'] as String?);
// Note: admissionNumber not in builder based on file read
}
void _mapW4Fields(dc.UpdateTaxFormVariablesBuilder builder, Map<String, dynamic> data) {
void _mapW4Fields(
dc.UpdateTaxFormVariablesBuilder builder, Map<String, dynamic> data) {
if (data.containsKey('cityStateZip')) {
final String csz = data['cityStateZip'] as String;
// Extremely basic split: City, State Zip
final List<String> parts = csz.split(',');
if (parts.length >= 2) {
builder.city(parts[0].trim());
final String stateZip = parts[1].trim();
final List<String> szParts = stateZip.split(' ');
if (szParts.isNotEmpty) builder.state(szParts[0]);
if (szParts.length > 1) builder.zipCode(szParts.last);
}
final String csz = data['cityStateZip'] as String;
// Extremely basic split: City, State Zip
final List<String> parts = csz.split(',');
if (parts.length >= 2) {
builder.city(parts[0].trim());
final String stateZip = parts[1].trim();
final List<String> szParts = stateZip.split(' ');
if (szParts.isNotEmpty) builder.state(szParts[0]);
if (szParts.length > 1) builder.zipCode(szParts.last);
}
}
if (data.containsKey('filingStatus')) {
// MARITIAL_STATUS_SINGLE, MARITIAL_STATUS_MARRIED, MARITIAL_STATUS_HEAD
try {
final String status = data['filingStatus'] as String;
// Simple mapping assumptions:
if (status.contains('single')) builder.marital(dc.MaritalStatus.SINGLE);
else if (status.contains('married')) builder.marital(dc.MaritalStatus.MARRIED);
else if (status.contains('head')) builder.marital(dc.MaritalStatus.HEAD);
} catch (_) {}
try {
final String status = data['filingStatus'] as String;
// Simple mapping assumptions:
if (status.contains('single')) builder.marital(dc.MaritalStatus.SINGLE);
else if (status.contains('married'))
builder.marital(dc.MaritalStatus.MARRIED);
else if (status.contains('head'))
builder.marital(dc.MaritalStatus.HEAD);
} catch (_) {}
}
if (data.containsKey('multipleJobs')) builder.multipleJob(data['multipleJobs'] as bool?);
if (data.containsKey('qualifyingChildren')) builder.childrens(data['qualifyingChildren'] as int?);
if (data.containsKey('otherDependents')) builder.otherDeps(data['otherDependents'] as int?);
if (data.containsKey('multipleJobs'))
builder.multipleJob(data['multipleJobs'] as bool?);
if (data.containsKey('qualifyingChildren'))
builder.childrens(data['qualifyingChildren'] as int?);
if (data.containsKey('otherDependents'))
builder.otherDeps(data['otherDependents'] as int?);
if (data.containsKey('otherIncome')) {
builder.otherInconme(double.tryParse(data['otherIncome'].toString()));
builder.otherInconme(double.tryParse(data['otherIncome'].toString()));
}
if (data.containsKey('deductions')) {
builder.deductions(double.tryParse(data['deductions'].toString()));
builder.deductions(double.tryParse(data['deductions'].toString()));
}
if (data.containsKey('extraWithholding')) {
builder.extraWithholding(double.tryParse(data['extraWithholding'].toString()));
builder.extraWithholding(
double.tryParse(data['extraWithholding'].toString()));
}
if (data.containsKey('signature')) builder.signature(data['signature'] as String?);
if (data.containsKey('signature'))
builder.signature(data['signature'] as String?);
}
}

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';
@@ -62,7 +63,15 @@ class TaxFormsPage extends StatelessWidget {
if (state.status == TaxFormsStatus.failure) {
return Center(
child: Text(state.errorMessage ?? 'Error loading forms'),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
child: Text(
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'Error loading forms',
textAlign: TextAlign.center,
),
),
);
}

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(

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),
),
);

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:design_system/design_system.dart';
import 'package:core_localization/core_localization.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/shifts/shifts_bloc.dart';
import '../widgets/tabs/my_shifts_tab.dart';
@@ -66,9 +67,20 @@ class _ShiftsPageState extends State<ShiftsPage> {
@override
Widget build(BuildContext context) {
final t = Translations.of(context);
return BlocProvider.value(
value: _bloc,
child: BlocBuilder<ShiftsBloc, ShiftsState>(
child: BlocConsumer<ShiftsBloc, ShiftsState>(
listener: (context, state) {
if (state is ShiftsError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(translateErrorKey(state.message)),
behavior: SnackBarBehavior.floating,
),
);
}
},
builder: (context, state) {
final bool baseLoaded = state is ShiftsLoaded;
final List<Shift> myShifts = (state is ShiftsLoaded)
@@ -123,9 +135,9 @@ class _ShiftsPageState extends State<ShiftsPage> {
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 16,
children: [
const Text(
"Shifts",
style: TextStyle(
Text(
t.staff_shifts.title,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
@@ -137,7 +149,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
children: [
_buildTab(
"myshifts",
"My Shifts",
t.staff_shifts.tabs.my_shifts,
UiIcons.calendar,
myShifts.length,
showCount: myShiftsLoaded,
@@ -146,7 +158,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
const SizedBox(width: 8),
_buildTab(
"find",
"Find Shifts",
t.staff_shifts.tabs.find_work,
UiIcons.search,
availableJobs
.length, // Passed unfiltered count as badge? Or logic inside? Pass availableJobs.
@@ -156,7 +168,7 @@ class _ShiftsPageState extends State<ShiftsPage> {
const SizedBox(width: 8),
_buildTab(
"history",
"History",
t.staff_shifts.tabs.history,
UiIcons.clock,
historyShifts.length,
showCount: historyLoaded,
@@ -172,7 +184,26 @@ class _ShiftsPageState extends State<ShiftsPage> {
Expanded(
child: state is ShiftsLoading
? const Center(child: CircularProgressIndicator())
: _buildTabContent(
: state is ShiftsError
? Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
translateErrorKey(state.message),
style: const TextStyle(
fontSize: 16,
color: Color(0xFF64748B),
),
textAlign: TextAlign.center,
),
],
),
),
)
: _buildTabContent(
myShifts,
pendingAssignments,
cancelledShifts,

View File

@@ -36,6 +36,7 @@ class StaffMainBottomBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = Translations.of(context);
// Staff App colors from design system
// Using primary (Blue) for active as per prototype
const Color activeColor = UiColors.primary;