From 4e1a41ebff8e7d14f1600512a2d2911373d73b58 Mon Sep 17 00:00:00 2001 From: suriya Date: Sat, 14 Feb 2026 16:26:10 +0530 Subject: [PATCH 01/53] Fix build errors: localization syntax, key paths, and ViewOrderCard widget --- .../lib/src/l10n/en.i18n.json | 72 ++++++- .../lib/src/l10n/es.i18n.json | 72 ++++++- .../auth_repository_impl.dart | 38 ++-- .../pages/client_get_started_page.dart | 188 ++++++++++-------- .../coverage_repository_impl.dart | 10 +- .../one_time_order_success_view.dart | 2 + .../widgets/rapid_order/rapid_order_view.dart | 6 + .../presentation/widgets/coverage_widget.dart | 13 +- .../widgets/shift_order_form_sheet.dart | 25 ++- .../presentation/widgets/spending_widget.dart | 8 +- .../presentation/pages/client_hubs_page.dart | 14 +- .../presentation/widgets/view_order_card.dart | 36 ++-- .../auth_repository_impl.dart | 23 ++- .../src/presentation/blocs/auth_event.dart | 14 +- .../pages/phone_verification_page.dart | 8 +- .../phone_input/phone_input_form_field.dart | 2 +- .../src/presentation/widgets/shift_card.dart | 21 +- .../presentation/pages/certificates_page.dart | 10 +- .../emergency_contact_save_button.dart | 5 +- .../widgets/tabs/my_shifts_tab.dart | 42 ++-- .../connector/application/queries.gql | 9 +- .../dataconnect/connector/order/mutations.gql | 4 +- .../connector/shiftRole/queries.gql | 1 - backend/dataconnect/schema/order.gql | 2 +- backend/dataconnect/schema/shift.gql | 2 +- 25 files changed, 420 insertions(+), 207 deletions(-) diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 851578db..40b07667 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -148,9 +148,17 @@ "edit_mode_active": "Edit Mode Active", "drag_instruction": "Drag to reorder, toggle visibility", "reset": "Reset", + "todays_coverage": "TODAY'S COVERAGE", + "percent_covered": "$percent% Covered", "metric_needed": "Needed", "metric_filled": "Filled", "metric_open": "Open", + "spending": { + "this_week": "This Week", + "next_7_days": "Next 7 Days", + "shifts_count": "$count shifts", + "scheduled_count": "$count scheduled" + }, "view_all": "View all", "insight_lightbulb": "Save $amount/month", "insight_tip": "Book 48hrs ahead for better rates" @@ -237,6 +245,14 @@ "scan_button": "Scan NFC Tag", "tag_identified": "Tag Identified", "assign_button": "Assign Tag" + }, + "delete_dialog": { + "title": "Confirm Hub Deletion", + "message": "Are you sure you want to delete \"$hubName\"?", + "undo_warning": "This action cannot be undone.", + "dependency_warning": "Note that if there are any shifts/orders assigned to this hub we shouldn't be able to delete the hub.", + "cancel": "Cancel", + "delete": "Delete" } }, "client_create_order": { @@ -337,14 +353,26 @@ "cancelled": "CANCELLED", "get_direction": "Get direction", "total": "Total", - "hrs": "HRS", + "hrs": "Hrs", "workers": "$count workers", "clock_in": "CLOCK IN", "clock_out": "CLOCK OUT", "coverage": "Coverage", "workers_label": "$filled/$needed Workers", "confirmed_workers": "Workers Confirmed", - "no_workers": "No workers confirmed yet." + "no_workers": "No workers confirmed yet.", + "today": "Today", + "tomorrow": "Tomorrow", + "workers_needed": "$count Workers Needed", + "all_confirmed": "All Workers Confirmed", + "confirmed_workers_title": "CONFIRMED WORKERS", + "message_all": "Message All", + "show_more_workers": "Show $count more workers", + "checked_in": "Checked In", + "call_dialog": { + "title": "Call", + "message": "Do you want to call $phone?" + } } }, "client_billing": { @@ -498,6 +526,10 @@ "menu_items": { "personal_info": "Personal Info", "emergency_contact": "Emergency Contact", + "emergency_contact_page": { + "save_success": "Emergency contacts saved successfully", + "save_continue": "Save & Continue" + }, "experience": "Experience", "attire": "Attire", "documents": "Documents", @@ -853,6 +885,7 @@ }, "staff_certificates": { "title": "Certificates", + "error_loading": "Error loading certificates", "progress": { "title": "Your Progress", "verified_count": "$completed of $total verified", @@ -988,6 +1021,41 @@ "applying_dialog": { "title": "Applying" } + }, + "card": { + "just_now": "Just now", + "assigned": "Assigned $time ago", + "accept_shift": "Accept shift", + "decline_shift": "Decline shift" + }, + "my_shifts_tab": { + "confirm_dialog": { + "title": "Accept Shift", + "message": "Are you sure you want to accept this shift?", + "success": "Shift confirmed!" + }, + "decline_dialog": { + "title": "Decline Shift", + "message": "Are you sure you want to decline this shift? This action cannot be undone.", + "success": "Shift declined." + }, + "sections": { + "awaiting": "Awaiting Confirmation", + "cancelled": "Cancelled Shifts", + "confirmed": "Confirmed Shifts" + }, + "empty": { + "title": "No shifts this week", + "subtitle": "Try finding new jobs in the Find tab" + }, + "date": { + "today": "Today", + "tomorrow": "Tomorrow" + }, + "card": { + "cancelled": "CANCELLED", + "compensation": "• 4hr compensation" + } } }, "staff_time_card": { diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 6abaaf82..7627b0e3 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -148,9 +148,17 @@ "edit_mode_active": "Modo Edición Activo", "drag_instruction": "Arrastra para reordenar, cambia la visibilidad", "reset": "Restablecer", + "todays_coverage": "COBERTURA DE HOY", + "percent_covered": "$percent% Cubierto", "metric_needed": "Necesario", "metric_filled": "Lleno", "metric_open": "Abierto", + "spending": { + "this_week": "Esta Semana", + "next_7_days": "Próximos 7 Días", + "shifts_count": "$count turnos", + "scheduled_count": "$count programados" + }, "view_all": "Ver todo", "insight_lightbulb": "Ahorra $amount/mes", "insight_tip": "Reserva con 48h de antelación para mejores tarifas" @@ -237,6 +245,14 @@ "scan_button": "Escanear Etiqueta NFC", "tag_identified": "Etiqueta Identificada", "assign_button": "Asignar Etiqueta" + }, + "delete_dialog": { + "title": "Confirmar eliminación de Hub", + "message": "¿Estás seguro de que quieres eliminar \"$hubName\"?", + "undo_warning": "Esta acción no se puede deshacer.", + "dependency_warning": "Ten en cuenta que si hay turnos/órdenes asignados a este hub no deberíamos poder eliminarlo.", + "cancel": "Cancelar", + "delete": "Eliminar" } }, "client_create_order": { @@ -337,14 +353,26 @@ "cancelled": "CANCELADO", "get_direction": "Obtener dirección", "total": "Total", - "hrs": "HRS", + "hrs": "Hrs", "workers": "$count trabajadores", "clock_in": "ENTRADA", "clock_out": "SALIDA", "coverage": "Cobertura", "workers_label": "$filled/$needed Trabajadores", "confirmed_workers": "Trabajadores Confirmados", - "no_workers": "Ningún trabajador confirmado aún." + "no_workers": "Ningún trabajador confirmado aún.", + "today": "Hoy", + "tomorrow": "Mañana", + "workers_needed": "$count Trabajadores Necesarios", + "all_confirmed": "Todos los trabajadores confirmados", + "confirmed_workers_title": "TRABAJADORES CONFIRMADOS", + "message_all": "Mensaje a todos", + "show_more_workers": "Mostrar $count trabajadores más", + "checked_in": "Registrado", + "call_dialog": { + "title": "Llamar", + "message": "¿Quieres llamar a $phone?" + } } }, "client_billing": { @@ -498,6 +526,10 @@ "menu_items": { "personal_info": "Información Personal", "emergency_contact": "Contacto de Emergencia", + "emergency_contact_page": { + "save_success": "Contactos de emergencia guardados con éxito", + "save_continue": "Guardar y Continuar" + }, "experience": "Experiencia", "attire": "Vestimenta", "documents": "Documentos", @@ -853,6 +885,7 @@ }, "staff_certificates": { "title": "Certificados", + "error_loading": "Error al cargar certificados", "progress": { "title": "Tu Progreso", "verified_count": "$completed de $total verificados", @@ -988,6 +1021,41 @@ "applying_dialog": { "title": "Solicitando" } + }, + "card": { + "just_now": "Recién", + "assigned": "Asignado hace $time", + "accept_shift": "Aceptar turno", + "decline_shift": "Rechazar turno" + }, + "my_shifts_tab": { + "confirm_dialog": { + "title": "Aceptar Turno", + "message": "¿Estás seguro de que quieres aceptar este turno?", + "success": "¡Turno confirmado!" + }, + "decline_dialog": { + "title": "Rechazar Turno", + "message": "¿Estás seguro de que quieres rechazar este turno? Esta acción no se puede deshacer.", + "success": "Turno rechazado." + }, + "sections": { + "awaiting": "Esperando Confirmación", + "cancelled": "Turnos Cancelados", + "confirmed": "Turnos Confirmados" + }, + "empty": { + "title": "Sin turnos esta semana", + "subtitle": "Intenta buscar nuevos trabajos en la pestaña Buscar" + }, + "date": { + "today": "Hoy", + "tomorrow": "Mañana" + }, + "card": { + "cancelled": "CANCELADO", + "compensation": "• Compensación de 4h" + } } }, "staff_time_card": { diff --git a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index 6b07dcf1..f9ea9264 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -238,7 +238,7 @@ class AuthRepositoryImpl final QueryResult response = await executeProtected(() => _dataConnect.getUserById(id: firebaseUserId).execute()); final dc.GetUserByIdUser? user = response.data.user; - return user != null && user.userRole == 'BUSINESS'; + return user != null && (user.userRole == 'BUSINESS' || user.userRole == 'BOTH'); } /// Creates Business and User entities in PostgreSQL for a Firebase user. @@ -261,18 +261,28 @@ class AuthRepositoryImpl final dc.CreateBusinessBusinessInsert businessData = createBusinessResponse.data.business_insert; onBusinessCreated(businessData.id); - // Create User entity in PostgreSQL + // Check if User entity already exists in PostgreSQL + final QueryResult userResult = + await executeProtected(() => _dataConnect.getUserById(id: firebaseUser.uid).execute()); + final dc.GetUserByIdUser? existingUser = userResult.data.user; - final OperationResult createUserResponse = - await executeProtected(() => _dataConnect.createUser( - id: firebaseUser.uid, - role: dc.UserBaseRole.USER, - ) - .email(email) - .userRole('BUSINESS') - .execute()); - - final dc.CreateUserUserInsert newUserData = createUserResponse.data.user_insert; + if (existingUser != null) { + // User exists (likely in another app like STAFF). Update role to BOTH. + await executeProtected(() => _dataConnect.updateUser( + id: firebaseUser.uid, + ) + .userRole('BOTH') + .execute()); + } else { + // Create new User entity in PostgreSQL + await executeProtected(() => _dataConnect.createUser( + id: firebaseUser.uid, + role: dc.UserBaseRole.USER, + ) + .email(email) + .userRole('BUSINESS') + .execute()); + } return _getUserProfile( firebaseUserId: firebaseUser.uid, @@ -331,11 +341,11 @@ class AuthRepositoryImpl technicalMessage: 'Firebase UID $firebaseUserId not found in users table', ); } - if (requireBusinessRole && user.userRole != 'BUSINESS') { + if (requireBusinessRole && user.userRole != 'BUSINESS' && user.userRole != 'BOTH') { await _firebaseAuth.signOut(); dc.ClientSessionStore.instance.clear(); throw UnauthorizedAppException( - technicalMessage: 'User role is ${user.userRole}, expected BUSINESS', + technicalMessage: 'User role is ${user.userRole}, expected BUSINESS or BOTH', ); } diff --git a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_get_started_page.dart b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_get_started_page.dart index c6bd75be..f730ba34 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_get_started_page.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_get_started_page.dart @@ -27,94 +27,108 @@ class ClientGetStartedPage extends StatelessWidget { ), SafeArea( - child: Column( - children: [ - const SizedBox(height: UiConstants.space10), - // Logo - Center( - child: Image.asset( - UiImageAssets.logoBlue, - height: 40, - fit: BoxFit.contain, + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Column( + children: [ + const SizedBox(height: UiConstants.space10), + // Logo + Center( + child: Image.asset( + UiImageAssets.logoBlue, + height: 40, + fit: BoxFit.contain, + ), + ), + + const Spacer(), + + // Content Cards Area (Keeping prototype layout) + Container( + height: 300, + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space6, + ), + child: Stack( + children: [ + // Representative cards from prototype + Positioned( + top: 20, + left: 0, + right: 20, + child: _ShiftOrderCard(), + ), + Positioned( + bottom: 40, + right: 0, + left: 40, + child: _WorkerProfileCard(), + ), + Positioned( + top: 60, + right: 10, + child: _CalendarCard(), + ), + ], + ), + ), + + const Spacer(), + + // Bottom Content + Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space6, + vertical: UiConstants.space10, + ), + child: Column( + children: [ + Text( + t.client_authentication.get_started_page.title, + textAlign: TextAlign.center, + style: UiTypography.displayM, + ), + const SizedBox(height: UiConstants.space3), + Text( + t.client_authentication.get_started_page + .subtitle, + textAlign: TextAlign.center, + style: UiTypography.body2r.textSecondary, + ), + const SizedBox(height: UiConstants.space8), + + // Sign In Button + UiButton.primary( + text: t.client_authentication.get_started_page + .sign_in_button, + onPressed: () => Modular.to.toClientSignIn(), + fullWidth: true, + ), + + const SizedBox(height: UiConstants.space3), + + // Create Account Button + UiButton.secondary( + text: t.client_authentication.get_started_page + .create_account_button, + onPressed: () => Modular.to.toClientSignUp(), + fullWidth: true, + ), + ], + ), + ), + ], + ), + ), ), - ), - - const Spacer(), - - // Content Cards Area (Keeping prototype layout) - Container( - height: 300, - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space6, - ), - child: Stack( - children: [ - // Representative cards from prototype - Positioned( - top: 20, - left: 0, - right: 20, - child: _ShiftOrderCard(), - ), - Positioned( - bottom: 40, - right: 0, - left: 40, - child: _WorkerProfileCard(), - ), - Positioned(top: 60, right: 10, child: _CalendarCard()), - ], - ), - ), - - const Spacer(), - - // Bottom Content - Padding( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space6, - vertical: UiConstants.space10, - ), - child: Column( - children: [ - Text( - t.client_authentication.get_started_page.title, - textAlign: TextAlign.center, - style: UiTypography.displayM, - ), - const SizedBox(height: UiConstants.space3), - Text( - t.client_authentication.get_started_page.subtitle, - textAlign: TextAlign.center, - style: UiTypography.body2r.textSecondary, - ), - const SizedBox(height: UiConstants.space8), - - // Sign In Button - UiButton.primary( - text: t - .client_authentication - .get_started_page - .sign_in_button, - onPressed: () => Modular.to.toClientSignIn(), - fullWidth: true, - ), - - const SizedBox(height: UiConstants.space3), - - // Create Account Button - UiButton.secondary( - text: t - .client_authentication - .get_started_page - .create_account_button, - onPressed: () => Modular.to.toClientSignUp(), - fullWidth: true, - ), - ], - ), - ), - ], + ); + }, ), ), ], diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart index eefd4be3..47a6dbc6 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart @@ -151,10 +151,12 @@ class CoverageRepositoryImpl implements CoverageRepository { shiftId: app.shiftId, roleId: app.roleId, title: app.shiftRole.role.name, - location: app.shiftRole.shift.location ?? '', - startTime: '00:00', - workersNeeded: 0, - date: date, + location: app.shiftRole.shift.location ?? + app.shiftRole.shift.locationAddress ?? + '', + startTime: _formatTime(app.shiftRole.startTime) ?? '00:00', + workersNeeded: app.shiftRole.count, + date: app.shiftRole.shift.date?.toDateTime() ?? date, workers: [], ); diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_success_view.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_success_view.dart index e2e98350..a9981270 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_success_view.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_success_view.dart @@ -63,6 +63,8 @@ class OneTimeOrderSuccessView extends StatelessWidget { color: UiColors.accent, shape: BoxShape.circle, ), + + child: const Center( child: Icon( UiIcons.check, diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart index 559f4b53..da6a5df4 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/rapid_order/rapid_order_view.dart @@ -72,6 +72,12 @@ class _RapidOrderFormState extends State<_RapidOrderForm> { TextPosition(offset: _messageController.text.length), ); } + } else if (state is RapidOrderFailure) { + UiSnackbar.show( + context, + message: translateErrorKey(state.error), + type: UiSnackbarType.error, + ); } }, child: Scaffold( diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/coverage_widget.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/coverage_widget.dart index cc54f893..d90c7f6d 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/coverage_widget.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/coverage_widget.dart @@ -1,3 +1,4 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; @@ -47,7 +48,7 @@ class CoverageWidget extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - "TODAY'S COVERAGE", + t.client_home.dashboard.todays_coverage, style: UiTypography.footnote1b.copyWith( color: UiColors.textPrimary, letterSpacing: 0.5, @@ -64,8 +65,8 @@ class CoverageWidget extends StatelessWidget { color: backgroundColor, borderRadius: UiConstants.radiusLg, ), - child: Text( - '$coveragePercent% Covered', + child: Text( + t.client_home.dashboard.percent_covered(percent: coveragePercent), style: UiTypography.footnote2b.copyWith(color: textColor), ), ), @@ -81,7 +82,7 @@ class CoverageWidget extends StatelessWidget { child: _MetricCard( icon: UiIcons.target, iconColor: UiColors.primary, - label: 'Needed', + label: t.client_home.dashboard.metric_needed, value: '$totalNeeded', ), ), @@ -91,7 +92,7 @@ class CoverageWidget extends StatelessWidget { child: _MetricCard( icon: UiIcons.success, iconColor: UiColors.iconSuccess, - label: 'Filled', + label: t.client_home.dashboard.metric_filled, value: '$totalConfirmed', valueColor: UiColors.textSuccess, ), @@ -101,7 +102,7 @@ class CoverageWidget extends StatelessWidget { child: _MetricCard( icon: UiIcons.error, iconColor: UiColors.iconError, - label: 'Open', + label: t.client_home.dashboard.metric_open, value: '${totalNeeded - totalConfirmed}', valueColor: UiColors.textError, ), diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/shift_order_form_sheet.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/shift_order_form_sheet.dart index c2d7d93f..15bdac09 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/shift_order_form_sheet.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/shift_order_form_sheet.dart @@ -65,6 +65,8 @@ class _ShiftOrderFormSheetState extends State { dc.ListTeamHubsByOwnerIdTeamHubs? _selectedHub; bool _showSuccess = false; Map? _submitData; + bool _isSubmitting = false; + String? _errorMessage; @override void initState() { @@ -190,7 +192,25 @@ class _ShiftOrderFormSheetState extends State { } Future _handleSubmit() async { - await _submitNewOrder(); + if (_isSubmitting) return; + + setState(() { + _isSubmitting = true; + _errorMessage = null; + }); + + try { + await _submitNewOrder(); + } catch (e) { + if (!mounted) return; + setState(() { + _isSubmitting = false; + _errorMessage = 'Failed to create order. Please try again.'; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(_errorMessage!)), + ); + } } Future _submitNewOrder() async { @@ -296,6 +316,7 @@ class _ShiftOrderFormSheetState extends State { 'date': _dateController.text, }; _showSuccess = true; + _isSubmitting = false; }); } @@ -770,7 +791,7 @@ class _ShiftOrderFormSheetState extends State { UiButton.primary( text: widget.initialData != null ? 'Update Order' : 'Post Order', - onPressed: widget.isLoading ? null : _handleSubmit, + onPressed: (widget.isLoading || _isSubmitting) ? null : _handleSubmit, ), SizedBox(height: MediaQuery.of(context).padding.bottom + UiConstants.space5), ], diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/spending_widget.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/spending_widget.dart index 7ef551ed..f2beac80 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/spending_widget.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/spending_widget.dart @@ -78,7 +78,7 @@ class SpendingWidget extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'This Week', + t.client_home.dashboard.spending.this_week, style: UiTypography.footnote2r.white.copyWith( color: UiColors.white.withValues(alpha: 0.7), fontSize: 9, @@ -93,7 +93,7 @@ class SpendingWidget extends StatelessWidget { ), ), Text( - '$weeklyShifts shifts', + t.client_home.dashboard.spending.shifts_count(count: weeklyShifts), style: UiTypography.footnote2r.white.copyWith( color: UiColors.white.withValues(alpha: 0.6), fontSize: 9, @@ -107,7 +107,7 @@ class SpendingWidget extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - 'Next 7 Days', + t.client_home.dashboard.spending.next_7_days, style: UiTypography.footnote2r.white.copyWith( color: UiColors.white.withValues(alpha: 0.7), fontSize: 9, @@ -122,7 +122,7 @@ class SpendingWidget extends StatelessWidget { ), ), Text( - '$next7DaysScheduled scheduled', + t.client_home.dashboard.spending.scheduled_count(count: next7DaysScheduled), style: UiTypography.footnote2r.white.copyWith( color: UiColors.white.withValues(alpha: 0.6), fontSize: 9, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart index eb93cb7e..c8fdffed 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart @@ -227,23 +227,23 @@ class ClientHubsPage extends StatelessWidget { } Future _confirmDeleteHub(BuildContext context, Hub hub) async { - final String hubName = hub.name.isEmpty ? 'this hub' : hub.name; + final String hubName = hub.name.isEmpty ? t.client_hubs.title : hub.name; return showDialog( context: context, barrierDismissible: false, builder: (BuildContext dialogContext) { return AlertDialog( - title: const Text('Confirm Hub Deletion'), + title: Text(t.client_hubs.delete_dialog.title), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Are you sure you want to delete "$hubName"?'), + Text(t.client_hubs.delete_dialog.message(hubName: hubName)), const SizedBox(height: UiConstants.space2), - const Text('This action cannot be undone.'), + Text(t.client_hubs.delete_dialog.undo_warning), const SizedBox(height: UiConstants.space2), Text( - 'Note that if there are any shifts/orders assigned to this hub we shouldn\'t be able to delete the hub.', + t.client_hubs.delete_dialog.dependency_warning, style: UiTypography.footnote1r.copyWith( color: UiColors.textSecondary, ), @@ -253,7 +253,7 @@ class ClientHubsPage extends StatelessWidget { actions: [ TextButton( onPressed: () => Modular.to.pop(), - child: const Text('Cancel'), + child: Text(t.client_hubs.delete_dialog.cancel), ), TextButton( onPressed: () { @@ -265,7 +265,7 @@ class ClientHubsPage extends StatelessWidget { style: TextButton.styleFrom( foregroundColor: UiColors.destructive, ), - child: const Text('Delete'), + child: Text(t.client_hubs.delete_dialog.delete), ), ], ); diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart index 4106b4b0..480faece 100644 --- a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart +++ b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart @@ -89,8 +89,8 @@ class _ViewOrderCardState extends State { final DateTime tomorrow = today.add(const Duration(days: 1)); final DateTime checkDate = DateTime(date.year, date.month, date.day); - if (checkDate == today) return 'Today'; - if (checkDate == tomorrow) return 'Tomorrow'; + if (checkDate == today) return t.client_view_orders.card.today; + if (checkDate == tomorrow) return t.client_view_orders.card.tomorrow; return DateFormat('EEE, MMM d').format(date); } catch (_) { return dateStr; @@ -279,19 +279,19 @@ class _ViewOrderCardState extends State { _buildStatItem( icon: UiIcons.dollar, value: '\$${cost.round()}', - label: 'Total', + label: t.client_view_orders.card.total, ), _buildStatDivider(), _buildStatItem( icon: UiIcons.clock, value: hours.toStringAsFixed(1), - label: 'Hrs', + label: t.client_view_orders.card.hrs, ), _buildStatDivider(), _buildStatItem( icon: UiIcons.users, value: '${order.workersNeeded}', - label: 'Workers', + label: t.client_create_order.one_time.workers_label, ), ], ), @@ -303,14 +303,14 @@ class _ViewOrderCardState extends State { children: [ Expanded( child: _buildTimeDisplay( - label: 'Clock In', + label: t.client_view_orders.card.clock_in, time: _formatTime(timeStr: order.startTime), ), ), const SizedBox(width: UiConstants.space3), Expanded( child: _buildTimeDisplay( - label: 'Clock Out', + label: t.client_view_orders.card.clock_out, time: _formatTime(timeStr: order.endTime), ), ), @@ -341,8 +341,8 @@ class _ViewOrderCardState extends State { const SizedBox(width: UiConstants.space2), Text( coveragePercent == 100 - ? 'All Workers Confirmed' - : '${order.workersNeeded} Workers Needed', + ? t.client_view_orders.card.all_confirmed + : t.client_view_orders.card.workers_needed(count: order.workersNeeded), style: UiTypography.body2m.textPrimary, ), ], @@ -378,7 +378,7 @@ class _ViewOrderCardState extends State { Padding( padding: const EdgeInsets.only(left: 12), child: Text( - '+${order.confirmedApps.length - 3} more', + t.client_view_orders.card.show_more_workers(count: order.confirmedApps.length - 3), style: UiTypography.footnote2r.textSecondary, ), ), @@ -408,13 +408,13 @@ class _ViewOrderCardState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'CONFIRMED WORKERS', + t.client_view_orders.card.confirmed_workers_title, style: UiTypography.footnote2b.textSecondary, ), GestureDetector( onTap: () {}, child: Text( - 'Message All', + t.client_view_orders.card.message_all, style: UiTypography.footnote2b.copyWith( color: UiColors.primary, ), @@ -433,7 +433,7 @@ class _ViewOrderCardState extends State { child: TextButton( onPressed: () {}, child: Text( - 'Show ${order.confirmedApps.length - 5} more workers', + t.client_view_orders.card.show_more_workers(count: order.confirmedApps.length - 5), style: UiTypography.body2m.copyWith( color: UiColors.primary, ), @@ -569,7 +569,7 @@ class _ViewOrderCardState extends State { borderRadius: UiConstants.radiusSm, ), child: Text( - 'Checked In', + t.client_view_orders.card.checked_in, style: UiTypography.titleUppercase4m.copyWith( color: UiColors.textSuccess, ), @@ -615,16 +615,16 @@ class _ViewOrderCardState extends State { context: context, builder: (BuildContext context) { return AlertDialog( - title: const Text('Call'), - content: Text('Do you want to call $phone?'), + title: Text(t.client_view_orders.card.call_dialog.title), + content: Text(t.client_view_orders.card.call_dialog.message(phone: phone)), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), + child: Text(t.common.cancel), ), TextButton( onPressed: () => Navigator.of(context).pop(true), - child: const Text('Call'), + child: Text(t.client_view_orders.card.call_dialog.title), ), ], ); diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index 36f3ffe5..0d4bca6b 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -156,24 +156,30 @@ class AuthRepositoryImpl .userRole('STAFF') .execute()); } else { - if (user.userRole != 'STAFF') { - await firebaseAuth.signOut(); - throw const domain.UnauthorizedAppException( - technicalMessage: 'User is not authorized for this app.', - ); - } + // User exists in PostgreSQL. Check if they have a STAFF profile. final QueryResult staffResponse = await executeProtected(() => dataConnect .getStaffByUserId( userId: firebaseUser.uid, ) .execute()); + if (staffResponse.data.staffs.isNotEmpty) { + // If profile exists, they should use Login mode. await firebaseAuth.signOut(); throw const domain.AccountExistsException( - technicalMessage: 'This user already has a staff profile. Please log in.', + technicalMessage: + 'This user already has a staff profile. Please log in.', ); } + + // If they don't have a staff profile but they exist as BUSINESS, + // they are allowed to "Sign Up" for Staff. + // We update their userRole to 'BOTH'. + if (user.userRole == 'BUSINESS') { + await executeProtected(() => + dataConnect.updateUser(id: firebaseUser.uid).userRole('BOTH').execute()); + } } } else { if (user == null) { @@ -182,7 +188,8 @@ class AuthRepositoryImpl technicalMessage: 'Authenticated user profile not found in database.', ); } - if (user.userRole != 'STAFF') { + // Allow STAFF or BOTH roles to log in to the Staff App + if (user.userRole != 'STAFF' && user.userRole != 'BOTH') { await firebaseAuth.signOut(); throw const domain.UnauthorizedAppException( technicalMessage: 'User is not authorized for this app.', diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_event.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_event.dart index 51407bb9..cc9a9bea 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_event.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_event.dart @@ -5,7 +5,7 @@ import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart'; abstract class AuthEvent extends Equatable { const AuthEvent(); @override - List get props => []; + List get props => []; } /// Event for requesting a sign-in with a phone number. @@ -19,7 +19,7 @@ class AuthSignInRequested extends AuthEvent { const AuthSignInRequested({this.phoneNumber, required this.mode}); @override - List get props => [mode]; + List get props => [phoneNumber, mode]; } /// Event for submitting an OTP (One-Time Password) for verification. @@ -43,7 +43,7 @@ class AuthOtpSubmitted extends AuthEvent { }); @override - List get props => [verificationId, smsCode, mode]; + List get props => [verificationId, smsCode, mode]; } /// Event for clearing any authentication error in the state. @@ -57,7 +57,7 @@ class AuthResetRequested extends AuthEvent { const AuthResetRequested({required this.mode}); @override - List get props => [mode]; + List get props => [mode]; } /// Event for ticking down the resend cooldown. @@ -67,7 +67,7 @@ class AuthCooldownTicked extends AuthEvent { const AuthCooldownTicked(this.secondsRemaining); @override - List get props => [secondsRemaining]; + List get props => [secondsRemaining]; } /// Event for updating the current draft OTP in the state. @@ -78,7 +78,7 @@ class AuthOtpUpdated extends AuthEvent { const AuthOtpUpdated(this.otp); @override - List get props => [otp]; + List get props => [otp]; } /// Event for updating the current draft phone number in the state. @@ -89,5 +89,5 @@ class AuthPhoneUpdated extends AuthEvent { const AuthPhoneUpdated(this.phoneNumber); @override - List get props => [phoneNumber]; + List get props => [phoneNumber]; } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart index ceef6f69..9cbf1455 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart @@ -50,7 +50,13 @@ class _PhoneVerificationPageState extends State { required BuildContext context, required String phoneNumber, }) { - final String normalized = phoneNumber.replaceAll(RegExp(r'\\D'), ''); + String normalized = phoneNumber.replaceAll(RegExp(r'\D'), ''); + + // Handle US numbers entered with a leading 1 + if (normalized.length == 11 && normalized.startsWith('1')) { + normalized = normalized.substring(1); + } + if (normalized.length == 10) { BlocProvider.of( context, diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_form_field.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_form_field.dart index e12cd2da..0ed74eff 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_form_field.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_form_field.dart @@ -88,7 +88,7 @@ class _PhoneInputFormFieldState extends State { keyboardType: TextInputType.phone, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(10), + LengthLimitingTextInputFormatter(11), ], decoration: InputDecoration( hintText: t.staff_authentication.phone_input.hint, diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart index e688681d..f8bf4992 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart @@ -1,3 +1,4 @@ +import 'package:core_localization/core_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; @@ -58,9 +59,9 @@ class _ShiftCardState extends State { try { final date = DateTime.parse(dateStr); final diff = DateTime.now().difference(date); - if (diff.inHours < 1) return 'Just now'; - if (diff.inHours < 24) return 'Pending ${diff.inHours}h ago'; - return 'Pending ${diff.inDays}d ago'; + if (diff.inHours < 1) return t.staff_shifts.card.just_now; + if (diff.inHours < 24) return t.staff_shifts.details.pending_time(time: '${diff.inHours}h'); + return t.staff_shifts.details.pending_time(time: '${diff.inDays}d'); } catch (e) { return ''; } @@ -220,7 +221,11 @@ class _ShiftCardState extends State { borderRadius: UiConstants.radiusFull, ), child: Text( - 'Assigned ${_getTimeAgo(widget.shift.createdDate).replaceAll('Pending ', '')}', + t.staff_shifts.card.assigned( + time: _getTimeAgo(widget.shift.createdDate) + .replaceAll('Pending ', '') + .replaceAll('Just now', 'just now'), + ), style: UiTypography.body3m.white, ), ), @@ -302,13 +307,13 @@ class _ShiftCardState extends State { children: [ _buildTag( UiIcons.zap, - 'Immediate start', + t.staff_shifts.tags.immediate_start, UiColors.accent.withValues(alpha: 0.3), UiColors.foreground, ), _buildTag( UiIcons.timer, - 'No experience', + t.staff_shifts.tags.no_experience, UiColors.tagError, UiColors.textError, ), @@ -342,7 +347,7 @@ class _ShiftCardState extends State { BorderRadius.circular(UiConstants.radiusBase), ), ), - child: const Text('Accept shift'), + child: Text(t.staff_shifts.card.accept_shift), ), ), const SizedBox(height: UiConstants.space2), @@ -361,7 +366,7 @@ class _ShiftCardState extends State { BorderRadius.circular(UiConstants.radiusBase), ), ), - child: const Text('Decline shift'), + child: Text(t.staff_shifts.card.decline_shift), ), ), const SizedBox(height: UiConstants.space5), diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificates_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificates_page.dart index aed50873..0a1893a5 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificates_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificates_page.dart @@ -35,14 +35,14 @@ class CertificatesPage extends StatelessWidget { if (state.status == CertificatesStatus.failure) { return Scaffold( - appBar: AppBar(title: const Text('Certificates')), + appBar: AppBar(title: Text(t.staff_certificates.title)), body: Center( child: Padding( padding: const EdgeInsets.all(16.0), - child: Text( - state.errorMessage != null - ? translateErrorKey(state.errorMessage!) - : 'Error loading certificates', + child: Text( + state.errorMessage != null + ? translateErrorKey(state.errorMessage!) + : t.staff_certificates.error_loading, textAlign: TextAlign.center, style: UiTypography.body2r.textSecondary, ), diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_save_button.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_save_button.dart index 825e32fa..c332ac74 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_save_button.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_save_button.dart @@ -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'; @@ -18,7 +19,7 @@ class EmergencyContactSaveButton extends StatelessWidget { if (state.status == EmergencyContactStatus.saved) { UiSnackbar.show( context, - message: 'Emergency contacts saved successfully', + message: t.staff.profile.menu_items.emergency_contact_page.save_success, type: UiSnackbarType.success, margin: const EdgeInsets.only(bottom: 150, left: 16, right: 16), ); @@ -48,7 +49,7 @@ class EmergencyContactSaveButton extends StatelessWidget { ), ), ) - : const Text('Save & Continue'), + : Text(t.staff.profile.menu_items.emergency_contact_page.save_continue), ), ), ); diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart index cd5694d4..e17654e3 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart @@ -105,12 +105,12 @@ class _MyShiftsTabState extends State { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Accept Shift'), - content: const Text('Are you sure you want to accept this shift?'), + title: Text(t.staff_shifts.my_shifts_tab.confirm_dialog.title), + content: Text(t.staff_shifts.my_shifts_tab.confirm_dialog.message), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), + child: Text(t.common.cancel), ), TextButton( onPressed: () { @@ -118,14 +118,14 @@ class _MyShiftsTabState extends State { context.read().add(AcceptShiftEvent(id)); UiSnackbar.show( context, - message: 'Shift confirmed!', + message: t.staff_shifts.my_shifts_tab.confirm_dialog.success, type: UiSnackbarType.success, ); }, style: TextButton.styleFrom( foregroundColor: UiColors.success, ), - child: const Text('Accept'), + child: Text(t.staff_shifts.shift_details.accept_shift), ), ], ), @@ -136,14 +136,14 @@ class _MyShiftsTabState extends State { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Decline Shift'), - content: const Text( - 'Are you sure you want to decline this shift? This action cannot be undone.', + title: Text(t.staff_shifts.my_shifts_tab.decline_dialog.title), + content: Text( + t.staff_shifts.my_shifts_tab.decline_dialog.message, ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), + child: Text(t.common.cancel), ), TextButton( onPressed: () { @@ -151,14 +151,14 @@ class _MyShiftsTabState extends State { context.read().add(DeclineShiftEvent(id)); UiSnackbar.show( context, - message: 'Shift declined.', + message: t.staff_shifts.my_shifts_tab.decline_dialog.success, type: UiSnackbarType.error, ); }, style: TextButton.styleFrom( foregroundColor: UiColors.destructive, ), - child: const Text('Decline'), + child: Text(t.staff_shifts.shift_details.decline), ), ], ), @@ -169,9 +169,9 @@ class _MyShiftsTabState extends State { try { final date = DateTime.parse(dateStr); final now = DateTime.now(); - if (_isSameDay(date, now)) return "Today"; + if (_isSameDay(date, now)) return t.staff_shifts.my_shifts_tab.date.today; final tomorrow = now.add(const Duration(days: 1)); - if (_isSameDay(date, tomorrow)) return "Tomorrow"; + if (_isSameDay(date, tomorrow)) return t.staff_shifts.my_shifts_tab.date.tomorrow; return DateFormat('EEE, MMM d').format(date); } catch (_) { return dateStr; @@ -338,7 +338,7 @@ class _MyShiftsTabState extends State { const SizedBox(height: UiConstants.space5), if (widget.pendingAssignments.isNotEmpty) ...[ _buildSectionHeader( - "Awaiting Confirmation", + t.staff_shifts.my_shifts_tab.sections.awaiting, UiColors.textWarning, ), ...widget.pendingAssignments.map( @@ -356,7 +356,7 @@ class _MyShiftsTabState extends State { ], if (visibleCancelledShifts.isNotEmpty) ...[ - _buildSectionHeader("Cancelled Shifts", UiColors.textSecondary), + _buildSectionHeader(t.staff_shifts.my_shifts_tab.sections.cancelled, UiColors.textSecondary), ...visibleCancelledShifts.map( (shift) => Padding( padding: const EdgeInsets.only(bottom: UiConstants.space4), @@ -378,7 +378,7 @@ class _MyShiftsTabState extends State { // Confirmed Shifts if (visibleMyShifts.isNotEmpty) ...[ - _buildSectionHeader("Confirmed Shifts", UiColors.textSecondary), + _buildSectionHeader(t.staff_shifts.my_shifts_tab.sections.confirmed, UiColors.textSecondary), ...visibleMyShifts.map( (shift) => Padding( padding: const EdgeInsets.only(bottom: UiConstants.space3), @@ -390,10 +390,10 @@ class _MyShiftsTabState extends State { if (visibleMyShifts.isEmpty && widget.pendingAssignments.isEmpty && widget.cancelledShifts.isEmpty) - const EmptyStateView( + EmptyStateView( icon: UiIcons.calendar, - title: "No shifts this week", - subtitle: "Try finding new jobs in the Find tab", + title: t.staff_shifts.my_shifts_tab.empty.title, + subtitle: t.staff_shifts.my_shifts_tab.empty.subtitle, ), const SizedBox(height: UiConstants.space32), @@ -462,13 +462,13 @@ class _MyShiftsTabState extends State { ), const SizedBox(width: 6), Text( - "CANCELLED", + t.staff_shifts.my_shifts_tab.card.cancelled, style: UiTypography.footnote2b.textError, ), if (isLastMinute) ...[ const SizedBox(width: 4), Text( - "• 4hr compensation", + t.staff_shifts.my_shifts_tab.card.compensation, style: UiTypography.footnote2m.textSuccess, ), ], diff --git a/backend/dataconnect/connector/application/queries.gql b/backend/dataconnect/connector/application/queries.gql index 4458488d..44c66795 100644 --- a/backend/dataconnect/connector/application/queries.gql +++ b/backend/dataconnect/connector/application/queries.gql @@ -598,16 +598,19 @@ query listStaffsApplicationsByBusinessForDay( appliedAt status - shiftRole{ - shift{ + shiftRole { + startTime + shift { + date location + locationAddress cost } count assigned hours - role{ + role { name } } diff --git a/backend/dataconnect/connector/order/mutations.gql b/backend/dataconnect/connector/order/mutations.gql index 6827d73a..32423968 100644 --- a/backend/dataconnect/connector/order/mutations.gql +++ b/backend/dataconnect/connector/order/mutations.gql @@ -46,7 +46,7 @@ mutation createOrder( detectedConflicts: $detectedConflicts poReference: $poReference } - ) + ) } mutation updateOrder( @@ -92,7 +92,7 @@ mutation updateOrder( detectedConflicts: $detectedConflicts poReference: $poReference } - ) + ) } mutation deleteOrder($id: UUID!) @auth(level: USER) { diff --git a/backend/dataconnect/connector/shiftRole/queries.gql b/backend/dataconnect/connector/shiftRole/queries.gql index 86314355..98b83f94 100644 --- a/backend/dataconnect/connector/shiftRole/queries.gql +++ b/backend/dataconnect/connector/shiftRole/queries.gql @@ -321,7 +321,6 @@ query listShiftRolesByBusinessAndDateRange( shift: { date: { ge: $start, le: $end } order: { businessId: { eq: $businessId } } - status: { eq: $status } } } offset: $offset diff --git a/backend/dataconnect/schema/order.gql b/backend/dataconnect/schema/order.gql index 16a60eac..1c815e60 100644 --- a/backend/dataconnect/schema/order.gql +++ b/backend/dataconnect/schema/order.gql @@ -22,7 +22,7 @@ enum OrderDuration { } #events -type Order @table(name: "orders") { +type Order @table(name: "orders", key: ["id"]) { id: UUID! @default(expr: "uuidV4()") eventName: String diff --git a/backend/dataconnect/schema/shift.gql b/backend/dataconnect/schema/shift.gql index 96b1d2d1..3e5f7c67 100644 --- a/backend/dataconnect/schema/shift.gql +++ b/backend/dataconnect/schema/shift.gql @@ -10,7 +10,7 @@ enum ShiftStatus { CANCELED } -type Shift @table(name: "shifts") { +type Shift @table(name: "shifts", key: ["id"]) { id: UUID! @default(expr: "uuidV4()") title: String! From 690d4f4213e0dfd6ef81948e5ccaa7cdd15806ca Mon Sep 17 00:00:00 2001 From: Suriya Date: Mon, 16 Feb 2026 15:57:27 +0530 Subject: [PATCH 02/53] feat(staff): Refactor Shift Cards & Integrate Google Maps Refactors MyShiftCard to match prototype design with expandable details, bold typography, and Google Static Maps integration. Updates AppConfig for API keys. --- apps/mobile/config.dev.json | 5 +- .../core/lib/src/config/app_config.dart | 3 + .../pages/shift_details_page.dart | 73 +-- .../presentation/widgets/my_shift_card.dart | 445 ++++++++++++++++-- .../widgets/shift_location_map.dart | 103 ++++ .../widgets/tabs/find_shifts_tab.dart | 10 + .../widgets/tabs/my_shifts_tab.dart | 12 +- 7 files changed, 544 insertions(+), 107 deletions(-) create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_location_map.dart diff --git a/apps/mobile/config.dev.json b/apps/mobile/config.dev.json index d2d5fa4c..214cb535 100644 --- a/apps/mobile/config.dev.json +++ b/apps/mobile/config.dev.json @@ -1,3 +1,4 @@ { - "GOOGLE_PLACES_API_KEY": "AIzaSyAS9yTf4q51_CNSZ7mbmeS9V3l_LZR80lU" -} + "GOOGLE_PLACES_API_KEY": "AIzaSyAS9yTf4q51_CNSZ7mbmeS9V3l_LZR80lU", + "GOOGLE_MAPS_API_KEY": "AIzaSyAyRS9I4xxoVPAX91RJvWJHszB3ZY3-IC0" +} \ No newline at end of file diff --git a/apps/mobile/packages/core/lib/src/config/app_config.dart b/apps/mobile/packages/core/lib/src/config/app_config.dart index ac1ccfc7..727638a4 100644 --- a/apps/mobile/packages/core/lib/src/config/app_config.dart +++ b/apps/mobile/packages/core/lib/src/config/app_config.dart @@ -6,4 +6,7 @@ class AppConfig { /// The Google Places API key used for address autocomplete functionality. static const String googlePlacesApiKey = String.fromEnvironment('GOOGLE_PLACES_API_KEY'); + + /// The Google Maps Static API key used for location preview images. + static const String googleMapsApiKey = String.fromEnvironment('GOOGLE_MAPS_API_KEY'); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart index 819293cc..6c83272b 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart @@ -579,40 +579,21 @@ class _ShiftDetailsPageState extends State { final i18n = Translations.of(context).staff_shifts.shift_details; if (status == 'confirmed') { - return Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: () => _openCancelDialog(context), - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.destructive, - foregroundColor: UiColors.white, - padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - elevation: 0, - ), - child: Text(i18n.cancel_shift, style: UiTypography.body2b.white), + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Modular.to.toClockIn(), + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.success, + foregroundColor: UiColors.white, + padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), ), + elevation: 0, ), - const SizedBox(width: UiConstants.space4), - Expanded( - child: ElevatedButton( - onPressed: () => Modular.to.toClockIn(), - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.success, - foregroundColor: UiColors.white, - padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - elevation: 0, - ), - child: Text(i18n.clock_in, style: UiTypography.body2b.white), - ), - ), - ], + child: Text(i18n.clock_in, style: UiTypography.body2b.white), + ), ); } @@ -693,32 +674,4 @@ class _ShiftDetailsPageState extends State { return const SizedBox(); } - void _openCancelDialog(BuildContext context) { - final i18n = Translations.of(context).staff_shifts.shift_details.cancel_dialog; - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(i18n.title), - content: Text(i18n.message), - actions: [ - TextButton( - onPressed: () => Modular.to.pop(), - child: Text(Translations.of(context).common.cancel), - ), - TextButton( - onPressed: () { - Modular.to.pop(); - BlocProvider.of(context).add( - DeclineShiftDetailsEvent(widget.shiftId), - ); - }, - style: TextButton.styleFrom( - foregroundColor: UiColors.destructive, - ), - child: Text(Translations.of(context).common.ok), - ), - ], - ), - ); - } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart index d6ee5fa1..bbf5eb35 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart @@ -4,27 +4,39 @@ import 'package:intl/intl.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:design_system/design_system.dart'; import 'package:core_localization/core_localization.dart'; -import 'package:krow_core/core.dart'; +import 'shift_location_map.dart'; +import 'package:krow_core/core.dart'; // For modular navigation class MyShiftCard extends StatefulWidget { final Shift shift; + final bool historyMode; + final VoidCallback? onAccept; + final VoidCallback? onDecline; + final VoidCallback? onRequestSwap; const MyShiftCard({ super.key, required this.shift, + this.historyMode = false, + this.onAccept, + this.onDecline, + this.onRequestSwap, }); @override State createState() => _MyShiftCardState(); } -class _MyShiftCardState extends State { +class _MyShiftCardState extends State with TickerProviderStateMixin { + bool _isExpanded = false; + String _formatTime(String time) { if (time.isEmpty) return ''; try { final parts = time.split(':'); final hour = int.parse(parts[0]); final minute = int.parse(parts[1]); + // Date doesn't matter for time formatting final dt = DateTime(2022, 1, 1, hour, minute); return DateFormat('h:mm a').format(dt); } catch (e) { @@ -65,13 +77,18 @@ class _MyShiftCardState extends State { } String _getShiftType() { - if (widget.shift.durationDays != null && widget.shift.durationDays! > 30) { - return t.staff_shifts.filter.long_term; + // Handling potential localization key availability + try { + if (widget.shift.durationDays != null && widget.shift.durationDays! > 30) { + return t.staff_shifts.filter.long_term; + } + if (widget.shift.durationDays != null && widget.shift.durationDays! > 1) { + return t.staff_shifts.filter.multi_day; + } + return t.staff_shifts.filter.one_day; + } catch (_) { + return "One Day"; } - if (widget.shift.durationDays != null && widget.shift.durationDays! > 1) { - return t.staff_shifts.filter.multi_day; - } - return t.staff_shifts.filter.one_day; } @override @@ -86,38 +103,43 @@ class _MyShiftCardState extends State { String statusText = ''; IconData? statusIcon; - if (status == 'confirmed') { - statusText = t.staff_shifts.status.confirmed; - statusColor = UiColors.textLink; - statusBg = UiColors.primary; - } else if (status == 'checked_in') { - statusText = 'Checked in'; - statusColor = UiColors.textSuccess; - statusBg = UiColors.iconSuccess; - } else if (status == 'pending' || status == 'open') { - statusText = t.staff_shifts.status.act_now; - statusColor = UiColors.destructive; - statusBg = UiColors.destructive; - } else if (status == 'swap') { - statusText = t.staff_shifts.status.swap_requested; - statusColor = UiColors.textWarning; - statusBg = UiColors.textWarning; - statusIcon = UiIcons.swap; - } else if (status == 'completed') { - statusText = t.staff_shifts.status.completed; - statusColor = UiColors.textSuccess; - statusBg = UiColors.iconSuccess; - } else if (status == 'no_show') { - statusText = t.staff_shifts.status.no_show; - statusColor = UiColors.destructive; - statusBg = UiColors.destructive; + // Fallback localization if keys missing + // Assuming t.staff_shifts.status.* exists as per previous file content + try { + if (status == 'confirmed') { + statusText = t.staff_shifts.status.confirmed; + statusColor = UiColors.textLink; + statusBg = UiColors.primary; + } else if (status == 'checked_in') { + statusText = 'Checked in'; + statusColor = UiColors.textSuccess; + statusBg = UiColors.iconSuccess; + } else if (status == 'pending' || status == 'open') { + statusText = t.staff_shifts.status.act_now; + statusColor = UiColors.destructive; + statusBg = UiColors.destructive; + } else if (status == 'swap') { + statusText = t.staff_shifts.status.swap_requested; + statusColor = UiColors.textWarning; + statusBg = UiColors.textWarning; + statusIcon = UiIcons.swap; + } else if (status == 'completed') { + statusText = t.staff_shifts.status.completed; + statusColor = UiColors.textSuccess; + statusBg = UiColors.iconSuccess; + } else if (status == 'no_show') { + statusText = t.staff_shifts.status.no_show; + statusColor = UiColors.destructive; + statusBg = UiColors.destructive; + } + } catch (_) { + statusText = status?.toUpperCase() ?? ""; } return GestureDetector( - onTap: () { - Modular.to.pushShiftDetails(widget.shift); - }, - child: Container( + onTap: () => setState(() => _isExpanded = !_isExpanded), + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), margin: const EdgeInsets.only(bottom: UiConstants.space3), decoration: BoxDecoration( color: UiColors.white, @@ -156,8 +178,8 @@ class _MyShiftCardState extends State { ) else Container( - width: UiConstants.radiusMdValue, - height: UiConstants.radiusMdValue, + width: 8, + height: 8, margin: const EdgeInsets.only(right: UiConstants.space2), decoration: BoxDecoration( color: statusBg, @@ -304,12 +326,20 @@ class _MyShiftCardState extends State { ], ), const SizedBox(height: UiConstants.space1), - Text( - "Showing first schedule...", - style: UiTypography.footnote2r.copyWith( - color: UiColors.primary, - ), + // Mock loop for demo purposes, as we don't have all schedule dates in the model + // In real app, we might need to fetch schedule or iterate if model changes + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + '${_formatDate(widget.shift.date)}, ${_formatTime(widget.shift.startTime)} – ${_formatTime(widget.shift.endTime)}', + style: UiTypography.footnote2r.copyWith(color: UiColors.primary), + ), ), + if (widget.shift.durationDays! > 1) + Text( + '... +${widget.shift.durationDays! - 1} more days', + style: UiTypography.footnote2r.copyWith(color: UiColors.primary.withOpacity(0.7)), + ) ], ), ] else ...[ @@ -370,9 +400,336 @@ class _MyShiftCardState extends State { ], ), ), + + // Expanded Content + AnimatedSize( + duration: const Duration(milliseconds: 300), + child: _isExpanded + ? Column( + children: [ + const Divider(height: 1, color: UiColors.border), + Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Stats Row + Row( + children: [ + Expanded( + child: _buildStatCard( + UiIcons.dollar, + "\$${estimatedTotal.toStringAsFixed(0)}", + "Total", + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: _buildStatCard( + UiIcons.dollar, + "\$${widget.shift.hourlyRate}", + "Hourly Rate", + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: _buildStatCard( + UiIcons.clock, + "${duration}", + "Hours", + ), + ), + ], + ), + const SizedBox(height: UiConstants.space5), + + // In/Out Time + Row( + children: [ + Expanded( + child: _buildTimeBox( + "CLOCK IN TIME", + widget.shift.startTime, + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: _buildTimeBox( + "CLOCK OUT TIME", + widget.shift.endTime, + ), + ), + ], + ), + const SizedBox(height: UiConstants.space5), + + // Location + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "LOCATION", + style: UiTypography.footnote2b.copyWith( + color: UiColors.textSecondary, + letterSpacing: 0.5), + ), + const SizedBox(height: UiConstants.space2), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + widget.shift.location.isEmpty + ? "TBD" + : widget.shift.location, + style: UiTypography.title1m.textPrimary, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: UiConstants.space3), + OutlinedButton.icon( + onPressed: () { + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: Text( + widget.shift.locationAddress ?? + widget.shift.location, + ), + duration: const Duration( + seconds: 3, + ), + ), + ); + }, + icon: const Icon( + UiIcons.navigation, + size: UiConstants.iconXs, + ), + label: const Text( + "Get direction", + ), + style: OutlinedButton.styleFrom( + foregroundColor: + UiColors.textPrimary, + side: const BorderSide( + color: UiColors.border, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: 0, + ), + minimumSize: const Size(0, 32), + ), + ), + ], + ), + const SizedBox(height: UiConstants.space3), + ShiftLocationMap( + shift: widget.shift, + height: 128, + borderRadius: UiConstants.radiusBase, + ), + ], + ), + const SizedBox(height: UiConstants.space5), + + // Additional Info + if (widget.shift.description != null) ...[ + SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + "ADDITIONAL INFO", + style: UiTypography.footnote2b.copyWith( + color: UiColors.textSecondary, + letterSpacing: 0.5), + ), + const SizedBox(height: UiConstants.space2), + Text( + widget.shift.description!, + style: UiTypography.body2m.textPrimary, + ), + ], + ), + ), + const SizedBox(height: UiConstants.space5), + ], + + // Actions + if (!widget.historyMode) + Padding( + padding: const EdgeInsets.only(top: UiConstants.space2), + child: _buildActions(status), + ), + ], + ), + ), + ], + ) + : const SizedBox.shrink(), + ), ], ), ), ); } + + Widget _buildActions(String? status) { + if (status == 'confirmed') { + return SizedBox( + width: double.infinity, + height: 48, + child: OutlinedButton.icon( + onPressed: widget.onRequestSwap, + icon: const Icon( + UiIcons.swap, + size: UiConstants.iconSm, + ), + label: const Text("Request Swap"), + style: OutlinedButton.styleFrom( + foregroundColor: UiColors.primary, + side: const BorderSide( + color: UiColors.primary, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), + ), + ), + ), + ); + } else if (status == 'swap') { + return Container( + width: double.infinity, + height: 48, + decoration: BoxDecoration( + color: UiColors.tagPending, + border: Border.all( + color: UiColors.textWarning, + ), + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.swap, + size: UiConstants.iconSm, + color: UiColors.textWarning, + ), + const SizedBox(width: UiConstants.space2), + Text( + "Swap Pending", + style: UiTypography.body2b.copyWith( + color: UiColors.textWarning, + ), + ), + ], + ), + ); + } else { + // status == 'open' || status == 'pending' or others + return Column( + children: [ + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + onPressed: widget.onAccept, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.primary, + foregroundColor: UiColors.white, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(UiConstants.radiusBase), + ), + ), + child: widget.onAccept == null + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2) + ) // Loading state if callback null? or just Text + : const Text( + "Book Shift", + style: TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ); + } + } + + Widget _buildStatCard(IconData icon, String value, String label) { + return Container( + padding: const EdgeInsets.symmetric(vertical: UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.background, + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + border: Border.all(color: UiColors.border), + ), + child: Column( + children: [ + Container( + width: 40, + height: 40, + decoration: const BoxDecoration( + color: UiColors.white, + shape: BoxShape.circle, + ), + child: Icon(icon, size: 20, color: UiColors.textSecondary), + ), + const SizedBox(height: UiConstants.space2), + Text( + value, + style: UiTypography.title1m.copyWith(fontWeight: FontWeight.w700).textPrimary, + ), + Text( + label, + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), + ); + } + + Widget _buildTimeBox(String label, String time) { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.background, + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + ), + child: Column( + children: [ + Text( + label, + style: UiTypography.footnote2b.copyWith( + color: UiColors.textSecondary, letterSpacing: 0.5), + ), + const SizedBox(height: UiConstants.space1), + Text( + _formatTime(time), + style: UiTypography.title1m.copyWith(fontWeight: FontWeight.w700).textPrimary, + ), + ], + ), + ); + } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_location_map.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_location_map.dart new file mode 100644 index 00000000..d5f8dc35 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_location_map.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:design_system/design_system.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:krow_core/core.dart'; // Import AppConfig from krow_core + +class ShiftLocationMap extends StatelessWidget { + final Shift shift; + final double height; + final double borderRadius; + + const ShiftLocationMap({ + super.key, + required this.shift, + this.height = 120, + this.borderRadius = 8, + }); + + @override + Widget build(BuildContext context) { + if (AppConfig.googleMapsApiKey.isEmpty) { + return _buildPlaceholder(context, "Config Map Key"); + } + + final String mapUrl = _generateStaticMapUrl(); + + return Container( + height: height, + width: double.infinity, + decoration: BoxDecoration( + color: UiColors.background, + borderRadius: BorderRadius.circular(borderRadius), + ), + clipBehavior: Clip.antiAlias, + child: Image.network( + mapUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return _buildPlaceholder(context, "Map unavailable"); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ); + }, + ), + ); + } + + String _generateStaticMapUrl() { + // Base URL + const String baseUrl = "https://maps.googleapis.com/maps/api/staticmap"; + + // Parameters + String center; + if (shift.latitude != null && shift.longitude != null) { + center = "${shift.latitude},${shift.longitude}"; + } else { + center = Uri.encodeComponent(shift.locationAddress.isNotEmpty + ? shift.locationAddress + : shift.location); + } + + // Construct URL + // scale=2 for retina displays + return "$baseUrl?center=$center&zoom=15&size=600x300&maptype=roadmap&markers=color:red%7C$center&key=${AppConfig.googleMapsApiKey}&scale=2"; + } + + Widget _buildPlaceholder(BuildContext context, String message) { + return Container( + height: height, + width: double.infinity, + decoration: BoxDecoration( + color: UiColors.secondary, + borderRadius: BorderRadius.circular(borderRadius), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + UiIcons.mapPin, + size: 32, + color: UiColors.iconSecondary, + ), + if (message.isNotEmpty) ...[ + const SizedBox(height: UiConstants.space2), + Text( + message, + style: UiTypography.footnote2r.textSecondary, + ), + ], + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart index 6422c312..95d1f7db 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart @@ -2,6 +2,8 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../blocs/shifts/shifts_bloc.dart'; import '../my_shift_card.dart'; import '../shared/empty_state_view.dart'; @@ -171,6 +173,14 @@ class _FindShiftsTabState extends State { padding: const EdgeInsets.only(bottom: UiConstants.space3), child: MyShiftCard( shift: shift, + onAccept: () { + context.read().add(AcceptShiftEvent(shift.id)); + UiSnackbar.show( + context, + message: "Shift application submitted!", // Todo: Localization + type: UiSnackbarType.success, + ); + }, ), ), ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart index e17654e3..51472ec9 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart @@ -382,7 +382,17 @@ class _MyShiftsTabState extends State { ...visibleMyShifts.map( (shift) => Padding( padding: const EdgeInsets.only(bottom: UiConstants.space3), - child: MyShiftCard(shift: shift), + child: MyShiftCard( + shift: shift, + onDecline: () => _declineShift(shift.id), + onRequestSwap: () { + UiSnackbar.show( + context, + message: "Swap functionality coming soon!", // Todo: Localization + type: UiSnackbarType.message, + ); + }, + ), ), ), ], From 40fa4ebdfa39a8b61245d0ef28bee4094ea84323 Mon Sep 17 00:00:00 2001 From: Suriya Date: Mon, 16 Feb 2026 20:28:43 +0530 Subject: [PATCH 03/53] Refactor: Move detailed shift UI from card to ShiftDetailsPage --- .../pages/shift_details_page.dart | 214 +++-- .../presentation/widgets/my_shift_card.dart | 811 +++++------------- 2 files changed, 339 insertions(+), 686 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart index 6c83272b..48cca943 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart @@ -1,5 +1,5 @@ import 'package:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; // Re-added for UiIcons/Colors as they are used in expanded logic +import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; @@ -10,6 +10,7 @@ import 'package:krow_domain/krow_domain.dart'; import '../blocs/shift_details/shift_details_bloc.dart'; import '../blocs/shift_details/shift_details_event.dart'; import '../blocs/shift_details/shift_details_state.dart'; +import '../widgets/shift_location_map.dart'; class ShiftDetailsPage extends StatefulWidget { final String shiftId; @@ -65,10 +66,10 @@ class _ShiftDetailsPageState extends State { Widget _buildStatCard(IconData icon, String value, String label) { return Container( - padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), + padding: const EdgeInsets.symmetric(vertical: UiConstants.space3), decoration: BoxDecoration( color: UiColors.background, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), border: Border.all(color: UiColors.border), ), child: Column( @@ -80,12 +81,12 @@ class _ShiftDetailsPageState extends State { color: UiColors.white, shape: BoxShape.circle, ), - child: Icon(icon, size: 20, color: UiColors.iconSecondary), + child: Icon(icon, size: 20, color: UiColors.textSecondary), ), const SizedBox(height: UiConstants.space2), Text( value, - style: UiTypography.title1m.textPrimary, + style: UiTypography.title1m.copyWith(fontWeight: FontWeight.w700).textPrimary, ), Text( label, @@ -98,21 +99,22 @@ class _ShiftDetailsPageState extends State { Widget _buildTimeBox(String label, String time) { return Container( - padding: const EdgeInsets.all(UiConstants.space4), + padding: const EdgeInsets.all(UiConstants.space3), decoration: BoxDecoration( color: UiColors.background, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), ), child: Column( children: [ Text( label, - style: UiTypography.titleUppercase4b.textSecondary, + style: UiTypography.footnote2b.copyWith( + color: UiColors.textSecondary, letterSpacing: 0.5), ), const SizedBox(height: UiConstants.space1), Text( _formatTime(time), - style: UiTypography.headline2m.textPrimary, + style: UiTypography.title1m.copyWith(fontWeight: FontWeight.w700).textPrimary, ), ], ), @@ -267,45 +269,49 @@ class _ShiftDetailsPageState extends State { ), const SizedBox(height: UiConstants.space6), - // Worker Capacity / Open Slots - if ((displayShift.requiredSlots ?? 0) > 0) - Container( - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.success.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(UiConstants.radiusBase), + // Stats Row (New) + Row( + children: [ + Expanded( + child: _buildStatCard( + UiIcons.dollar, + "\$${estimatedTotal.toStringAsFixed(0)}", + "Total", + ), ), - child: Row( - children: [ - const Icon( - UiIcons.users, - size: 16, - color: UiColors.success, - ), - const SizedBox(width: UiConstants.space2), - Text( - i18n.slots_remaining(count: openSlots), - style: UiTypography.footnote1m.textSuccess, - ), - ], + const SizedBox(width: UiConstants.space4), + Expanded( + child: _buildStatCard( + UiIcons.dollar, + "\$${displayShift.hourlyRate.toStringAsFixed(0)}", + "Hourly Rate", + ), ), - ), - + const SizedBox(width: UiConstants.space4), + Expanded( + child: _buildStatCard( + UiIcons.clock, + "${duration.toStringAsFixed(1)}", + "Hours", + ), + ), + ], + ), const SizedBox(height: UiConstants.space6), - // Time Section + // Time Section (New) Row( children: [ Expanded( child: _buildTimeBox( - i18n.start_time, + "CLOCK IN TIME", displayShift.startTime, ), ), const SizedBox(width: UiConstants.space4), Expanded( child: _buildTimeBox( - i18n.end_time, + "CLOCK OUT TIME", displayShift.endTime, ), ), @@ -313,97 +319,79 @@ class _ShiftDetailsPageState extends State { ), const SizedBox(height: UiConstants.space6), - // Quick Info Grid - Row( - children: [ - Expanded( - child: _buildStatCard( - UiIcons.dollar, - "\$${displayShift.hourlyRate.toStringAsFixed(0)}/hr", - i18n.base_rate, - ), - ), - const SizedBox(width: UiConstants.space4), - Expanded( - child: _buildStatCard( - UiIcons.clock, - i18n.hours_label(count: duration.toInt()), - i18n.duration, - ), - ), - const SizedBox(width: UiConstants.space4), - Expanded( - child: _buildStatCard( - UiIcons.wallet, - "\$${estimatedTotal.toStringAsFixed(0)}", - i18n.est_total, - ), - ), - ], - ), - const SizedBox(height: UiConstants.space8), - // Location Section + // Location Section (New with Map) Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - i18n.location, + "LOCATION", style: UiTypography.titleUppercase4b.textSecondary, ), const SizedBox(height: UiConstants.space3), - Container( - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all(color: UiColors.border), - ), - child: Column( - children: [ - Row( - children: [ - const Icon( - UiIcons.mapPin, - color: UiColors.primary, - size: 20, - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - displayShift.location, - style: UiTypography.body2b.textPrimary, - ), - Text( - displayShift.locationAddress, - style: UiTypography.body3r.textSecondary, - ), - ], + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + displayShift.location.isEmpty + ? "TBD" + : displayShift.location, + style: UiTypography.title1m.textPrimary, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: UiConstants.space3), + OutlinedButton.icon( + onPressed: () { + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: Text( + displayShift!.locationAddress.isNotEmpty + ? displayShift!.locationAddress + : displayShift!.location, + ), + duration: const Duration( + seconds: 3, ), ), - ], + ); + }, + icon: const Icon( + UiIcons.navigation, + size: UiConstants.iconXs, ), - const SizedBox(height: UiConstants.space4), - const Divider(), - const SizedBox(height: UiConstants.space2), - TextButton.icon( - onPressed: () {}, - icon: const Icon( - UiIcons.arrowRight, - size: 16, - ), - label: Text(i18n.open_in_maps), - style: TextButton.styleFrom( - foregroundColor: UiColors.primary, - padding: EdgeInsets.zero, - ), + label: const Text( + "Get direction", ), - ], - ), + style: OutlinedButton.styleFrom( + foregroundColor: + UiColors.textPrimary, + side: const BorderSide( + color: UiColors.border, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: 0, + ), + minimumSize: const Size(0, 32), + ), + ), + ], + ), + const SizedBox(height: UiConstants.space3), + ShiftLocationMap( + shift: displayShift, + height: 160, + borderRadius: UiConstants.radiusBase, ), ], ), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart index bbf5eb35..86352524 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/my_shift_card.dart @@ -4,7 +4,6 @@ import 'package:intl/intl.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:design_system/design_system.dart'; import 'package:core_localization/core_localization.dart'; -import 'shift_location_map.dart'; import 'package:krow_core/core.dart'; // For modular navigation class MyShiftCard extends StatefulWidget { @@ -27,8 +26,7 @@ class MyShiftCard extends StatefulWidget { State createState() => _MyShiftCardState(); } -class _MyShiftCardState extends State with TickerProviderStateMixin { - bool _isExpanded = false; +class _MyShiftCardState extends State { String _formatTime(String time) { if (time.isEmpty) return ''; @@ -104,7 +102,6 @@ class _MyShiftCardState extends State with TickerProviderStateMixin IconData? statusIcon; // Fallback localization if keys missing - // Assuming t.staff_shifts.status.* exists as per previous file content try { if (status == 'confirmed') { statusText = t.staff_shifts.status.confirmed; @@ -137,9 +134,13 @@ class _MyShiftCardState extends State with TickerProviderStateMixin } return GestureDetector( - onTap: () => setState(() => _isExpanded = !_isExpanded), - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), + onTap: () { + Modular.to.pushNamed( + StaffPaths.shiftDetails(widget.shift.id), + arguments: widget.shift, + ); + }, + child: Container( margin: const EdgeInsets.only(bottom: UiConstants.space3), decoration: BoxDecoration( color: UiColors.white, @@ -153,582 +154,246 @@ class _MyShiftCardState extends State with TickerProviderStateMixin ), ], ), - child: Column( - children: [ - // Collapsed Content - Padding( - padding: const EdgeInsets.all(UiConstants.space4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Status Badge - if (statusText.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space2), - child: Row( - children: [ - if (statusIcon != null) - Padding( - padding: const EdgeInsets.only(right: UiConstants.space2), - child: Icon( - statusIcon, - size: UiConstants.iconXs, - color: statusColor, - ), - ) - else - Container( - width: 8, - height: 8, - margin: const EdgeInsets.only(right: UiConstants.space2), - decoration: BoxDecoration( - color: statusBg, - shape: BoxShape.circle, - ), - ), - Text( - statusText, - style: UiTypography.footnote2b.copyWith( - color: statusColor, - letterSpacing: 0.5, - ), - ), - // Shift Type Badge for available/pending shifts - if (status == 'open' || status == 'pending') ...[ - const SizedBox(width: UiConstants.space2), - Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: 2, - ), - decoration: BoxDecoration( - color: UiColors.primary.withValues(alpha: 0.1), - borderRadius: UiConstants.radiusSm, - ), - child: Text( - _getShiftType(), - style: UiTypography.footnote2m.copyWith( - color: UiColors.primary, - ), - ), - ), - ], - ], - ), - ), - - Row( - crossAxisAlignment: CrossAxisAlignment.start, + child: Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Status Badge + if (statusText.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space2), + child: Row( children: [ - // Logo - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - UiColors.primary.withValues(alpha: 0.09), - UiColors.primary.withValues(alpha: 0.03), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all( - color: UiColors.primary.withValues(alpha: 0.09), - ), - ), - child: widget.shift.logoUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - child: Image.network( - widget.shift.logoUrl!, - fit: BoxFit.contain, - ), - ) - : const Center( - child: Icon( - UiIcons.briefcase, - color: UiColors.primary, - size: UiConstants.iconMd, - ), - ), - ), - const SizedBox(width: UiConstants.space3), - - // Details - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - widget.shift.title, - style: UiTypography.body2m.textPrimary, - overflow: TextOverflow.ellipsis, - ), - Text( - widget.shift.clientName, - style: UiTypography.body3r.textSecondary, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - const SizedBox(width: UiConstants.space2), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - "\$${estimatedTotal.toStringAsFixed(0)}", - style: UiTypography.title1m.textPrimary, - ), - Text( - "\$${widget.shift.hourlyRate.toInt()}/hr · ${duration.toInt()}h", - style: UiTypography.footnote2r.textSecondary, - ), - ], - ), - ], - ), - const SizedBox(height: UiConstants.space2), - - // Date & Time - Multi-Day or Single Day - if (widget.shift.durationDays != null && - widget.shift.durationDays! > 1) ...[ - // Multi-Day Schedule Display - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon( - UiIcons.clock, - size: UiConstants.iconXs, - color: UiColors.primary, - ), - const SizedBox(width: UiConstants.space1), - Text( - t.staff_shifts.details.days( - days: widget.shift.durationDays!, - ), - style: UiTypography.footnote2m.copyWith( - color: UiColors.primary, - ), - ), - ], - ), - const SizedBox(height: UiConstants.space1), - // Mock loop for demo purposes, as we don't have all schedule dates in the model - // In real app, we might need to fetch schedule or iterate if model changes - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text( - '${_formatDate(widget.shift.date)}, ${_formatTime(widget.shift.startTime)} – ${_formatTime(widget.shift.endTime)}', - style: UiTypography.footnote2r.copyWith(color: UiColors.primary), - ), - ), - if (widget.shift.durationDays! > 1) - Text( - '... +${widget.shift.durationDays! - 1} more days', - style: UiTypography.footnote2r.copyWith(color: UiColors.primary.withOpacity(0.7)), - ) - ], - ), - ] else ...[ - // Single Day Display - Row( - children: [ - const Icon( - UiIcons.calendar, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Text( - _formatDate(widget.shift.date), - style: UiTypography.footnote1r.textSecondary, - ), - const SizedBox(width: UiConstants.space3), - const Icon( - UiIcons.clock, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Text( - "${_formatTime(widget.shift.startTime)} - ${_formatTime(widget.shift.endTime)}", - style: UiTypography.footnote1r.textSecondary, - ), - ], - ), - ], - const SizedBox(height: UiConstants.space1), - - // Location - Row( - children: [ - const Icon( - UiIcons.mapPin, - size: UiConstants.iconXs, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Expanded( - child: Text( - widget.shift.locationAddress.isNotEmpty - ? widget.shift.locationAddress - : widget.shift.location, - style: UiTypography.footnote1r.textSecondary, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ], - ), - ), - ], - ), - ], - ), - ), - - // Expanded Content - AnimatedSize( - duration: const Duration(milliseconds: 300), - child: _isExpanded - ? Column( - children: [ - const Divider(height: 1, color: UiColors.border), + if (statusIcon != null) Padding( - padding: const EdgeInsets.all(UiConstants.space4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Stats Row - Row( - children: [ - Expanded( - child: _buildStatCard( - UiIcons.dollar, - "\$${estimatedTotal.toStringAsFixed(0)}", - "Total", - ), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: _buildStatCard( - UiIcons.dollar, - "\$${widget.shift.hourlyRate}", - "Hourly Rate", - ), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: _buildStatCard( - UiIcons.clock, - "${duration}", - "Hours", - ), - ), - ], - ), - const SizedBox(height: UiConstants.space5), - - // In/Out Time - Row( - children: [ - Expanded( - child: _buildTimeBox( - "CLOCK IN TIME", - widget.shift.startTime, - ), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: _buildTimeBox( - "CLOCK OUT TIME", - widget.shift.endTime, - ), - ), - ], - ), - const SizedBox(height: UiConstants.space5), - - // Location - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "LOCATION", - style: UiTypography.footnote2b.copyWith( - color: UiColors.textSecondary, - letterSpacing: 0.5), - ), - const SizedBox(height: UiConstants.space2), - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - widget.shift.location.isEmpty - ? "TBD" - : widget.shift.location, - style: UiTypography.title1m.textPrimary, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: UiConstants.space3), - OutlinedButton.icon( - onPressed: () { - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - content: Text( - widget.shift.locationAddress ?? - widget.shift.location, - ), - duration: const Duration( - seconds: 3, - ), - ), - ); - }, - icon: const Icon( - UiIcons.navigation, - size: UiConstants.iconXs, - ), - label: const Text( - "Get direction", - ), - style: OutlinedButton.styleFrom( - foregroundColor: - UiColors.textPrimary, - side: const BorderSide( - color: UiColors.border, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, - ), - ), - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space3, - vertical: 0, - ), - minimumSize: const Size(0, 32), - ), - ), - ], - ), - const SizedBox(height: UiConstants.space3), - ShiftLocationMap( - shift: widget.shift, - height: 128, - borderRadius: UiConstants.radiusBase, - ), - ], - ), - const SizedBox(height: UiConstants.space5), - - // Additional Info - if (widget.shift.description != null) ...[ - SizedBox( - width: double.infinity, - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - "ADDITIONAL INFO", - style: UiTypography.footnote2b.copyWith( - color: UiColors.textSecondary, - letterSpacing: 0.5), - ), - const SizedBox(height: UiConstants.space2), - Text( - widget.shift.description!, - style: UiTypography.body2m.textPrimary, - ), - ], - ), - ), - const SizedBox(height: UiConstants.space5), - ], - - // Actions - if (!widget.historyMode) - Padding( - padding: const EdgeInsets.only(top: UiConstants.space2), - child: _buildActions(status), - ), - ], + padding: const EdgeInsets.only(right: UiConstants.space2), + child: Icon( + statusIcon, + size: UiConstants.iconXs, + color: statusColor, + ), + ) + else + Container( + width: 8, + height: 8, + margin: const EdgeInsets.only(right: UiConstants.space2), + decoration: BoxDecoration( + color: statusBg, + shape: BoxShape.circle, + ), + ), + Text( + statusText, + style: UiTypography.footnote2b.copyWith( + color: statusColor, + letterSpacing: 0.5, + ), + ), + // Shift Type Badge + if (status == 'open' || status == 'pending') ...[ + const SizedBox(width: UiConstants.space2), + Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.1), + borderRadius: UiConstants.radiusSm, + ), + child: Text( + _getShiftType(), + style: UiTypography.footnote2m.copyWith( + color: UiColors.primary, + ), ), ), ], - ) - : const SizedBox.shrink(), - ), - ], - ), - ), - ); - } + ], + ), + ), - Widget _buildActions(String? status) { - if (status == 'confirmed') { - return SizedBox( - width: double.infinity, - height: 48, - child: OutlinedButton.icon( - onPressed: widget.onRequestSwap, - icon: const Icon( - UiIcons.swap, - size: UiConstants.iconSm, - ), - label: const Text("Request Swap"), - style: OutlinedButton.styleFrom( - foregroundColor: UiColors.primary, - side: const BorderSide( - color: UiColors.primary, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, - ), - ), - ), - ), - ); - } else if (status == 'swap') { - return Container( - width: double.infinity, - height: 48, - decoration: BoxDecoration( - color: UiColors.tagPending, - border: Border.all( - color: UiColors.textWarning, - ), - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - const Icon( - UiIcons.swap, - size: UiConstants.iconSm, - color: UiColors.textWarning, - ), - const SizedBox(width: UiConstants.space2), - Text( - "Swap Pending", - style: UiTypography.body2b.copyWith( - color: UiColors.textWarning, - ), - ), - ], - ), - ); - } else { - // status == 'open' || status == 'pending' or others - return Column( - children: [ - SizedBox( - width: double.infinity, - height: 48, - child: ElevatedButton( - onPressed: widget.onAccept, - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.primary, - foregroundColor: UiColors.white, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(UiConstants.radiusBase), - ), - ), - child: widget.onAccept == null - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2) - ) // Loading state if callback null? or just Text - : const Text( - "Book Shift", - style: TextStyle( - fontWeight: FontWeight.w600, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Logo + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + UiColors.primary.withValues(alpha: 0.09), + UiColors.primary.withValues(alpha: 0.03), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all( + color: UiColors.primary.withValues(alpha: 0.09), + ), ), - ), - ), - ), - ], - ); - } - } + child: widget.shift.logoUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + child: Image.network( + widget.shift.logoUrl!, + fit: BoxFit.contain, + ), + ) + : const Center( + child: Icon( + UiIcons.briefcase, + color: UiColors.primary, + size: UiConstants.iconMd, + ), + ), + ), + const SizedBox(width: UiConstants.space3), - Widget _buildStatCard(IconData icon, String value, String label) { - return Container( - padding: const EdgeInsets.symmetric(vertical: UiConstants.space3), - decoration: BoxDecoration( - color: UiColors.background, - borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), - border: Border.all(color: UiColors.border), - ), - child: Column( - children: [ - Container( - width: 40, - height: 40, - decoration: const BoxDecoration( - color: UiColors.white, - shape: BoxShape.circle, - ), - child: Icon(icon, size: 20, color: UiColors.textSecondary), - ), - const SizedBox(height: UiConstants.space2), - Text( - value, - style: UiTypography.title1m.copyWith(fontWeight: FontWeight.w700).textPrimary, - ), - Text( - label, - style: UiTypography.footnote2r.textSecondary, - ), - ], - ), - ); - } + // Consensed Details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + widget.shift.title, + style: UiTypography.body2m.textPrimary, + overflow: TextOverflow.ellipsis, + ), + Text( + widget.shift.clientName, + style: UiTypography.body3r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: UiConstants.space2), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + "\$${estimatedTotal.toStringAsFixed(0)}", + style: UiTypography.title1m.textPrimary, + ), + Text( + "\$${widget.shift.hourlyRate.toInt()}/hr · ${duration.toInt()}h", + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), + ], + ), + const SizedBox(height: UiConstants.space2), - Widget _buildTimeBox(String label, String time) { - return Container( - padding: const EdgeInsets.all(UiConstants.space3), - decoration: BoxDecoration( - color: UiColors.background, - borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), - ), - child: Column( - children: [ - Text( - label, - style: UiTypography.footnote2b.copyWith( - color: UiColors.textSecondary, letterSpacing: 0.5), + // Date & Time + if (widget.shift.durationDays != null && + widget.shift.durationDays! > 1) ...[ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + UiIcons.clock, + size: UiConstants.iconXs, + color: UiColors.primary, + ), + const SizedBox(width: UiConstants.space1), + Text( + t.staff_shifts.details.days( + days: widget.shift.durationDays!, + ), + style: UiTypography.footnote2m.copyWith( + color: UiColors.primary, + ), + ), + ], + ), + const SizedBox(height: UiConstants.space1), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + '${_formatDate(widget.shift.date)}, ${_formatTime(widget.shift.startTime)} – ${_formatTime(widget.shift.endTime)}', + style: UiTypography.footnote2r.copyWith(color: UiColors.primary), + ), + ), + if (widget.shift.durationDays! > 1) + Text( + '... +${widget.shift.durationDays! - 1} more days', + style: UiTypography.footnote2r.copyWith(color: UiColors.primary.withOpacity(0.7)), + ) + ], + ), + ] else ...[ + Row( + children: [ + const Icon( + UiIcons.calendar, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + _formatDate(widget.shift.date), + style: UiTypography.footnote1r.textSecondary, + ), + const SizedBox(width: UiConstants.space3), + const Icon( + UiIcons.clock, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + "${_formatTime(widget.shift.startTime)} - ${_formatTime(widget.shift.endTime)}", + style: UiTypography.footnote1r.textSecondary, + ), + ], + ), + ], + const SizedBox(height: UiConstants.space1), + + // Location + Row( + children: [ + const Icon( + UiIcons.mapPin, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Expanded( + child: Text( + widget.shift.locationAddress.isNotEmpty + ? widget.shift.locationAddress + : widget.shift.location, + style: UiTypography.footnote1r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ], ), - const SizedBox(height: UiConstants.space1), - Text( - _formatTime(time), - style: UiTypography.title1m.copyWith(fontWeight: FontWeight.w700).textPrimary, - ), - ], + ), ), ); } From e6b512ee84679e93a85ffb44c042dcfc9a90c0b6 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 12:58:05 -0500 Subject: [PATCH 04/53] feat: Add Headline 1 Bold style and refactor ShiftDetailsPage and FindShiftsTab layout --- .../design_system/lib/src/ui_typography.dart | 8 + .../src/presentation/widgets/shift_card.dart | 7 - .../pages/shift_details_page.dart | 223 +++++++++--------- .../src/presentation/pages/shifts_page.dart | 3 - .../presentation/styles/shifts_styles.dart | 13 - .../widgets/shared/empty_state_view.dart | 1 - .../widgets/tabs/find_shifts_tab.dart | 40 ++-- .../features/staff/shifts/pubspec.yaml | 2 + 8 files changed, 151 insertions(+), 146 deletions(-) delete mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/styles/shifts_styles.dart diff --git a/apps/mobile/packages/design_system/lib/src/ui_typography.dart b/apps/mobile/packages/design_system/lib/src/ui_typography.dart index 6d33312b..b2224b11 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_typography.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_typography.dart @@ -173,6 +173,14 @@ class UiTypography { color: UiColors.textPrimary, ); + /// Headline 1 Bold - Font: Instrument Sans, Size: 26, Height: 1.5 (#121826) + static final TextStyle headline1b = _primaryBase.copyWith( + fontWeight: FontWeight.w600, + fontSize: 26, + height: 1.5, + color: UiColors.textPrimary, + ); + /// Headline 2 Medium - Font: Instrument Sans, Size: 20, Height: 1.5 (#121826) static final TextStyle headline2m = _primaryBase.copyWith( fontWeight: FontWeight.w500, diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart index f8bf4992..f35d97ae 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart @@ -84,13 +84,6 @@ class _ShiftCardState extends State { color: UiColors.white, borderRadius: BorderRadius.circular(UiConstants.radiusBase), border: Border.all(color: UiColors.border), - boxShadow: [ - BoxShadow( - color: UiColors.black.withValues(alpha: 0.05), - blurRadius: 2, - offset: const Offset(0, 1), - ), - ], ), child: Row( children: [ diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart index 48cca943..dc408d94 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart @@ -14,9 +14,13 @@ import '../widgets/shift_location_map.dart'; class ShiftDetailsPage extends StatefulWidget { final String shiftId; - final Shift? shift; + final Shift shift; - const ShiftDetailsPage({super.key, required this.shiftId, this.shift}); + const ShiftDetailsPage({ + super.key, + required this.shiftId, + required this.shift, + }); @override State createState() => _ShiftDetailsPageState(); @@ -86,12 +90,11 @@ class _ShiftDetailsPageState extends State { const SizedBox(height: UiConstants.space2), Text( value, - style: UiTypography.title1m.copyWith(fontWeight: FontWeight.w700).textPrimary, - ), - Text( - label, - style: UiTypography.footnote2r.textSecondary, + style: UiTypography.title1m + .copyWith(fontWeight: FontWeight.w700) + .textPrimary, ), + Text(label, style: UiTypography.footnote2r.textSecondary), ], ), ); @@ -109,12 +112,16 @@ class _ShiftDetailsPageState extends State { Text( label, style: UiTypography.footnote2b.copyWith( - color: UiColors.textSecondary, letterSpacing: 0.5), + color: UiColors.textSecondary, + letterSpacing: 0.5, + ), ), const SizedBox(height: UiConstants.space1), Text( _formatTime(time), - style: UiTypography.title1m.copyWith(fontWeight: FontWeight.w700).textPrimary, + style: UiTypography.title1m + .copyWith(fontWeight: FontWeight.w700) + .textPrimary, ), ], ), @@ -124,14 +131,10 @@ class _ShiftDetailsPageState extends State { @override Widget build(BuildContext context) { return BlocProvider( - create: (_) => - Modular.get() - ..add( - LoadShiftDetailsEvent( - widget.shiftId, - roleId: widget.shift?.roleId, - ), - ), + create: (_) => Modular.get() + ..add( + LoadShiftDetailsEvent(widget.shiftId, roleId: widget.shift?.roleId), + ), child: BlocListener( listener: (context, state) { if (state is ShiftActionSuccess || state is ShiftDetailsError) { @@ -164,30 +167,16 @@ class _ShiftDetailsPageState extends State { ); } - Shift? displayShift; - if (state is ShiftDetailsLoaded) { - displayShift = state.shift; - } else { - displayShift = widget.shift; - } + Shift? displayShift = widget.shift; final i18n = Translations.of(context).staff_shifts.shift_details; - if (displayShift == null) { - return Scaffold( - body: Center(child: Text(Translations.of(context).staff_shifts.list.no_shifts)), - ); - } final duration = _calculateDuration(displayShift); final estimatedTotal = displayShift.totalValue ?? (displayShift.hourlyRate * duration); - final openSlots = - (displayShift.requiredSlots ?? 0) - - (displayShift.filledSlots ?? 0); return Scaffold( appBar: UiAppBar( - title: displayShift.title, centerTitle: false, onLeadingPressed: () => Modular.to.toShifts(), ), @@ -199,44 +188,49 @@ class _ShiftDetailsPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Vendor Section - Column( + // Role & Client Section + Row( crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space4, children: [ - Text( - i18n.vendor, - style: UiTypography.titleUppercase4b.textSecondary, + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: UiColors.background, + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), + border: Border.all(color: UiColors.border), + ), + child: const Center( + child: Icon( + UiIcons.briefcase, + color: UiColors.primary, + size: 24, + ), + ), ), - const SizedBox(height: UiConstants.space2), - Row( - children: [ - SizedBox( - width: 24, - height: 24, - child: displayShift.logoUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular( - UiConstants.radiusMdValue, - ), - child: Image.network( - displayShift.logoUrl!, - fit: BoxFit.cover, - ), - ) - : const Center( - child: Icon( - UiIcons.briefcase, - color: UiColors.primary, - size: 20, - ), - ), - ), - const SizedBox(width: UiConstants.space2), - Text( - displayShift.clientName, - style: UiTypography.headline5m.textPrimary, - ), - ], + + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayShift.title, + style: + UiTypography.headline1b.textPrimary, + ), + Text( + displayShift.clientName, + style: UiTypography.body1m.textSecondary, + ), + Text( + displayShift.locationAddress, + style: UiTypography.body2r.textSecondary, + ), + ], + ), ), ], ), @@ -248,7 +242,8 @@ class _ShiftDetailsPageState extends State { children: [ Text( i18n.shift_date, - style: UiTypography.titleUppercase4b.textSecondary, + style: + UiTypography.titleUppercase4b.textSecondary, ), const SizedBox(height: UiConstants.space2), Row( @@ -276,7 +271,7 @@ class _ShiftDetailsPageState extends State { child: _buildStatCard( UiIcons.dollar, "\$${estimatedTotal.toStringAsFixed(0)}", - "Total", + "Total", ), ), const SizedBox(width: UiConstants.space4), @@ -291,7 +286,7 @@ class _ShiftDetailsPageState extends State { Expanded( child: _buildStatCard( UiIcons.clock, - "${duration.toStringAsFixed(1)}", + "${duration.toStringAsFixed(1)}", "Hours", ), ), @@ -319,14 +314,14 @@ class _ShiftDetailsPageState extends State { ), const SizedBox(height: UiConstants.space6), - // Location Section (New with Map) Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "LOCATION", - style: UiTypography.titleUppercase4b.textSecondary, + style: + UiTypography.titleUppercase4b.textSecondary, ), const SizedBox(height: UiConstants.space3), Row( @@ -350,13 +345,13 @@ class _ShiftDetailsPageState extends State { ).showSnackBar( SnackBar( content: Text( - displayShift!.locationAddress.isNotEmpty - ? displayShift!.locationAddress - : displayShift!.location, - ), - duration: const Duration( - seconds: 3, + displayShift! + .locationAddress + .isNotEmpty + ? displayShift!.locationAddress + : displayShift!.location, ), + duration: const Duration(seconds: 3), ), ); }, @@ -364,12 +359,9 @@ class _ShiftDetailsPageState extends State { UiIcons.navigation, size: UiConstants.iconXs, ), - label: const Text( - "Get direction", - ), + label: const Text("Get direction"), style: OutlinedButton.styleFrom( - foregroundColor: - UiColors.textPrimary, + foregroundColor: UiColors.textPrimary, side: const BorderSide( color: UiColors.border, ), @@ -401,7 +393,8 @@ class _ShiftDetailsPageState extends State { if ((displayShift.description ?? '').isNotEmpty) ...[ Text( i18n.job_description, - style: UiTypography.titleUppercase4b.textSecondary, + style: + UiTypography.titleUppercase4b.textSecondary, ), const SizedBox(height: UiConstants.space2), Text( @@ -420,7 +413,8 @@ class _ShiftDetailsPageState extends State { UiConstants.space5, UiConstants.space4, UiConstants.space5, - MediaQuery.of(context).padding.bottom + UiConstants.space4, + MediaQuery.of(context).padding.bottom + + UiConstants.space4, ), decoration: BoxDecoration( color: UiColors.white, @@ -444,11 +438,10 @@ class _ShiftDetailsPageState extends State { ); } - void _bookShift( - BuildContext context, - Shift shift, - ) { - final i18n = Translations.of(context).staff_shifts.shift_details.book_dialog; + void _bookShift(BuildContext context, Shift shift) { + final i18n = Translations.of( + context, + ).staff_shifts.shift_details.book_dialog; showDialog( context: context, builder: (ctx) => AlertDialog( @@ -471,10 +464,10 @@ class _ShiftDetailsPageState extends State { ), ); }, - style: TextButton.styleFrom( - foregroundColor: UiColors.success, + style: TextButton.styleFrom(foregroundColor: UiColors.success), + child: Text( + Translations.of(context).staff_shifts.shift_details.apply_now, ), - child: Text(Translations.of(context).staff_shifts.shift_details.apply_now), ), ], ), @@ -482,7 +475,9 @@ class _ShiftDetailsPageState extends State { } void _declineShift(BuildContext context, String id) { - final i18n = Translations.of(context).staff_shifts.shift_details.decline_dialog; + final i18n = Translations.of( + context, + ).staff_shifts.shift_details.decline_dialog; showDialog( context: context, builder: (ctx) => AlertDialog( @@ -499,10 +494,10 @@ class _ShiftDetailsPageState extends State { context, ).add(DeclineShiftDetailsEvent(id)); }, - style: TextButton.styleFrom( - foregroundColor: UiColors.destructive, + style: TextButton.styleFrom(foregroundColor: UiColors.destructive), + child: Text( + Translations.of(context).staff_shifts.shift_details.decline, ), - child: Text(Translations.of(context).staff_shifts.shift_details.decline), ), ], ), @@ -513,7 +508,9 @@ class _ShiftDetailsPageState extends State { if (_actionDialogOpen) return; _actionDialogOpen = true; _isApplying = true; - final i18n = Translations.of(context).staff_shifts.shift_details.applying_dialog; + final i18n = Translations.of( + context, + ).staff_shifts.shift_details.applying_dialog; showDialog( context: context, useRootNavigator: true, @@ -590,10 +587,14 @@ class _ShiftDetailsPageState extends State { children: [ Expanded( child: OutlinedButton( - onPressed: () => BlocProvider.of(context).add(DeclineShiftDetailsEvent(shift.id)), + onPressed: () => BlocProvider.of( + context, + ).add(DeclineShiftDetailsEvent(shift.id)), style: OutlinedButton.styleFrom( foregroundColor: UiColors.destructive, - padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space4, + ), side: const BorderSide(color: UiColors.destructive), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(UiConstants.radiusBase), @@ -605,11 +606,15 @@ class _ShiftDetailsPageState extends State { const SizedBox(width: UiConstants.space4), Expanded( child: ElevatedButton( - onPressed: () => BlocProvider.of(context).add(BookShiftDetailsEvent(shift.id, roleId: shift.roleId)), + onPressed: () => BlocProvider.of( + context, + ).add(BookShiftDetailsEvent(shift.id, roleId: shift.roleId)), style: ElevatedButton.styleFrom( backgroundColor: UiColors.primary, foregroundColor: UiColors.white, - padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space4, + ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(UiConstants.radiusBase), ), @@ -630,13 +635,18 @@ class _ShiftDetailsPageState extends State { onPressed: () => _declineShift(context, shift.id), style: OutlinedButton.styleFrom( foregroundColor: UiColors.textSecondary, - padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space4, + ), side: const BorderSide(color: UiColors.border), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(UiConstants.radiusBase), ), ), - child: Text(i18n.decline, style: UiTypography.body2b.textSecondary), + child: Text( + i18n.decline, + style: UiTypography.body2b.textSecondary, + ), ), ), const SizedBox(width: UiConstants.space4), @@ -646,7 +656,9 @@ class _ShiftDetailsPageState extends State { style: ElevatedButton.styleFrom( backgroundColor: UiColors.primary, foregroundColor: UiColors.white, - padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space4, + ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(UiConstants.radiusBase), ), @@ -661,5 +673,4 @@ class _ShiftDetailsPageState extends State { return const SizedBox(); } - } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart index 3f360efd..1b6e1592 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart @@ -29,7 +29,6 @@ class _ShiftsPageState extends State { super.initState(); _activeTab = widget.initialTab ?? 'myshifts'; _selectedDate = widget.selectedDate; - print('ShiftsPage init: initialTab=$_activeTab'); _prioritizeFind = widget.initialTab == 'find'; if (_prioritizeFind) { _bloc.add(LoadFindFirstEvent()); @@ -37,11 +36,9 @@ class _ShiftsPageState extends State { _bloc.add(LoadShiftsEvent()); } if (_activeTab == 'history') { - print('ShiftsPage init: loading history tab'); _bloc.add(LoadHistoryShiftsEvent()); } if (_activeTab == 'find') { - print('ShiftsPage init: entering find tab (not loaded yet)'); if (!_prioritizeFind) { _bloc.add(LoadAvailableShiftsEvent()); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/styles/shifts_styles.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/styles/shifts_styles.dart deleted file mode 100644 index 0a9a3675..00000000 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/styles/shifts_styles.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:design_system/design_system.dart'; - -class AppColors { - static const Color krowBlue = UiColors.primary; - static const Color krowYellow = UiColors.accent; - static const Color krowCharcoal = UiColors.textPrimary; - static const Color krowMuted = UiColors.textSecondary; - static const Color krowBorder = UiColors.border; - static const Color krowBackground = UiColors.background; - static const Color white = UiColors.white; - static const Color black = UiColors.black; -} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shared/empty_state_view.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shared/empty_state_view.dart index 63569050..42b506cf 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shared/empty_state_view.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shared/empty_state_view.dart @@ -1,6 +1,5 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import '../../styles/shifts_styles.dart'; class EmptyStateView extends StatelessWidget { final IconData icon; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart index 95d1f7db..bb426fd7 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/find_shifts_tab.dart @@ -10,10 +10,7 @@ import '../shared/empty_state_view.dart'; class FindShiftsTab extends StatefulWidget { final List availableJobs; - const FindShiftsTab({ - super.key, - required this.availableJobs, - }); + const FindShiftsTab({super.key, required this.availableJobs}); @override State createState() => _FindShiftsTabState(); @@ -42,7 +39,9 @@ class _FindShiftsTabState extends State { child: Text( label, textAlign: TextAlign.center, - style: (isSelected ? UiTypography.footnote2m.white : UiTypography.footnote2m.textSecondary), + style: (isSelected + ? UiTypography.footnote2m.white + : UiTypography.footnote2m.textSecondary), ), ), ); @@ -86,13 +85,15 @@ class _FindShiftsTabState extends State { Expanded( child: Container( height: 48, - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + ), decoration: BoxDecoration( color: UiColors.background, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all( - color: UiColors.border, + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, ), + border: Border.all(color: UiColors.border), ), child: Row( children: [ @@ -123,10 +124,10 @@ class _FindShiftsTabState extends State { width: 48, decoration: BoxDecoration( color: UiColors.white, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all( - color: UiColors.border, + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, ), + border: Border.all(color: UiColors.border), ), child: const Icon( UiIcons.filter, @@ -164,20 +165,27 @@ class _FindShiftsTabState extends State { subtitle: "Check back later", ) : SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), child: Column( children: [ const SizedBox(height: UiConstants.space5), ...filteredJobs.map( (shift) => Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space3), + padding: const EdgeInsets.only( + bottom: UiConstants.space3, + ), child: MyShiftCard( shift: shift, onAccept: () { - context.read().add(AcceptShiftEvent(shift.id)); + context.read().add( + AcceptShiftEvent(shift.id), + ); UiSnackbar.show( context, - message: "Shift application submitted!", // Todo: Localization + message: + "Shift application submitted!", // Todo: Localization type: UiSnackbarType.success, ); }, diff --git a/apps/mobile/packages/features/staff/shifts/pubspec.yaml b/apps/mobile/packages/features/staff/shifts/pubspec.yaml index 99360d7a..4a6d7070 100644 --- a/apps/mobile/packages/features/staff/shifts/pubspec.yaml +++ b/apps/mobile/packages/features/staff/shifts/pubspec.yaml @@ -27,6 +27,8 @@ dependencies: path: ../../../data_connect core_localization: path: ../../../core_localization + firebase_auth: ^6.1.4 + firebase_data_connect: ^0.2.2+2 dev_dependencies: flutter_test: From 2a0b39926a4834d7f6e741228777981a496b4b01 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 13:09:10 -0500 Subject: [PATCH 05/53] refactor: Update UI theme and shift details layout for improved consistency --- .../design_system/lib/src/ui_theme.dart | 2 +- .../pages/shift_details_page.dart | 453 +++++++++--------- 2 files changed, 231 insertions(+), 224 deletions(-) diff --git a/apps/mobile/packages/design_system/lib/src/ui_theme.dart b/apps/mobile/packages/design_system/lib/src/ui_theme.dart index 98ee7214..919a78a0 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_theme.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_theme.dart @@ -40,7 +40,7 @@ class UiTheme { dividerTheme: const DividerThemeData( color: UiColors.separatorPrimary, space: 1, - thickness: 1, + thickness: 0.5, ), // Card Theme diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart index dc408d94..7b8b05a5 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart @@ -72,9 +72,8 @@ class _ShiftDetailsPageState extends State { return Container( padding: const EdgeInsets.symmetric(vertical: UiConstants.space3), decoration: BoxDecoration( - color: UiColors.background, - borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), - border: Border.all(color: UiColors.border), + color: UiColors.bgThird, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), ), child: Column( children: [ @@ -104,8 +103,8 @@ class _ShiftDetailsPageState extends State { return Container( padding: const EdgeInsets.all(UiConstants.space3), decoration: BoxDecoration( - color: UiColors.background, - borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + color: UiColors.bgThird, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), ), child: Column( children: [ @@ -184,229 +183,265 @@ class _ShiftDetailsPageState extends State { children: [ Expanded( child: SingleChildScrollView( - padding: const EdgeInsets.all(UiConstants.space5), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Role & Client Section - Row( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: UiConstants.space4, - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: UiColors.background, - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space4, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: UiColors.background, + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), + border: Border.all(color: UiColors.border), ), - border: Border.all(color: UiColors.border), - ), - child: const Center( - child: Icon( - UiIcons.briefcase, - color: UiColors.primary, - size: 24, + child: const Center( + child: Icon( + UiIcons.briefcase, + color: UiColors.primary, + size: 24, + ), ), ), - ), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + displayShift.title, + style: + UiTypography.headline1b.textPrimary, + ), + Text( + displayShift.clientName, + style: + UiTypography.body1m.textSecondary, + ), + Text( + displayShift.locationAddress, + style: + UiTypography.body2r.textSecondary, + ), + ], + ), + ), + ], + ), + ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + const Divider(height: 1, thickness: 0.5), + + // Date Section + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + i18n.shift_date, + style: UiTypography + .titleUppercase4b + .textSecondary, + ), + const SizedBox(height: UiConstants.space2), + Row( children: [ + const Icon( + UiIcons.calendar, + size: 20, + color: UiColors.primary, + ), + const SizedBox(width: UiConstants.space2), Text( - displayShift.title, + _formatDate(displayShift.date), style: - UiTypography.headline1b.textPrimary, - ), - Text( - displayShift.clientName, - style: UiTypography.body1m.textSecondary, - ), - Text( - displayShift.locationAddress, - style: UiTypography.body2r.textSecondary, + UiTypography.headline5m.textPrimary, ), ], ), - ), - ], + ], + ), ), - const SizedBox(height: UiConstants.space6), - // Date Section - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - i18n.shift_date, - style: - UiTypography.titleUppercase4b.textSecondary, - ), - const SizedBox(height: UiConstants.space2), - Row( - children: [ - const Icon( - UiIcons.calendar, - size: 20, - color: UiColors.primary, - ), - const SizedBox(width: UiConstants.space2), - Text( - _formatDate(displayShift.date), - style: UiTypography.headline5m.textPrimary, - ), - ], - ), - ], - ), - const SizedBox(height: UiConstants.space6), + const Divider(height: 1, thickness: 0.5), // Stats Row (New) - Row( - children: [ - Expanded( - child: _buildStatCard( - UiIcons.dollar, - "\$${estimatedTotal.toStringAsFixed(0)}", - "Total", + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Row( + children: [ + Expanded( + child: _buildStatCard( + UiIcons.dollar, + "\$${estimatedTotal.toStringAsFixed(0)}", + "Total", + ), ), - ), - const SizedBox(width: UiConstants.space4), - Expanded( - child: _buildStatCard( - UiIcons.dollar, - "\$${displayShift.hourlyRate.toStringAsFixed(0)}", - "Hourly Rate", + const SizedBox(width: UiConstants.space4), + Expanded( + child: _buildStatCard( + UiIcons.dollar, + "\$${displayShift.hourlyRate.toStringAsFixed(0)}", + "Hourly Rate", + ), ), - ), - const SizedBox(width: UiConstants.space4), - Expanded( - child: _buildStatCard( - UiIcons.clock, - "${duration.toStringAsFixed(1)}", - "Hours", + const SizedBox(width: UiConstants.space4), + Expanded( + child: _buildStatCard( + UiIcons.clock, + duration.toStringAsFixed(1), + "Hours", + ), ), - ), - ], + ], + ), ), - const SizedBox(height: UiConstants.space6), + + const Divider(height: 1, thickness: 0.5), // Time Section (New) - Row( - children: [ - Expanded( - child: _buildTimeBox( - "CLOCK IN TIME", - displayShift.startTime, + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Row( + children: [ + Expanded( + child: _buildTimeBox( + "CLOCK IN TIME", + displayShift.startTime, + ), ), - ), - const SizedBox(width: UiConstants.space4), - Expanded( - child: _buildTimeBox( - "CLOCK OUT TIME", - displayShift.endTime, + const SizedBox(width: UiConstants.space4), + Expanded( + child: _buildTimeBox( + "CLOCK OUT TIME", + displayShift.endTime, + ), ), - ), - ], + ], + ), ), - const SizedBox(height: UiConstants.space6), + + const Divider(height: 1, thickness: 0.5), // Location Section (New with Map) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "LOCATION", - style: - UiTypography.titleUppercase4b.textSecondary, - ), - const SizedBox(height: UiConstants.space3), - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - displayShift.location.isEmpty - ? "TBD" - : displayShift.location, - style: UiTypography.title1m.textPrimary, - overflow: TextOverflow.ellipsis, + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "LOCATION", + style: UiTypography + .titleUppercase4b + .textSecondary, + ), + const SizedBox(height: UiConstants.space3), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + displayShift.location.isEmpty + ? "TBD" + : displayShift.location, + style: UiTypography.title1m.textPrimary, + overflow: TextOverflow.ellipsis, + ), ), - ), - const SizedBox(width: UiConstants.space3), - OutlinedButton.icon( - onPressed: () { - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - content: Text( - displayShift! - .locationAddress - .isNotEmpty - ? displayShift!.locationAddress - : displayShift!.location, + const SizedBox(width: UiConstants.space3), + OutlinedButton.icon( + onPressed: () { + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: Text( + displayShift! + .locationAddress + .isNotEmpty + ? displayShift! + .locationAddress + : displayShift!.location, + ), + duration: const Duration( + seconds: 3, + ), ), - duration: const Duration(seconds: 3), + ); + }, + icon: const Icon( + UiIcons.navigation, + size: UiConstants.iconXs, + ), + label: const Text("Get direction"), + style: OutlinedButton.styleFrom( + foregroundColor: UiColors.textPrimary, + side: const BorderSide( + color: UiColors.border, ), - ); - }, - icon: const Icon( - UiIcons.navigation, - size: UiConstants.iconXs, - ), - label: const Text("Get direction"), - style: OutlinedButton.styleFrom( - foregroundColor: UiColors.textPrimary, - side: const BorderSide( - color: UiColors.border, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), ), + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: 0, + ), + minimumSize: const Size(0, 32), ), - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space3, - vertical: 0, - ), - minimumSize: const Size(0, 32), ), - ), - ], - ), - const SizedBox(height: UiConstants.space3), - ShiftLocationMap( - shift: displayShift, - height: 160, - borderRadius: UiConstants.radiusBase, - ), - ], + ], + ), + const SizedBox(height: UiConstants.space3), + ShiftLocationMap( + shift: displayShift, + height: 160, + borderRadius: UiConstants.radiusBase, + ), + ], + ), ), - const SizedBox(height: UiConstants.space8), + + const Divider(height: 1, thickness: 0.5), // Description / Instructions if ((displayShift.description ?? '').isNotEmpty) ...[ - Text( - i18n.job_description, - style: - UiTypography.titleUppercase4b.textSecondary, + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + i18n.job_description, + style: UiTypography + .titleUppercase4b + .textSecondary, + ), + const SizedBox(height: UiConstants.space2), + Text( + displayShift.description!, + style: UiTypography.body2r.textSecondary, + ), + ], + ), ), - const SizedBox(height: UiConstants.space2), - Text( - displayShift.description!, - style: UiTypography.body2r.textSecondary, - ), - const SizedBox(height: UiConstants.space8), ], ], ), ), ), + // Bottom Action Bar Container( padding: EdgeInsets.fromLTRB( @@ -421,7 +456,7 @@ class _ShiftDetailsPageState extends State { border: Border(top: BorderSide(color: UiColors.border)), boxShadow: [ BoxShadow( - color: UiColors.black.withValues(alpha: 0.05), + color: UiColors.popupShadow.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, -4), ), @@ -628,46 +663,18 @@ class _ShiftDetailsPageState extends State { } if (status == 'open' || status == 'available') { - return Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () => _declineShift(context, shift.id), - style: OutlinedButton.styleFrom( - foregroundColor: UiColors.textSecondary, - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space4, - ), - side: const BorderSide(color: UiColors.border), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - ), - child: Text( - i18n.decline, - style: UiTypography.body2b.textSecondary, - ), - ), + return ElevatedButton( + onPressed: () => _bookShift(context, shift), + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.primary, + foregroundColor: UiColors.white, + padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), ), - const SizedBox(width: UiConstants.space4), - Expanded( - child: ElevatedButton( - onPressed: () => _bookShift(context, shift), - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.primary, - foregroundColor: UiColors.white, - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space4, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - elevation: 0, - ), - child: Text(i18n.apply_now, style: UiTypography.body2b.white), - ), - ), - ], + elevation: 0, + ), + child: Text(i18n.apply_now, style: UiTypography.body2b.white), ); } From 9b6cad3bdecfda73d3a4514f03b3828359262f82 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 13:26:04 -0500 Subject: [PATCH 06/53] feat(breaks): Implement break functionality with Break entity and adapter --- .../packages/domain/lib/krow_domain.dart | 2 + .../adapters/shifts/break/break_adapter.dart | 39 ++++++++ .../lib/src/entities/shifts/break/break.dart | 47 ++++++++++ .../domain/lib/src/entities/shifts/shift.dart | 71 ++++++++------- .../shifts_repository_impl.dart | 24 +++++ .../pages/shift_details_page.dart | 90 ++++++------------- .../connector/application/queries.gql | 7 +- 7 files changed, 180 insertions(+), 100 deletions(-) create mode 100644 apps/mobile/packages/domain/lib/src/adapters/shifts/break/break_adapter.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/shifts/break/break.dart diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index 3dc41679..bbe513ae 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -30,6 +30,8 @@ export 'src/entities/events/work_session.dart'; // Shifts export 'src/entities/shifts/shift.dart'; export 'src/adapters/shifts/shift_adapter.dart'; +export 'src/entities/shifts/break/break.dart'; +export 'src/adapters/shifts/break/break_adapter.dart'; // Orders & Requests export 'src/entities/orders/order_type.dart'; diff --git a/apps/mobile/packages/domain/lib/src/adapters/shifts/break/break_adapter.dart b/apps/mobile/packages/domain/lib/src/adapters/shifts/break/break_adapter.dart new file mode 100644 index 00000000..59f46949 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/adapters/shifts/break/break_adapter.dart @@ -0,0 +1,39 @@ +import '../../../entities/shifts/break/break.dart'; + +/// Adapter for Break related data. +class BreakAdapter { + /// Maps break data to a Break entity. + /// + /// [isPaid] whether the break is paid. + /// [breakTime] the string representation of the break duration (e.g., 'MIN_10', 'MIN_30'). + static Break fromData({ + required bool isPaid, + required String? breakTime, + }) { + return Break( + isBreakPaid: isPaid, + duration: _parseDuration(breakTime), + ); + } + + static BreakDuration _parseDuration(String? breakTime) { + if (breakTime == null) return BreakDuration.none; + + switch (breakTime.toUpperCase()) { + case 'MIN_10': + return BreakDuration.ten; + case 'MIN_15': + return BreakDuration.fifteen; + case 'MIN_20': + return BreakDuration.twenty; + case 'MIN_30': + return BreakDuration.thirty; + case 'MIN_45': + return BreakDuration.fortyFive; + case 'MIN_60': + return BreakDuration.sixty; + default: + return BreakDuration.none; + } + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/break/break.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/break/break.dart new file mode 100644 index 00000000..b90750bd --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/break/break.dart @@ -0,0 +1,47 @@ +import 'package:equatable/equatable.dart'; + +/// Enum representing common break durations in minutes. +enum BreakDuration { + /// No break. + none(0), + + /// 10 minutes break. + ten(10), + + /// 15 minutes break. + fifteen(15), + + /// 20 minutes break. + twenty(20), + + /// 30 minutes break. + thirty(30), + + /// 45 minutes break. + fortyFive(45), + + /// 60 minutes break. + sixty(60); + + /// The duration in minutes. + final int minutes; + + const BreakDuration(this.minutes); +} + +/// Represents a break configuration for a shift. +class Break extends Equatable { + const Break({ + required this.duration, + required this.isBreakPaid, + }); + + /// The duration of the break. + final BreakDuration duration; + + /// Whether the break is paid or unpaid. + final bool isBreakPaid; + + @override + List get props => [duration, isBreakPaid]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart index 5ef22e1e..e24d6477 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/shift.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/src/entities/shifts/break/break.dart'; class Shift extends Equatable { final String id; @@ -29,6 +30,7 @@ class Shift extends Equatable { final String? roleId; final bool? hasApplied; final double? totalValue; + final Break? breakInfo; const Shift({ required this.id, @@ -59,48 +61,49 @@ class Shift extends Equatable { this.roleId, this.hasApplied, this.totalValue, + this.breakInfo, }); @override - List get props => [ - id, - title, - clientName, - logoUrl, - hourlyRate, - location, - locationAddress, - date, - startTime, - endTime, - createdDate, - tipsAvailable, - travelTime, - mealProvided, - parkingAvailable, - gasCompensation, - description, - instructions, - managers, - latitude, - longitude, - status, - durationDays, - requiredSlots, - filledSlots, - roleId, - hasApplied, - totalValue, - ]; + List get props => [ + id, + title, + clientName, + logoUrl, + hourlyRate, + location, + locationAddress, + date, + startTime, + endTime, + createdDate, + tipsAvailable, + travelTime, + mealProvided, + parkingAvailable, + gasCompensation, + description, + instructions, + managers, + latitude, + longitude, + status, + durationDays, + requiredSlots, + filledSlots, + roleId, + hasApplied, + totalValue, + breakInfo, + ]; } class ShiftManager extends Equatable { + const ShiftManager({required this.name, required this.phone, this.avatar}); + final String name; final String phone; final String? avatar; - - const ShiftManager({required this.name, required this.phone, this.avatar}); - @override - List get props => [name, phone, avatar]; + List get props => [name, phone, avatar]; } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart index a1588633..5700c60a 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart @@ -141,6 +141,10 @@ class ShiftsRepositoryImpl requiredSlots: app.shiftRole.count, filledSlots: app.shiftRole.assigned ?? 0, hasApplied: true, + breakInfo: BreakAdapter.fromData( + isPaid: app.shiftRole.isBreakPaid ?? false, + breakTime: app.shiftRole.breakType?.stringValue, + ), ), ); } @@ -208,6 +212,10 @@ class ShiftsRepositoryImpl requiredSlots: app.shiftRole.count, filledSlots: app.shiftRole.assigned ?? 0, hasApplied: true, + breakInfo: BreakAdapter.fromData( + isPaid: app.shiftRole.isBreakPaid ?? false, + breakTime: app.shiftRole.breakType?.stringValue, + ), ), ); } @@ -277,6 +285,10 @@ class ShiftsRepositoryImpl durationDays: sr.shift.durationDays, requiredSlots: sr.count, filledSlots: sr.assigned ?? 0, + breakInfo: BreakAdapter.fromData( + isPaid: sr.isBreakPaid ?? false, + breakTime: sr.breakType?.stringValue, + ), ), ); } @@ -350,6 +362,10 @@ class ShiftsRepositoryImpl filledSlots: sr.assigned ?? 0, hasApplied: hasApplied, totalValue: sr.totalValue, + breakInfo: BreakAdapter.fromData( + isPaid: sr.isBreakPaid ?? false, + breakTime: sr.breakType?.stringValue, + ), ); } @@ -360,6 +376,7 @@ class ShiftsRepositoryImpl int? required; int? filled; + Break? breakInfo; try { final rolesRes = await executeProtected(() => _dataConnect.listShiftRolesByShiftId(shiftId: shiftId).execute()); @@ -370,6 +387,12 @@ class ShiftsRepositoryImpl required = (required ?? 0) + r.count; filled = (filled ?? 0) + (r.assigned ?? 0); } + // Use the first role's break info as a representative + final firstRole = rolesRes.data.shiftRoles.first; + breakInfo = BreakAdapter.fromData( + isPaid: firstRole.isBreakPaid ?? false, + breakTime: firstRole.breakType?.stringValue, + ); } } catch (_) {} @@ -394,6 +417,7 @@ class ShiftsRepositoryImpl durationDays: s.durationDays, requiredSlots: required, filledSlots: filled, + breakInfo: breakInfo, ); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart index 7b8b05a5..7ca8f8ca 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart @@ -132,7 +132,7 @@ class _ShiftDetailsPageState extends State { return BlocProvider( create: (_) => Modular.get() ..add( - LoadShiftDetailsEvent(widget.shiftId, roleId: widget.shift?.roleId), + LoadShiftDetailsEvent(widget.shiftId, roleId: widget.shift.roleId), ), child: BlocListener( listener: (context, state) { @@ -148,7 +148,7 @@ class _ShiftDetailsPageState extends State { ); Modular.to.toShifts(selectedDate: state.shiftDate); } else if (state is ShiftDetailsError) { - if (_isApplying || widget.shift == null) { + if (_isApplying) { UiSnackbar.show( context, message: translateErrorKey(state.message), @@ -240,7 +240,7 @@ class _ShiftDetailsPageState extends State { const Divider(height: 1, thickness: 0.5), - // Date Section + // Date & Time Section Padding( padding: const EdgeInsets.all(UiConstants.space5), child: Column( @@ -248,8 +248,7 @@ class _ShiftDetailsPageState extends State { children: [ Text( i18n.shift_date, - style: UiTypography - .titleUppercase4b + style: UiTypography.titleUppercase4b .textSecondary, ), const SizedBox(height: UiConstants.space2), @@ -268,6 +267,24 @@ class _ShiftDetailsPageState extends State { ), ], ), + const SizedBox(height: UiConstants.space4), + Row( + children: [ + Expanded( + child: _buildTimeBox( + "CLOCK IN TIME", + displayShift.startTime, + ), + ), + const SizedBox(width: UiConstants.space4), + Expanded( + child: _buildTimeBox( + "CLOCK OUT TIME", + displayShift.endTime, + ), + ), + ], + ), ], ), ), @@ -308,30 +325,6 @@ class _ShiftDetailsPageState extends State { const Divider(height: 1, thickness: 0.5), - // Time Section (New) - Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: Row( - children: [ - Expanded( - child: _buildTimeBox( - "CLOCK IN TIME", - displayShift.startTime, - ), - ), - const SizedBox(width: UiConstants.space4), - Expanded( - child: _buildTimeBox( - "CLOCK OUT TIME", - displayShift.endTime, - ), - ), - ], - ), - ), - - const Divider(height: 1, thickness: 0.5), - // Location Section (New with Map) Padding( padding: const EdgeInsets.all(UiConstants.space5), @@ -344,7 +337,6 @@ class _ShiftDetailsPageState extends State { .titleUppercase4b .textSecondary, ), - const SizedBox(height: UiConstants.space3), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -366,12 +358,10 @@ class _ShiftDetailsPageState extends State { ).showSnackBar( SnackBar( content: Text( - displayShift! - .locationAddress + displayShift.locationAddress .isNotEmpty - ? displayShift! - .locationAddress - : displayShift!.location, + ? displayShift.locationAddress + : displayShift.location, ), duration: const Duration( seconds: 3, @@ -509,36 +499,6 @@ class _ShiftDetailsPageState extends State { ); } - void _declineShift(BuildContext context, String id) { - final i18n = Translations.of( - context, - ).staff_shifts.shift_details.decline_dialog; - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(i18n.title), - content: Text(i18n.message), - actions: [ - TextButton( - onPressed: () => Modular.to.pop(), - child: Text(Translations.of(context).common.cancel), - ), - TextButton( - onPressed: () { - BlocProvider.of( - context, - ).add(DeclineShiftDetailsEvent(id)); - }, - style: TextButton.styleFrom(foregroundColor: UiColors.destructive), - child: Text( - Translations.of(context).staff_shifts.shift_details.decline, - ), - ), - ], - ), - ); - } - void _showApplyingDialog(BuildContext context, Shift shift) { if (_actionDialogOpen) return; _actionDialogOpen = true; diff --git a/backend/dataconnect/connector/application/queries.gql b/backend/dataconnect/connector/application/queries.gql index 44c66795..6d57438f 100644 --- a/backend/dataconnect/connector/application/queries.gql +++ b/backend/dataconnect/connector/application/queries.gql @@ -52,6 +52,8 @@ query listApplications @auth(level: USER) { startTime endTime hours + breakType + isBreakPaid totalValue role { id @@ -341,6 +343,8 @@ query getApplicationsByStaffId( startTime endTime hours + breakType + isBreakPaid totalValue role { id @@ -352,7 +356,6 @@ query getApplicationsByStaffId( } } - query vaidateDayStaffApplication( $staffId: UUID! $offset: Int @@ -692,6 +695,8 @@ query listCompletedApplicationsByStaffId( startTime endTime hours + breakType + isBreakPaid totalValue role { From 55f62207a815c9196a8115e3d92b7aee0e6340a6 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 13:31:21 -0500 Subject: [PATCH 07/53] feat(breaks): Add break duration and payment status to shift details --- .../lib/src/l10n/en.i18n.json | 3 + .../lib/src/l10n/es.i18n.json | 3 + .../pages/shift_details_page.dart | 104 ++++++++++++------ 3 files changed, 77 insertions(+), 33 deletions(-) diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 40b07667..2dc0b1e5 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -999,6 +999,9 @@ "est_total": "Est. Total", "hours_label": "$count hours", "location": "LOCATION", + "break": "BREAK", + "paid": "Paid", + "unpaid": "Unpaid", "open_in_maps": "Open in Maps", "job_description": "JOB DESCRIPTION", "cancel_shift": "CANCEL SHIFT", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 7627b0e3..2c70eb24 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -999,6 +999,9 @@ "est_total": "Total est.", "hours_label": "$count horas", "location": "UBICACIÓN", + "break": "DESCANSO", + "paid": "Pagado", + "unpaid": "No pagado", "open_in_maps": "Abrir en Mapas", "job_description": "DESCRIPCIÓN DEL TRABAJO", "cancel_shift": "CANCELAR TURNO", diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart index 7ca8f8ca..b00740a9 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart @@ -240,6 +240,40 @@ class _ShiftDetailsPageState extends State { const Divider(height: 1, thickness: 0.5), + // Stats Row (New) + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Row( + children: [ + Expanded( + child: _buildStatCard( + UiIcons.dollar, + "\$${estimatedTotal.toStringAsFixed(0)}", + "Total", + ), + ), + const SizedBox(width: UiConstants.space4), + Expanded( + child: _buildStatCard( + UiIcons.dollar, + "\$${displayShift.hourlyRate.toStringAsFixed(0)}", + "Hourly Rate", + ), + ), + const SizedBox(width: UiConstants.space4), + Expanded( + child: _buildStatCard( + UiIcons.clock, + duration.toStringAsFixed(1), + "Hours", + ), + ), + ], + ), + ), + + const Divider(height: 1, thickness: 0.5), + // Date & Time Section Padding( padding: const EdgeInsets.all(UiConstants.space5), @@ -248,7 +282,8 @@ class _ShiftDetailsPageState extends State { children: [ Text( i18n.shift_date, - style: UiTypography.titleUppercase4b + style: UiTypography + .titleUppercase4b .textSecondary, ), const SizedBox(height: UiConstants.space2), @@ -288,42 +323,44 @@ class _ShiftDetailsPageState extends State { ], ), ), - + const Divider(height: 1, thickness: 0.5), - // Stats Row (New) - Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: Row( - children: [ - Expanded( - child: _buildStatCard( - UiIcons.dollar, - "\$${estimatedTotal.toStringAsFixed(0)}", - "Total", + // Break Section + if (displayShift.breakInfo != null && + displayShift.breakInfo!.duration != + BreakDuration.none) ...[ + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "BREAK", + style: UiTypography.titleUppercase4b + .textSecondary, ), - ), - const SizedBox(width: UiConstants.space4), - Expanded( - child: _buildStatCard( - UiIcons.dollar, - "\$${displayShift.hourlyRate.toStringAsFixed(0)}", - "Hourly Rate", + const SizedBox(height: UiConstants.space2), + Row( + children: [ + const Icon( + UiIcons.breakIcon, + size: 20, + color: UiColors.primary, + ), + const SizedBox(width: UiConstants.space2), + Text( + "${displayShift.breakInfo!.duration.minutes} min (${displayShift.breakInfo!.isBreakPaid ? 'Paid' : 'Unpaid'})", + style: + UiTypography.headline5m.textPrimary, + ), + ], ), - ), - const SizedBox(width: UiConstants.space4), - Expanded( - child: _buildStatCard( - UiIcons.clock, - duration.toStringAsFixed(1), - "Hours", - ), - ), - ], + ], + ), ), - ), - - const Divider(height: 1, thickness: 0.5), + const Divider(height: 1, thickness: 0.5), + ], // Location Section (New with Map) Padding( @@ -358,7 +395,8 @@ class _ShiftDetailsPageState extends State { ).showSnackBar( SnackBar( content: Text( - displayShift.locationAddress + displayShift + .locationAddress .isNotEmpty ? displayShift.locationAddress : displayShift.location, From 2f9b2788f8e5c224c2db79ffe4cf913a73239d6b Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 13:33:22 -0500 Subject: [PATCH 08/53] feat(localization): Update English and Spanish translations for shift details and breaks --- .../lib/src/l10n/en.i18n.json | 7 ++++++- .../lib/src/l10n/es.i18n.json | 7 ++++++- .../pages/shift_details_page.dart | 20 +++++++++---------- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 2dc0b1e5..0241ab37 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -999,9 +999,14 @@ "est_total": "Est. Total", "hours_label": "$count hours", "location": "LOCATION", - "break": "BREAK", + "tbd": "TBD", + "get_direction": "Get direction", + "break_title": "BREAK", "paid": "Paid", "unpaid": "Unpaid", + "min": "min", + "hourly_rate": "Hourly Rate", + "hours": "Hours", "open_in_maps": "Open in Maps", "job_description": "JOB DESCRIPTION", "cancel_shift": "CANCEL SHIFT", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 2c70eb24..ee54965e 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -999,9 +999,14 @@ "est_total": "Total est.", "hours_label": "$count horas", "location": "UBICACIÓN", - "break": "DESCANSO", + "tbd": "TBD", + "get_direction": "Obtener dirección", + "break_title": "DESCANSO", "paid": "Pagado", "unpaid": "No pagado", + "min": "min", + "hourly_rate": "Tarifa por hora", + "hours": "Horas", "open_in_maps": "Abrir en Mapas", "job_description": "DESCRIPCIÓN DEL TRABAJO", "cancel_shift": "CANCELAR TURNO", diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart index b00740a9..daab8913 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart @@ -249,7 +249,7 @@ class _ShiftDetailsPageState extends State { child: _buildStatCard( UiIcons.dollar, "\$${estimatedTotal.toStringAsFixed(0)}", - "Total", + i18n.est_total, ), ), const SizedBox(width: UiConstants.space4), @@ -257,7 +257,7 @@ class _ShiftDetailsPageState extends State { child: _buildStatCard( UiIcons.dollar, "\$${displayShift.hourlyRate.toStringAsFixed(0)}", - "Hourly Rate", + i18n.hourly_rate, ), ), const SizedBox(width: UiConstants.space4), @@ -265,7 +265,7 @@ class _ShiftDetailsPageState extends State { child: _buildStatCard( UiIcons.clock, duration.toStringAsFixed(1), - "Hours", + i18n.hours, ), ), ], @@ -307,14 +307,14 @@ class _ShiftDetailsPageState extends State { children: [ Expanded( child: _buildTimeBox( - "CLOCK IN TIME", + i18n.start_time, displayShift.startTime, ), ), const SizedBox(width: UiConstants.space4), Expanded( child: _buildTimeBox( - "CLOCK OUT TIME", + i18n.end_time, displayShift.endTime, ), ), @@ -336,7 +336,7 @@ class _ShiftDetailsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "BREAK", + i18n.break_title, style: UiTypography.titleUppercase4b .textSecondary, ), @@ -350,7 +350,7 @@ class _ShiftDetailsPageState extends State { ), const SizedBox(width: UiConstants.space2), Text( - "${displayShift.breakInfo!.duration.minutes} min (${displayShift.breakInfo!.isBreakPaid ? 'Paid' : 'Unpaid'})", + "${displayShift.breakInfo!.duration.minutes} ${i18n.min} (${displayShift.breakInfo!.isBreakPaid ? i18n.paid : i18n.unpaid})", style: UiTypography.headline5m.textPrimary, ), @@ -369,7 +369,7 @@ class _ShiftDetailsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "LOCATION", + i18n.location, style: UiTypography .titleUppercase4b .textSecondary, @@ -381,7 +381,7 @@ class _ShiftDetailsPageState extends State { Expanded( child: Text( displayShift.location.isEmpty - ? "TBD" + ? i18n.tbd : displayShift.location, style: UiTypography.title1m.textPrimary, overflow: TextOverflow.ellipsis, @@ -411,7 +411,7 @@ class _ShiftDetailsPageState extends State { UiIcons.navigation, size: UiConstants.iconXs, ), - label: const Text("Get direction"), + label: Text(i18n.get_direction), style: OutlinedButton.styleFrom( foregroundColor: UiColors.textPrimary, side: const BorderSide( From 0b787dbc128e14ebd60e602a2654577d3b02644e Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 13:38:39 -0500 Subject: [PATCH 09/53] feat(shift-details): Refactor ShiftDetailsPage layout and implement new sections for breaks, date/time, description, and location --- .../pages/shift_details_page.dart | 574 +++--------------- .../shift_details/shift_break_section.dart | 62 ++ .../shift_date_time_section.dart | 135 ++++ .../shift_description_section.dart | 41 ++ .../shift_details_bottom_bar.dart | 137 +++++ .../shift_details/shift_details_header.dart | 65 ++ .../shift_details/shift_location_section.dart | 94 +++ .../shift_details/shift_stats_row.dart | 99 +++ 8 files changed, 727 insertions(+), 480 deletions(-) create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_break_section.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_date_time_section.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_description_section.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_section.dart create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_stats_row.dart diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart index daab8913..e4563de1 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart @@ -10,7 +10,13 @@ import 'package:krow_domain/krow_domain.dart'; import '../blocs/shift_details/shift_details_bloc.dart'; import '../blocs/shift_details/shift_details_event.dart'; import '../blocs/shift_details/shift_details_state.dart'; -import '../widgets/shift_location_map.dart'; +import '../widgets/shift_details/shift_break_section.dart'; +import '../widgets/shift_details/shift_date_time_section.dart'; +import '../widgets/shift_details/shift_description_section.dart'; +import '../widgets/shift_details/shift_details_bottom_bar.dart'; +import '../widgets/shift_details/shift_details_header.dart'; +import '../widgets/shift_details/shift_location_section.dart'; +import '../widgets/shift_details/shift_stats_row.dart'; class ShiftDetailsPage extends StatefulWidget { final String shiftId; @@ -68,65 +74,6 @@ class _ShiftDetailsPageState extends State { } } - Widget _buildStatCard(IconData icon, String value, String label) { - return Container( - padding: const EdgeInsets.symmetric(vertical: UiConstants.space3), - decoration: BoxDecoration( - color: UiColors.bgThird, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - child: Column( - children: [ - Container( - width: 40, - height: 40, - decoration: const BoxDecoration( - color: UiColors.white, - shape: BoxShape.circle, - ), - child: Icon(icon, size: 20, color: UiColors.textSecondary), - ), - const SizedBox(height: UiConstants.space2), - Text( - value, - style: UiTypography.title1m - .copyWith(fontWeight: FontWeight.w700) - .textPrimary, - ), - Text(label, style: UiTypography.footnote2r.textSecondary), - ], - ), - ); - } - - Widget _buildTimeBox(String label, String time) { - return Container( - padding: const EdgeInsets.all(UiConstants.space3), - decoration: BoxDecoration( - color: UiColors.bgThird, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - child: Column( - children: [ - Text( - label, - style: UiTypography.footnote2b.copyWith( - color: UiColors.textSecondary, - letterSpacing: 0.5, - ), - ), - const SizedBox(height: UiConstants.space1), - Text( - _formatTime(time), - style: UiTypography.title1m - .copyWith(fontWeight: FontWeight.w700) - .textPrimary, - ), - ], - ), - ); - } - @override Widget build(BuildContext context) { return BlocProvider( @@ -134,7 +81,7 @@ class _ShiftDetailsPageState extends State { ..add( LoadShiftDetailsEvent(widget.shiftId, roleId: widget.shift.roleId), ), - child: BlocListener( + child: BlocConsumer( listener: (context, state) { if (state is ShiftActionSuccess || state is ShiftDetailsError) { _closeActionDialog(context); @@ -158,345 +105,99 @@ class _ShiftDetailsPageState extends State { _isApplying = false; } }, - child: BlocBuilder( - builder: (context, state) { - if (state is ShiftDetailsLoading) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); - } + builder: (context, state) { + if (state is ShiftDetailsLoading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } - Shift? displayShift = widget.shift; + Shift displayShift = widget.shift; + final i18n = Translations.of(context).staff_shifts.shift_details; - final i18n = Translations.of(context).staff_shifts.shift_details; + final duration = _calculateDuration(displayShift); + final estimatedTotal = + displayShift.totalValue ?? (displayShift.hourlyRate * duration); - final duration = _calculateDuration(displayShift); - final estimatedTotal = - displayShift.totalValue ?? (displayShift.hourlyRate * duration); - - return Scaffold( - appBar: UiAppBar( - centerTitle: false, - onLeadingPressed: () => Modular.to.toShifts(), - ), - body: Column( - children: [ - Expanded( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Role & Client Section - Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: UiConstants.space4, - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: UiColors.background, - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, - ), - border: Border.all(color: UiColors.border), - ), - child: const Center( - child: Icon( - UiIcons.briefcase, - color: UiColors.primary, - size: 24, - ), - ), - ), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - displayShift.title, - style: - UiTypography.headline1b.textPrimary, - ), - Text( - displayShift.clientName, - style: - UiTypography.body1m.textSecondary, - ), - Text( - displayShift.locationAddress, - style: - UiTypography.body2r.textSecondary, - ), - ], - ), - ), - ], - ), - ), - - const Divider(height: 1, thickness: 0.5), - - // Stats Row (New) - Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: Row( - children: [ - Expanded( - child: _buildStatCard( - UiIcons.dollar, - "\$${estimatedTotal.toStringAsFixed(0)}", - i18n.est_total, - ), - ), - const SizedBox(width: UiConstants.space4), - Expanded( - child: _buildStatCard( - UiIcons.dollar, - "\$${displayShift.hourlyRate.toStringAsFixed(0)}", - i18n.hourly_rate, - ), - ), - const SizedBox(width: UiConstants.space4), - Expanded( - child: _buildStatCard( - UiIcons.clock, - duration.toStringAsFixed(1), - i18n.hours, - ), - ), - ], - ), - ), - - const Divider(height: 1, thickness: 0.5), - - // Date & Time Section - Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - i18n.shift_date, - style: UiTypography - .titleUppercase4b - .textSecondary, - ), - const SizedBox(height: UiConstants.space2), - Row( - children: [ - const Icon( - UiIcons.calendar, - size: 20, - color: UiColors.primary, - ), - const SizedBox(width: UiConstants.space2), - Text( - _formatDate(displayShift.date), - style: - UiTypography.headline5m.textPrimary, - ), - ], - ), - const SizedBox(height: UiConstants.space4), - Row( - children: [ - Expanded( - child: _buildTimeBox( - i18n.start_time, - displayShift.startTime, - ), - ), - const SizedBox(width: UiConstants.space4), - Expanded( - child: _buildTimeBox( - i18n.end_time, - displayShift.endTime, - ), - ), - ], - ), - ], - ), - ), - - const Divider(height: 1, thickness: 0.5), - - // Break Section - if (displayShift.breakInfo != null && - displayShift.breakInfo!.duration != - BreakDuration.none) ...[ - Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - i18n.break_title, - style: UiTypography.titleUppercase4b - .textSecondary, - ), - const SizedBox(height: UiConstants.space2), - Row( - children: [ - const Icon( - UiIcons.breakIcon, - size: 20, - color: UiColors.primary, - ), - const SizedBox(width: UiConstants.space2), - Text( - "${displayShift.breakInfo!.duration.minutes} ${i18n.min} (${displayShift.breakInfo!.isBreakPaid ? i18n.paid : i18n.unpaid})", - style: - UiTypography.headline5m.textPrimary, - ), - ], - ), - ], - ), - ), - const Divider(height: 1, thickness: 0.5), - ], - - // Location Section (New with Map) - Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - i18n.location, - style: UiTypography - .titleUppercase4b - .textSecondary, - ), - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - displayShift.location.isEmpty - ? i18n.tbd - : displayShift.location, - style: UiTypography.title1m.textPrimary, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: UiConstants.space3), - OutlinedButton.icon( - onPressed: () { - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - content: Text( - displayShift - .locationAddress - .isNotEmpty - ? displayShift.locationAddress - : displayShift.location, - ), - duration: const Duration( - seconds: 3, - ), - ), - ); - }, - icon: const Icon( - UiIcons.navigation, - size: UiConstants.iconXs, - ), - label: Text(i18n.get_direction), - style: OutlinedButton.styleFrom( - foregroundColor: UiColors.textPrimary, - side: const BorderSide( - color: UiColors.border, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, - ), - ), - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space3, - vertical: 0, - ), - minimumSize: const Size(0, 32), - ), - ), - ], - ), - const SizedBox(height: UiConstants.space3), - ShiftLocationMap( - shift: displayShift, - height: 160, - borderRadius: UiConstants.radiusBase, - ), - ], - ), - ), - - const Divider(height: 1, thickness: 0.5), - - // Description / Instructions - if ((displayShift.description ?? '').isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - i18n.job_description, - style: UiTypography - .titleUppercase4b - .textSecondary, - ), - const SizedBox(height: UiConstants.space2), - Text( - displayShift.description!, - style: UiTypography.body2r.textSecondary, - ), - ], - ), - ), - ], - ], - ), - ), - ), - - // Bottom Action Bar - Container( - padding: EdgeInsets.fromLTRB( - UiConstants.space5, - UiConstants.space4, - UiConstants.space5, - MediaQuery.of(context).padding.bottom + - UiConstants.space4, - ), - decoration: BoxDecoration( - color: UiColors.white, - border: Border(top: BorderSide(color: UiColors.border)), - boxShadow: [ - BoxShadow( - color: UiColors.popupShadow.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, -4), + return Scaffold( + appBar: UiAppBar( + centerTitle: false, + onLeadingPressed: () => Modular.to.toShifts(), + ), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShiftDetailsHeader(shift: displayShift), + const Divider(height: 1, thickness: 0.5), + ShiftStatsRow( + estimatedTotal: estimatedTotal, + hourlyRate: displayShift.hourlyRate, + duration: duration, + totalLabel: i18n.est_total, + hourlyRateLabel: i18n.hourly_rate, + hoursLabel: i18n.hours, ), + const Divider(height: 1, thickness: 0.5), + ShiftDateTimeSection( + date: displayShift.date, + startTime: displayShift.startTime, + endTime: displayShift.endTime, + shiftDateLabel: i18n.shift_date, + clockInLabel: i18n.start_time, + clockOutLabel: i18n.end_time, + ), + const Divider(height: 1, thickness: 0.5), + if (displayShift.breakInfo != null && + displayShift.breakInfo!.duration != + BreakDuration.none) ...[ + ShiftBreakSection( + breakInfo: displayShift.breakInfo!, + breakTitle: i18n.break_title, + paidLabel: i18n.paid, + unpaidLabel: i18n.unpaid, + minLabel: i18n.min, + ), + const Divider(height: 1, thickness: 0.5), + ], + ShiftLocationSection( + shift: displayShift, + locationLabel: i18n.location, + tbdLabel: i18n.tbd, + getDirectionLabel: i18n.get_direction, + ), + const Divider(height: 1, thickness: 0.5), + if (displayShift.description != null && + displayShift.description!.isNotEmpty) + ShiftDescriptionSection( + description: displayShift.description!, + descriptionLabel: i18n.job_description, + ), ], ), - child: _buildBottomButton(displayShift, context), ), - ], - ), - ); - }, - ), + ), + ShiftDetailsBottomBar( + shift: displayShift, + onApply: () => _bookShift(context, displayShift), + onDecline: () => BlocProvider.of( + context, + ).add(DeclineShiftDetailsEvent(displayShift.id)), + onAccept: () => + BlocProvider.of(context).add( + BookShiftDetailsEvent( + displayShift.id, + roleId: displayShift.roleId, + ), + ), + ), + ], + ), + ); + }, ), ); } @@ -591,91 +292,4 @@ class _ShiftDetailsPageState extends State { Navigator.of(context, rootNavigator: true).pop(); _actionDialogOpen = false; } - - Widget _buildBottomButton(Shift shift, BuildContext context) { - final String status = shift.status ?? 'open'; - - final i18n = Translations.of(context).staff_shifts.shift_details; - if (status == 'confirmed') { - return SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () => Modular.to.toClockIn(), - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.success, - foregroundColor: UiColors.white, - padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - elevation: 0, - ), - child: Text(i18n.clock_in, style: UiTypography.body2b.white), - ), - ); - } - - if (status == 'pending') { - return Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () => BlocProvider.of( - context, - ).add(DeclineShiftDetailsEvent(shift.id)), - style: OutlinedButton.styleFrom( - foregroundColor: UiColors.destructive, - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space4, - ), - side: const BorderSide(color: UiColors.destructive), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - ), - child: Text(i18n.decline, style: UiTypography.body2b.textError), - ), - ), - const SizedBox(width: UiConstants.space4), - Expanded( - child: ElevatedButton( - onPressed: () => BlocProvider.of( - context, - ).add(BookShiftDetailsEvent(shift.id, roleId: shift.roleId)), - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.primary, - foregroundColor: UiColors.white, - padding: const EdgeInsets.symmetric( - vertical: UiConstants.space4, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - elevation: 0, - ), - child: Text(i18n.accept_shift, style: UiTypography.body2b.white), - ), - ), - ], - ); - } - - if (status == 'open' || status == 'available') { - return ElevatedButton( - onPressed: () => _bookShift(context, shift), - style: ElevatedButton.styleFrom( - backgroundColor: UiColors.primary, - foregroundColor: UiColors.white, - padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - elevation: 0, - ), - child: Text(i18n.apply_now, style: UiTypography.body2b.white), - ); - } - - return const SizedBox(); - } } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_break_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_break_section.dart new file mode 100644 index 00000000..50288460 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_break_section.dart @@ -0,0 +1,62 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// A section displaying shift break details (duration and payment status). +class ShiftBreakSection extends StatelessWidget { + /// The break information. + final Break breakInfo; + + /// Localization string for break section title. + final String breakTitle; + + /// Localization string for paid status. + final String paidLabel; + + /// Localization string for unpaid status. + final String unpaidLabel; + + /// Localization string for minutes ("min"). + final String minLabel; + + /// Creates a [ShiftBreakSection]. + const ShiftBreakSection({ + super.key, + required this.breakInfo, + required this.breakTitle, + required this.paidLabel, + required this.unpaidLabel, + required this.minLabel, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + breakTitle, + style: UiTypography.titleUppercase4b.textSecondary, + ), + const SizedBox(height: UiConstants.space2), + Row( + children: [ + const Icon( + UiIcons.breakIcon, + size: 20, + color: UiColors.primary, + ), + const SizedBox(width: UiConstants.space2), + Text( + "${breakInfo.duration.minutes} $minLabel (${breakInfo.isBreakPaid ? paidLabel : unpaidLabel})", + style: UiTypography.headline5m.textPrimary, + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_date_time_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_date_time_section.dart new file mode 100644 index 00000000..47eded2f --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_date_time_section.dart @@ -0,0 +1,135 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +/// A section displaying the date and the shift's start/end times. +class ShiftDateTimeSection extends StatelessWidget { + /// The ISO string of the date. + final String date; + + /// The start time string (HH:mm). + final String startTime; + + /// The end time string (HH:mm). + final String endTime; + + /// Localization string for shift date. + final String shiftDateLabel; + + /// Localization string for clock in time. + final String clockInLabel; + + /// Localization string for clock out time. + final String clockOutLabel; + + /// Creates a [ShiftDateTimeSection]. + const ShiftDateTimeSection({ + super.key, + required this.date, + required this.startTime, + required this.endTime, + required this.shiftDateLabel, + required this.clockInLabel, + required this.clockOutLabel, + }); + + String _formatTime(String time) { + if (time.isEmpty) return ''; + try { + final parts = time.split(':'); + final hour = int.parse(parts[0]); + final minute = int.parse(parts[1]); + final dt = DateTime(2022, 1, 1, hour, minute); + return DateFormat('h:mm a').format(dt); + } catch (e) { + return time; + } + } + + String _formatDate(String dateStr) { + if (dateStr.isEmpty) return ''; + try { + final date = DateTime.parse(dateStr); + return DateFormat('EEEE, MMMM d, y').format(date); + } catch (e) { + return dateStr; + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + shiftDateLabel, + style: UiTypography.titleUppercase4b.textSecondary, + ), + const SizedBox(height: UiConstants.space2), + Row( + children: [ + const Icon( + UiIcons.calendar, + size: 20, + color: UiColors.primary, + ), + const SizedBox(width: UiConstants.space2), + Text( + _formatDate(date), + style: UiTypography.headline5m.textPrimary, + ), + ], + ), + const SizedBox(height: UiConstants.space4), + Row( + children: [ + Expanded( + child: _buildTimeBox( + clockInLabel, + startTime, + ), + ), + const SizedBox(width: UiConstants.space4), + Expanded( + child: _buildTimeBox( + clockOutLabel, + endTime, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildTimeBox(String label, String time) { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.bgThird, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + child: Column( + children: [ + Text( + label, + style: UiTypography.footnote2b.copyWith( + color: UiColors.textSecondary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: UiConstants.space1), + Text( + _formatTime(time), + style: UiTypography.title1m + .copyWith(fontWeight: FontWeight.w700) + .textPrimary, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_description_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_description_section.dart new file mode 100644 index 00000000..770fc3f9 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_description_section.dart @@ -0,0 +1,41 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A section displaying the job description for the shift. +class ShiftDescriptionSection extends StatelessWidget { + /// The description text. + final String description; + + /// Localization string for description section title. + final String descriptionLabel; + + /// Creates a [ShiftDescriptionSection]. + const ShiftDescriptionSection({ + super.key, + required this.description, + required this.descriptionLabel, + }); + + @override + Widget build(BuildContext context) { + if (description.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + descriptionLabel, + style: UiTypography.titleUppercase4b.textSecondary, + ), + const SizedBox(height: UiConstants.space2), + Text( + description, + style: UiTypography.body2r.textSecondary, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart new file mode 100644 index 00000000..00eb9578 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_bottom_bar.dart @@ -0,0 +1,137 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:core_localization/core_localization.dart'; +import 'package:krow_core/core.dart'; + +/// A bottom action bar containing contextual buttons based on shift status. +class ShiftDetailsBottomBar extends StatelessWidget { + /// The current shift. + final Shift shift; + + /// Callback for applying/booking a shift. + final VoidCallback onApply; + + /// Callback for declining a shift. + final VoidCallback onDecline; + + /// Callback for accepting a shift. + final VoidCallback onAccept; + + /// Creates a [ShiftDetailsBottomBar]. + const ShiftDetailsBottomBar({ + super.key, + required this.shift, + required this.onApply, + required this.onDecline, + required this.onAccept, + }); + + @override + Widget build(BuildContext context) { + final String status = shift.status ?? 'open'; + final i18n = Translations.of(context).staff_shifts.shift_details; + + return Container( + padding: EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space4, + UiConstants.space5, + MediaQuery.of(context).padding.bottom + UiConstants.space4, + ), + decoration: BoxDecoration( + color: UiColors.white, + border: Border(top: BorderSide(color: UiColors.border)), + boxShadow: [ + BoxShadow( + color: UiColors.popupShadow.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, -4), + ), + ], + ), + child: _buildButtons(status, i18n, context), + ); + } + + Widget _buildButtons(String status, dynamic i18n, BuildContext context) { + if (status == 'confirmed') { + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Modular.to.toClockIn(), + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.success, + foregroundColor: UiColors.white, + padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + elevation: 0, + ), + child: Text(i18n.clock_in, style: UiTypography.body2b.white), + ), + ); + } + + if (status == 'pending') { + return Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: onDecline, + style: OutlinedButton.styleFrom( + foregroundColor: UiColors.destructive, + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space4, + ), + side: const BorderSide(color: UiColors.destructive), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + ), + child: Text(i18n.decline, style: UiTypography.body2b.textError), + ), + ), + const SizedBox(width: UiConstants.space4), + Expanded( + child: ElevatedButton( + onPressed: onAccept, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.primary, + foregroundColor: UiColors.white, + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space4, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + elevation: 0, + ), + child: Text(i18n.accept_shift, style: UiTypography.body2b.white), + ), + ), + ], + ); + } + + if (status == 'open' || status == 'available') { + return ElevatedButton( + onPressed: onApply, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.primary, + foregroundColor: UiColors.white, + padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + elevation: 0, + ), + child: Text(i18n.apply_now, style: UiTypography.body2b.white), + ); + } + + return const SizedBox.shrink(); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart new file mode 100644 index 00000000..ea8d70db --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_details_header.dart @@ -0,0 +1,65 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// A header widget for the shift details page displaying the role, client name, and address. +class ShiftDetailsHeader extends StatelessWidget { + /// The shift entity containing the header information. + final Shift shift; + + /// Creates a [ShiftDetailsHeader]. + const ShiftDetailsHeader({ + super.key, + required this.shift, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space4, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: UiColors.background, + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), + border: Border.all(color: UiColors.border), + ), + child: const Center( + child: Icon( + UiIcons.briefcase, + color: UiColors.primary, + size: 24, + ), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + shift.title, + style: UiTypography.headline1b.textPrimary, + ), + Text( + shift.clientName, + style: UiTypography.body1m.textSecondary, + ), + Text( + shift.locationAddress, + style: UiTypography.body2r.textSecondary, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_section.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_section.dart new file mode 100644 index 00000000..35656dfd --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_location_section.dart @@ -0,0 +1,94 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../shift_location_map.dart'; + +/// A section displaying the shift's location, address, map, and "Get direction" action. +class ShiftLocationSection extends StatelessWidget { + /// The shift entity containing location data. + final Shift shift; + + /// Localization string for location section title. + final String locationLabel; + + /// Localization string for "TBD". + final String tbdLabel; + + /// Localization string for "Get direction". + final String getDirectionLabel; + + /// Creates a [ShiftLocationSection]. + const ShiftLocationSection({ + super.key, + required this.shift, + required this.locationLabel, + required this.tbdLabel, + required this.getDirectionLabel, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + locationLabel, + style: UiTypography.titleUppercase4b.textSecondary, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + shift.location.isEmpty ? tbdLabel : shift.location, + style: UiTypography.title1m.textPrimary, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: UiConstants.space3), + OutlinedButton.icon( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + shift.locationAddress.isNotEmpty + ? shift.locationAddress + : shift.location, + ), + duration: const Duration(seconds: 3), + ), + ); + }, + icon: const Icon( + UiIcons.navigation, + size: UiConstants.iconXs, + ), + label: Text(getDirectionLabel), + style: OutlinedButton.styleFrom( + foregroundColor: UiColors.textPrimary, + side: const BorderSide(color: UiColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: 0, + ), + minimumSize: const Size(0, 32), + ), + ), + ], + ), + const SizedBox(height: UiConstants.space3), + ShiftLocationMap( + shift: shift, + height: 160, + borderRadius: UiConstants.radiusBase, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_stats_row.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_stats_row.dart new file mode 100644 index 00000000..49d8a8c6 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details/shift_stats_row.dart @@ -0,0 +1,99 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A row of statistic cards for shift details (Total Pay, Rate, Hours). +class ShiftStatsRow extends StatelessWidget { + /// Estimated total pay for the shift. + final double estimatedTotal; + + /// Hourly rate for the shift. + final double hourlyRate; + + /// Total duration of the shift in hours. + final double duration; + + /// Localization string for total. + final String totalLabel; + + /// Localization string for hourly rate. + final String hourlyRateLabel; + + /// Localization string for hours. + final String hoursLabel; + + /// Creates a [ShiftStatsRow]. + const ShiftStatsRow({ + super.key, + required this.estimatedTotal, + required this.hourlyRate, + required this.duration, + required this.totalLabel, + required this.hourlyRateLabel, + required this.hoursLabel, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Row( + children: [ + Expanded( + child: _buildStatCard( + UiIcons.dollar, + "\$${estimatedTotal.toStringAsFixed(0)}", + totalLabel, + ), + ), + const SizedBox(width: UiConstants.space4), + Expanded( + child: _buildStatCard( + UiIcons.dollar, + "\$${hourlyRate.toStringAsFixed(0)}", + hourlyRateLabel, + ), + ), + const SizedBox(width: UiConstants.space4), + Expanded( + child: _buildStatCard( + UiIcons.clock, + duration.toStringAsFixed(1), + hoursLabel, + ), + ), + ], + ), + ); + } + + Widget _buildStatCard(IconData icon, String value, String label) { + return Container( + padding: const EdgeInsets.symmetric(vertical: UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.bgThird, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + child: Column( + children: [ + Container( + width: 40, + height: 40, + decoration: const BoxDecoration( + color: UiColors.white, + shape: BoxShape.circle, + ), + child: Icon(icon, size: 20, color: UiColors.textSecondary), + ), + const SizedBox(height: UiConstants.space2), + Text( + value, + style: UiTypography.title1m + .copyWith(fontWeight: FontWeight.w700) + .textPrimary, + ), + Text(label, style: UiTypography.footnote2r.textSecondary), + ], + ), + ); + } +} From 6ed12a05192bf55057a5c7afc1b9ed0e72668ddb Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 13:46:01 -0500 Subject: [PATCH 10/53] feat(api-keys): Replace Google Places API key with Google Maps API key across the application --- apps/mobile/config.dev.json | 3 +-- apps/mobile/packages/core/lib/src/config/app_config.dart | 5 +---- .../lib/src/data/repositories_impl/hub_repository_impl.dart | 2 +- .../src/presentation/widgets/hub_address_autocomplete.dart | 2 +- .../src/data/repositories_impl/place_repository_impl.dart | 4 +++- 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/mobile/config.dev.json b/apps/mobile/config.dev.json index 214cb535..4c630e51 100644 --- a/apps/mobile/config.dev.json +++ b/apps/mobile/config.dev.json @@ -1,4 +1,3 @@ { - "GOOGLE_PLACES_API_KEY": "AIzaSyAS9yTf4q51_CNSZ7mbmeS9V3l_LZR80lU", - "GOOGLE_MAPS_API_KEY": "AIzaSyAyRS9I4xxoVPAX91RJvWJHszB3ZY3-IC0" + "GOOGLE_MAPS_API_KEY": "AIzaSyAS9yTf4q51_CNSZ7mbmeS9V3l_LZR80lU" } \ No newline at end of file diff --git a/apps/mobile/packages/core/lib/src/config/app_config.dart b/apps/mobile/packages/core/lib/src/config/app_config.dart index 727638a4..9bf56394 100644 --- a/apps/mobile/packages/core/lib/src/config/app_config.dart +++ b/apps/mobile/packages/core/lib/src/config/app_config.dart @@ -4,9 +4,6 @@ class AppConfig { AppConfig._(); - /// The Google Places API key used for address autocomplete functionality. - static const String googlePlacesApiKey = String.fromEnvironment('GOOGLE_PLACES_API_KEY'); - - /// The Google Maps Static API key used for location preview images. + /// The Google Maps API key. static const String googleMapsApiKey = String.fromEnvironment('GOOGLE_MAPS_API_KEY'); } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart b/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart index 7d7a24f0..2fbd8aac 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart @@ -262,7 +262,7 @@ class HubRepositoryImpl { 'place_id': placeId, 'fields': 'address_component', - 'key': AppConfig.googlePlacesApiKey, + 'key': AppConfig.googleMapsApiKey, }, ); try { diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart index 138bb9e7..66f14d11 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart @@ -25,7 +25,7 @@ class HubAddressAutocomplete extends StatelessWidget { return GooglePlaceAutoCompleteTextField( textEditingController: controller, focusNode: focusNode, - googleAPIKey: AppConfig.googlePlacesApiKey, + googleAPIKey: AppConfig.googleMapsApiKey, debounceTime: 500, countries: HubsConstants.supportedCountries, isLatLngRequired: true, diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/place_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/place_repository_impl.dart index 9f8e99ad..6d6512b5 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/place_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/place_repository_impl.dart @@ -1,6 +1,8 @@ import 'dart:convert'; + import 'package:http/http.dart' as http; import 'package:krow_core/core.dart'; + import '../../domain/repositories/place_repository.dart'; class PlaceRepositoryImpl implements PlaceRepository { @@ -18,7 +20,7 @@ class PlaceRepositoryImpl implements PlaceRepository { { 'input': query, 'types': '(cities)', - 'key': AppConfig.googlePlacesApiKey, + 'key': AppConfig.googleMapsApiKey, }, ); From 888cf83c18d77803bdbdaef04b5bb4146ee6df20 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 13:50:02 -0500 Subject: [PATCH 11/53] feat(breaks): Implement Google Maps API key integration and decode Dart defines for both client and staff apps --- .../apps/client/android/app/build.gradle.kts | 16 ++++++++++++ .../android/app/src/main/AndroidManifest.xml | 3 +++ .../apps/client/ios/Runner/AppDelegate.swift | 25 +++++++++++++++++++ apps/mobile/apps/client/ios/Runner/Info.plist | 6 +++-- .../apps/staff/android/app/build.gradle.kts | 16 ++++++++++++ .../android/app/src/main/AndroidManifest.xml | 3 +++ .../apps/staff/ios/Runner/AppDelegate.swift | 25 +++++++++++++++++++ apps/mobile/apps/staff/ios/Runner/Info.plist | 6 +++-- 8 files changed, 96 insertions(+), 4 deletions(-) diff --git a/apps/mobile/apps/client/android/app/build.gradle.kts b/apps/mobile/apps/client/android/app/build.gradle.kts index 202bc20b..593af2c7 100644 --- a/apps/mobile/apps/client/android/app/build.gradle.kts +++ b/apps/mobile/apps/client/android/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Base64 + plugins { id("com.android.application") id("kotlin-android") @@ -6,6 +8,18 @@ plugins { id("com.google.gms.google-services") } +val dartDefinesString = project.findProperty("dart-defines") as? String ?: "" +val dartEnvironmentVariables = mutableMapOf() +dartDefinesString.split(",").forEach { + if (it.isNotEmpty()) { + val decoded = String(Base64.getDecoder().decode(it)) + val components = decoded.split("=") + if (components.size == 2) { + dartEnvironmentVariables[components[0]] = components[1] + } + } +} + android { namespace = "com.krowwithus.client" compileSdk = flutter.compileSdkVersion @@ -29,6 +43,8 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + + manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: "" } buildTypes { diff --git a/apps/mobile/apps/client/android/app/src/main/AndroidManifest.xml b/apps/mobile/apps/client/android/app/src/main/AndroidManifest.xml index 5bf8f125..da643f20 100644 --- a/apps/mobile/apps/client/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/apps/client/android/app/src/main/AndroidManifest.xml @@ -30,6 +30,9 @@ + I(Invoice) - B(Business) --> I - V(Vendor) --> I - I --> RP(RecentPayment) - A(Application) --> RP + %% ----------------------------- + %% Billing Core + %% ----------------------------- + B(Business) --> O(Order) + V(Vendor) --> O + O --> I(Invoice) + I --> RP(RecentPayment) + A(Application) --> RP + + %% ----------------------------- + %% Upstream Operations (Context) + %% ----------------------------- + subgraph OPS[Upstream Operations Context] + O --> S(Shift) + S --> SR(ShiftRole) + SR --> A + ST(Staff) --> A + A --> AS(Assignment) + W(Workforce) --> AS + ST --> W + end ``` ---- - -## 7. Teams Domain +## Teams Domain ### Summary -This section outlines the structure of the Teams domain, showing how users are organized and managed. It details the relationships between `Team`, `TeamMember`, and `User`, and the hierarchical structure involving `TeamHub` and `TeamHudDepartment`. The documentation also clarifies how task management (`Task`, `MemberTask`, `TaskComment`) is integrated into the team structure. +Details how teams, members, hubs, and departments structure organizational data. +Covers task management via tasks, member assignments, and task comments. +Notes verified and missing relationships that affect traceability in the schema. ### Full Content # Teams Domain Flowchart @@ -186,28 +202,38 @@ The Teams domain in this repository organizes users and their associated tasks. ## Flowchart ```mermaid -flowchart TD - T(Team) - U(User) - TK(Task) - - T --> TM(TeamMember) - U --> TM +--- +config: + layout: elk +--- +--- +config: + layout: elk +--- +flowchart TB + subgraph STRUCTURE[Team Structure] + T(Team) --> TM(TeamMember) T --> TH(TeamHub) TH --> THD(TeamHudDepartment) - - TM --> MT(MemberTask) - TK --> MT - + U(User) --> TM + TM --> TH + end + + subgraph WORK[Work & Tasks] + TK(Task) --> MT(MemberTask) + TM --> MT TM --> TC(TaskComment) + TK --> TC + end + ``` ---- - -## 8. Messaging Domain +## Messaging Domain ### Summary -This section explains the architecture of the messaging system, which is built around three core entities. It describes how `Conversation` holds metadata, `Message` contains the actual content linked to a sender `User`, and `UserConversation` tracks the state (e.g., unread counts) for each participant in a conversation. The documentation includes both verified and inferred schema relationships. +Defines conversations as the container for chat metadata and history. +Links messages and user participation through user conversations. +Distinguishes verified and inferred relationships between entities. ### Full Content # Messaging Domain Flowchart @@ -249,12 +275,12 @@ flowchart TB U -- Verified --- M ``` ---- - -## 9. Compliance Domain +## Compliance Domain ### Summary -This section details the compliance and documentation management system for staff members. It explains how `Document` defines required document types, while `StaffDocument` tracks the submissions from `Staff`. The flowchart also illustrates how other compliance-related entities like `RequiredDoc`, `TaxForm`, and `Certificate` are linked directly to a staff member. +Explains how staff compliance is tracked through documents and submissions. +Includes required documents, tax forms, and certificates tied to staff records. +Separates verified links from inferred relationships for compliance entities. ### Full Content # Compliance Domain Flowchart @@ -282,32 +308,26 @@ The compliance domain manages the necessary documentation and certifications for ## Flowchart ```mermaid flowchart TB - subgraph "Compliance Requirements" - D(Document) - end - - subgraph "Staff Submissions & Documents" - S(Staff) - SD(StaffDocument) - RD(RequiredDoc) - TF(TaxForm) - C(Certificate) - end - + subgraph subGraph0["Compliance Requirements"] + D("Document") + end + subgraph subGraph1["Staff Submissions & Documents"] + S("Staff") + SD("StaffDocument") + TF("TaxForm") + C("Certificate") + end D -- Verified --> SD - - S -- Inferred --> SD - S -- Inferred --> RD - S -- Inferred --> TF + S -- Inferred --> SD & TF S -- Verified --> C ``` ---- - -## 10. Learning Domain +## Learning Domain ### Summary -This section describes the learning and training module for staff. It explains how `Course` represents a training module belonging to a `Category`. The `StaffCourse` entity is used to track the enrollment and progress of a `Staff` member. The documentation also notes that while a `Level` entity exists, it is not directly linked to courses in the current schema. +Outlines the training model with courses, categories, and levels. +Shows how staff progress is captured via staff course records. +Calls out relationships that are inferred versus explicitly modeled. ### Full Content # Learning Domain Flowchart @@ -345,16 +365,16 @@ flowchart TB CAT -- Verified --> C - S -- Inferred --> SC - C -- Inferred --> SC + S -- Verified --> SC + C -- Verified --> SC ``` ---- - -## 11. Operations Sequence Diagrams +## Sequence Diagrams ### Summary -This section provides a sequence diagram that illustrates the step-by-step operational flow from order creation to invoicing. Based on an analysis of the connector mutations, it shows the sequence of events: a `User` creates an `Order`, then a `Shift`, followed by an `Application` and `Assignment`. Finally, an `Invoice` is generated from the original `Order`. +Walks through the order-to-invoice sequence based on connector operations. +Lists the verified mutation steps that drive the operational flow. +Visualizes participant interactions from creation through billing. ### Full Content # Operations Sequence Diagrams @@ -367,6 +387,7 @@ Based on the repository's connector operations, the operational flow begins when ### Verified Steps (Evidence) - `createOrder` (source: `dataconnect/connector/order/mutations.gql`) - `createShift` (source: `dataconnect/connector/shift/mutations.gql`) +- `createShiftRole` (source: `dataconnect/connector/shiftRole/mutations.gql`) - `createApplication` (source: `dataconnect/connector/application/mutations.gql`) - `CreateAssignment` (source: `dataconnect/connector/assignment/mutations.gql`) - `createInvoice` (source: `dataconnect/connector/invoice/mutations.gql`) @@ -374,981 +395,906 @@ Based on the repository's connector operations, the operational flow begins when ### Sequence Diagram ```mermaid sequenceDiagram - participant User + participant Business as Business (Client) + participant Vendor as Vendor (Provider) participant Order participant Shift + participant ShiftRole + participant Staff participant Application + participant Workforce participant Assignment participant Invoice - User->>Order: createOrder() - activate Order - Order-->>User: new Order - deactivate Order + Business->>Order: createOrder(businessId, vendorId, ...) + Order-->>Business: Order created (orderId) - User->>Shift: createShift(orderId) - activate Shift - Shift-->>User: new Shift - deactivate Shift - - User->>Application: createApplication(shiftId) - activate Application - Application-->>User: new Application - deactivate Application + Vendor->>Shift: createShift(orderId, ...) + Shift-->>Vendor: Shift created (shiftId) - User->>Assignment: CreateAssignment(shiftId, workforceId) - activate Assignment - Assignment-->>User: new Assignment - deactivate Assignment + Vendor->>ShiftRole: createShiftRole(shiftId, roleId, workersNeeded, rate, ...) + ShiftRole-->>Vendor: ShiftRole created (shiftRoleId) + + Staff->>Application: createApplication(shiftId OR shiftRoleId, staffId, ...) + Application-->>Staff: Application submitted (applicationId) + + Vendor->>Workforce: createWorkforce(applicationId / staffId / shiftId, ...) + Workforce-->>Vendor: Workforce created (workforceId) + + Vendor->>Assignment: createAssignment(shiftId, workforceId, staffId, ...) + Assignment-->>Vendor: Assignment created (assignmentId) + + Vendor->>Invoice: createInvoice(orderId, businessId, vendorId, ...) + Invoice-->>Vendor: Invoice created (invoiceId) - User->>Invoice: createInvoice(orderId) - activate Invoice - Invoice-->>User: new Invoice - deactivate Invoice ``` ---- - -## 12. API Catalog +## API Catalog ### Summary -This section provides a complete and exhaustive catalog of every GraphQL query and mutation available in the Data Connect API. Generated by inspecting all 48 connector folders, it lists every operation without summarization. The catalog is organized by entity and serves as a definitive reference for all available API endpoints, their purposes, and their parameters. +Lists every GraphQL query and mutation in the Data Connect connectors. +Provides parameters and top-level return/affect fields for each operation. +Organizes operations by entity folder for quick discovery and reference. ### Full Content # API Catalog – Data Connect ## Overview -This document serves as a complete catalog of the available GraphQL queries and mutations for the Firebase Data Connect backend. It is generated automatically by inspecting the `queries.gql` and `mutations.gql` files within each entity's connector directory. This catalog is exhaustive and lists every operation found. Use this guide to understand the available operations, their parameters, and what they affect. +This catalog enumerates every GraphQL query and mutation defined in the Data Connect connector folders under `prototypes/dataconnect/connector/`. Use it to discover available operations, required parameters, and the top-level fields returned or affected by each operation. ---- -## Account -*Manages bank accounts for owners (vendors/businesses).* +## account ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listAccounts` | Retrieves a list of all accounts. | - | `[Account]` | -| `getAccountById` | Fetches a single account by its unique ID. | `id: UUID!` | `Account` | -| `getAccountsByOwnerId` | Finds all accounts belonging to a specific owner. | `ownerId: UUID!` | `[Account]` | -| `filterAccounts` | Searches for accounts based on bank, type, primary status, or owner. | `bank`, `type`, `isPrimary`, `ownerId` | `[Account]` | +|------|---------|------------|---------| +| `listAccounts` | List accounts | — | `accounts` | +| `getAccountById` | Get account by id | `$id: UUID!` | `account` | +| `getAccountsByOwnerId` | Get accounts by owner id | `$ownerId: UUID!` | `accounts` | +| `filterAccounts` | Filter accounts | `$bank: String`
`$type: AccountType`
`$isPrimary: Boolean`
`$ownerId: UUID` | `accounts` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createAccount` | Adds a new bank account. | `bank`, `type`, `last4`, `isPrimary`, `ownerId` | `Account` | -| `updateAccount` | Modifies an existing bank account. | `id`, `bank`, `type`, `last4`, `isPrimary` | `Account` | -| `deleteAccount` | Removes a bank account. | `id: UUID!` | `Account` | +|------|---------|------------|---------| +| `createAccount` | Create account | `$bank: String!`
`$type: AccountType!`
`$last4: String!`
`$isPrimary: Boolean`
`$ownerId: UUID!`
`$accountNumber: String`
`$routeNumber: String`
`$expiryTime: Timestamp` | `account_insert` | +| `updateAccount` | Update account | `$id: UUID!`
`$bank: String`
`$type: AccountType`
`$last4: String`
`$isPrimary: Boolean`
`$accountNumber: String`
`$routeNumber: String`
`$expiryTime: Timestamp` | `account_update` | +| `deleteAccount` | Delete account | `$id: UUID!` | `account_delete` | ---- -## ActivityLog -*Tracks user activities and notifications.* +## activityLog ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listActivityLogs` | Retrieves all activity logs. | `offset`, `limit` | `[ActivityLog]` | -| `getActivityLogById`| Fetches a single log by its ID. | `id: UUID!` | `ActivityLog` | -| `listActivityLogsByUserId`| Lists all logs for a specific user. | `userId`, `offset`, `limit` | `[ActivityLog]` | -| `listUnreadActivityLogsByUserId`| Lists unread logs for a user. | `userId`, `offset`, `limit` | `[ActivityLog]` | -| `filterActivityLogs`| Searches logs by user, date range, read status, and type. | `userId`, `dateFrom`, `dateTo`, `isRead`, `activityType`, ... | `[ActivityLog]` | +|------|---------|------------|---------| +| `listActivityLogs` | List activity logs | `$offset: Int`
`$limit: Int` | `activityLogs` | +| `getActivityLogById` | Get activity log by id | `$id: UUID!` | `activityLog` | +| `listActivityLogsByUserId` | List activity logs by user id | `$userId: String!`
`$offset: Int`
`$limit: Int` | `activityLogs` | +| `listUnreadActivityLogsByUserId` | List unread activity logs by user id | `$userId: String!`
`$offset: Int`
`$limit: Int` | `activityLogs` | +| `filterActivityLogs` | Filter activity logs | `$userId: String`
`$dateFrom: Timestamp`
`$dateTo: Timestamp`
`$isRead: Boolean`
`$activityType: ActivityType`
`$iconType: ActivityIconType`
`$offset: Int`
`$limit: Int` | `activityLogs` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createActivityLog` | Creates a new activity log entry. | `userId`, `date`, `title`, `description`, `activityType`, ... | `ActivityLog` | -| `updateActivityLog` | Modifies an existing activity log. | `id`, `title`, `description`, `isRead`, ... | `ActivityLog` | -| `markActivityLogAsRead`| Marks a single log as read. | `id: UUID!` | `ActivityLog` | -| `markActivityLogsAsRead`| Marks multiple logs as read. | `ids: [UUID!]!` | `[ActivityLog]` | -| `deleteActivityLog` | Removes an activity log. | `id: UUID!` | `ActivityLog` | +|------|---------|------------|---------| +| `createActivityLog` | Create activity log | `$userId: String!`
`$date: Timestamp!`
`$hourStart: String`
`$hourEnd: String`
`$totalhours: String`
`$iconType: ActivityIconType`
`$iconColor: String`
`$title: String!`
`$description: String!`
`$isRead: Boolean`
`$activityType: ActivityType!` | `activityLog_insert` | +| `updateActivityLog` | Update activity log | `$id: UUID!`
`$userId: String`
`$date: Timestamp`
`$hourStart: String`
`$hourEnd: String`
`$totalhours: String`
`$iconType: ActivityIconType`
`$iconColor: String`
`$title: String`
`$description: String`
`$isRead: Boolean`
`$activityType: ActivityType` | `activityLog_update` | +| `markActivityLogAsRead` | Mark activity log as read | `$id: UUID!` | `activityLog_update` | +| `markActivityLogsAsRead` | Mark activity logs as read | `$ids: [UUID!]!` | `activityLog_updateMany` | +| `deleteActivityLog` | Delete activity log | `$id: UUID!` | `activityLog_delete` | ---- -## Application -*Manages staff applications for shifts.* +## application ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listApplications` | Retrieves all applications. | - | `[Application]` | -| `getApplicationById`| Fetches a single application by its ID. | `id: UUID!` | `Application` | -| `getApplicationsByShiftId`| Finds all applications for a specific shift. | `shiftId: UUID!` | `[Application]` | -| `getApplicationsByShiftIdAndStatus`| Filters applications for a shift by status. | `shiftId`, `status`, `offset`, `limit` | `[Application]` | -| `getApplicationsByStaffId`| Finds all applications submitted by a staff member. | `staffId`, `offset`, `limit` | `[Application]` | +|------|---------|------------|---------| +| `listApplications` | List applications | — | `applications` | +| `getApplicationById` | Get application by id | `$id: UUID!` | `application` | +| `getApplicationsByShiftId` | Get applications by shift id | `$shiftId: UUID!` | `applications` | +| `getApplicationsByShiftIdAndStatus` | Get applications by shift id and status | `$shiftId: UUID!`
`$status: ApplicationStatus!`
`$offset: Int`
`$limit: Int` | `applications` | +| `getApplicationsByStaffId` | Get applications by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int`
`$dayStart: Timestamp`
`$dayEnd: Timestamp` | `applications` | +| `vaidateDayStaffApplication` | Vaidate day staff application | `$staffId: UUID!`
`$offset: Int`
`$limit: Int`
`$dayStart: Timestamp`
`$dayEnd: Timestamp` | `applications` | +| `getApplicationByStaffShiftAndRole` | Get application by staff shift and role | `$staffId: UUID!`
`$shiftId: UUID!`
`$roleId: UUID!`
`$offset: Int`
`$limit: Int` | `applications` | +| `listAcceptedApplicationsByShiftRoleKey` | List accepted applications by shift role key | `$shiftId: UUID!`
`$roleId: UUID!`
`$offset: Int`
`$limit: Int` | `applications` | +| `listAcceptedApplicationsByBusinessForDay` | List accepted applications by business for day | `$businessId: UUID!`
`$dayStart: Timestamp!`
`$dayEnd: Timestamp!`
`$offset: Int`
`$limit: Int` | `applications` | +| `listStaffsApplicationsByBusinessForDay` | List staffs applications by business for day | `$businessId: UUID!`
`$dayStart: Timestamp!`
`$dayEnd: Timestamp!`
`$offset: Int`
`$limit: Int` | `applications` | +| `listCompletedApplicationsByStaffId` | List completed applications by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `applications` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createApplication` | Creates a new application for a shift. | `shiftId`, `staffId`, `status`, `origin`, `roleId`, ... | `Application` | -| `updateApplicationStatus`| Updates the status of an application. | `id`, `status`, ... | `Application` | -| `deleteApplication` | Deletes an application. | `id: UUID!` | `Application` | +|------|---------|------------|---------| +| `createApplication` | Create application | `$shiftId: UUID!`
`$staffId: UUID!`
`$status: ApplicationStatus!`
`$checkInTime: Timestamp`
`$checkOutTime: Timestamp`
`$origin: ApplicationOrigin!`
`$roleId: UUID!` | `application_insert` | +| `updateApplicationStatus` | Update application status | `$id: UUID!`
`$shiftId: UUID`
`$staffId: UUID`
`$status: ApplicationStatus`
`$checkInTime: Timestamp`
`$checkOutTime: Timestamp`
`$roleId: UUID` | `application_update` | +| `deleteApplication` | Delete application | `$id: UUID!` | `application_delete` | ---- -## Assignment -*Manages staff assignments to shifts.* +## assignment ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listAssignments` | Retrieves all assignments. | `offset`, `limit` | `[Assignment]` | -| `getAssignmentById` | Fetches a single assignment by ID. | `id: UUID!` | `Assignment` | -| `listAssignmentsByWorkforceId` | Lists assignments for a specific workforce member. | `workforceId`, `offset`, `limit` | `[Assignment]` | -| `listAssignmentsByWorkforceIds` | Lists assignments for a set of workforce members. | `workforceIds`, `offset`, `limit` | `[Assignment]` | -| `listAssignmentsByShiftRole` | Lists assignments for a specific role within a shift. | `shiftId`, `roleId`, `offset`, `limit` | `[Assignment]` | -| `filterAssignments` | Filters assignments by status and shift/role IDs. | `shiftIds`, `roleIds`, `status`, `offset`, `limit` | `[Assignment]` | +|------|---------|------------|---------| +| `listAssignments` | List assignments | `$offset: Int`
`$limit: Int` | `assignments` | +| `getAssignmentById` | Get assignment by id | `$id: UUID!` | `assignment` | +| `listAssignmentsByWorkforceId` | List assignments by workforce id | `$workforceId: UUID!`
`$offset: Int`
`$limit: Int` | `assignments` | +| `listAssignmentsByWorkforceIds` | List assignments by workforce ids | `$workforceIds: [UUID!]!`
`$offset: Int`
`$limit: Int` | `assignments` | +| `listAssignmentsByShiftRole` | List assignments by shift role | `$shiftId: UUID!`
`$roleId: UUID!`
`$offset: Int`
`$limit: Int` | `assignments` | +| `represents` | Represents | — | `assignments` | +| `filterAssignments` | Filter assignments | `$shiftIds: [UUID!]!`
`$roleIds: [UUID!]!`
`$status: AssignmentStatus`
`$offset: Int`
`$limit: Int` | `assignments` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `CreateAssignment` | Creates a new assignment. | `workforceId`, `shiftId`, `roleId`, `title`, ... | `Assignment` | -| `UpdateAssignment` | Modifies an existing assignment. | `id`, `status`, `title`, ... | `Assignment` | -| `DeleteAssignment` | Removes an assignment. | `id: UUID!` | `Assignment` | +|------|---------|------------|---------| +| `CreateAssignment` | Create assignment | `$workforceId: UUID!`
`$title: String`
`$description: String`
`$instructions: String`
`$status: AssignmentStatus`
`$tipsAvailable: Boolean`
`$travelTime: Boolean`
`$mealProvided: Boolean`
`$parkingAvailable: Boolean`
`$gasCompensation: Boolean`
`$managers: [Any!]`
`$roleId: UUID!`
`$shiftId: UUID!` | `assignment_insert` | +| `UpdateAssignment` | Update assignment | `$id: UUID!`
`$title: String`
`$description: String`
`$instructions: String`
`$status: AssignmentStatus`
`$tipsAvailable: Boolean`
`$travelTime: Boolean`
`$mealProvided: Boolean`
`$parkingAvailable: Boolean`
`$gasCompensation: Boolean`
`$managers: [Any!]`
`$roleId: UUID!`
`$shiftId: UUID!` | `assignment_update` | +| `DeleteAssignment` | Delete assignment | `$id: UUID!` | `assignment_delete` | ---- -## AttireOption -*Manages attire options for vendors.* +## attireOption ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listAttireOptions` | Retrieves all attire options. | - | `[AttireOption]` | -| `getAttireOptionById`| Fetches a single attire option by ID. | `id: UUID!` | `AttireOption` | -| `filterAttireOptions`| Searches options by item ID, mandatory status, or vendor. | `itemId`, `isMandatory`, `vendorId` | `[AttireOption]` | +|------|---------|------------|---------| +| `listAttireOptions` | List attire options | — | `attireOptions` | +| `getAttireOptionById` | Get attire option by id | `$id: UUID!` | `attireOption` | +| `filterAttireOptions` | Filter attire options | `$itemId: String`
`$isMandatory: Boolean`
`$vendorId: UUID` | `attireOptions` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createAttireOption` | Adds a new attire option. | `itemId`, `label`, `isMandatory`, `vendorId`, ... | `AttireOption` | -| `updateAttireOption` | Modifies an existing attire option. | `id`, `label`, `isMandatory`, ... | `AttireOption` | -| `deleteAttireOption` | Removes an attire option. | `id: UUID!` | `AttireOption` | +|------|---------|------------|---------| +| `createAttireOption` | Create attire option | `$itemId: String!`
`$label: String!`
`$icon: String`
`$imageUrl: String`
`$isMandatory: Boolean`
`$vendorId: UUID` | `attireOption_insert` | +| `updateAttireOption` | Update attire option | `$id: UUID!`
`$itemId: String`
`$label: String`
`$icon: String`
`$imageUrl: String`
`$isMandatory: Boolean`
`$vendorId: UUID` | `attireOption_update` | +| `deleteAttireOption` | Delete attire option | `$id: UUID!` | `attireOption_delete` | ---- -## BenefitsData -*Tracks staff enrollment in vendor benefit plans.* +## benefitsData ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listBenefitsData` | Retrieves all benefits data records. | `offset`, `limit` | `[BenefitsData]` | -| `getBenefitsDataByKey`| Fetches a record by staff and plan ID. | `staffId`, `vendorBenefitPlanId` | `BenefitsData` | -| `listBenefitsDataByStaffId`| Lists all benefit records for a staff member. | `staffId`, `offset`, `limit` | `[BenefitsData]` | -| `listBenefitsDataByVendorBenefitPlanId`| Lists all staff enrolled in a specific benefit plan. | `vendorBenefitPlanId`, `offset`, `limit` | `[BenefitsData]` | -| `listBenefitsDataByVendorBenefitPlanIds`| Lists records for a set of benefit plans. | `vendorBenefitPlanIds`, `offset`, `limit` | `[BenefitsData]` | +|------|---------|------------|---------| +| `listBenefitsData` | List benefits data | `$offset: Int`
`$limit: Int` | `benefitsDatas` | +| `getBenefitsDataByKey` | Get benefits data by key | `$staffId: UUID!`
`$vendorBenefitPlanId: UUID!` | `benefitsData` | +| `listBenefitsDataByStaffId` | List benefits data by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `benefitsDatas` | +| `listBenefitsDataByVendorBenefitPlanId` | List benefits data by vendor benefit plan id | `$vendorBenefitPlanId: UUID!`
`$offset: Int`
`$limit: Int` | `benefitsDatas` | +| `listBenefitsDataByVendorBenefitPlanIds` | List benefits data by vendor benefit plan ids | `$vendorBenefitPlanIds: [UUID!]!`
`$offset: Int`
`$limit: Int` | `benefitsDatas` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createBenefitsData` | Creates a new staff benefit enrollment record. | `vendorBenefitPlanId`, `staffId`, `current` | `BenefitsData` | -| `updateBenefitsData` | Updates a staff benefit enrollment record. | `staffId`, `vendorBenefitPlanId`, `current` | `BenefitsData` | -| `deleteBenefitsData` | Deletes a staff benefit enrollment record. | `staffId`, `vendorBenefitPlanId` | `BenefitsData` | +|------|---------|------------|---------| +| `createBenefitsData` | Create benefits data | `$vendorBenefitPlanId: UUID!`
`$staffId: UUID!`
`$current: Int!` | `benefitsData_insert` | +| `updateBenefitsData` | Update benefits data | `$staffId: UUID!`
`$vendorBenefitPlanId: UUID!`
`$current: Int` | `benefitsData_update` | +| `deleteBenefitsData` | Delete benefits data | `$staffId: UUID!`
`$vendorBenefitPlanId: UUID!` | `benefitsData_delete` | ---- -## Business -*Manages client/business entities.* +## business ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listBusinesses` | Retrieves all businesses. | - | `[Business]` | -| `getBusinessesByUserId`| Fetches businesses associated with a user. | `userId: String!` | `[Business]` | -| `getBusinessById` | Fetches a single business by its ID. | `id: UUID!` | `Business` | +|------|---------|------------|---------| +| `listBusinesses` | List businesses | — | `businesses` | +| `getBusinessesByUserId` | Get businesses by user id | `$userId: String!` | `businesses` | +| `getBusinessById` | Get business by id | `$id: UUID!` | `business` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createBusiness` | Creates a new business profile. | `businessName`, `userId`, `rateGroup`, `status`, ... | `Business` | -| `updateBusiness` | Modifies an existing business profile. | `id`, `businessName`, `status`, ... | `Business` | -| `deleteBusiness` | Deletes a business profile. | `id: UUID!` | `Business` | +|------|---------|------------|---------| +| `createBusiness` | Create business | `$businessName: String!`
`$contactName: String`
`$userId: String!`
`$companyLogoUrl: String`
`$phone: String`
`$email: String`
`$hubBuilding: String`
`$address: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$city: String`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String`
`$area: BusinessArea`
`$sector: BusinessSector`
`$rateGroup: BusinessRateGroup!`
`$status: BusinessStatus!`
`$notes: String` | `business_insert` | +| `updateBusiness` | Update business | `$id: UUID!`
`$businessName: String`
`$contactName: String`
`$companyLogoUrl: String`
`$phone: String`
`$email: String`
`$hubBuilding: String`
`$address: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$city: String`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String`
`$area: BusinessArea`
`$sector: BusinessSector`
`$rateGroup: BusinessRateGroup`
`$status: BusinessStatus`
`$notes: String` | `business_update` | +| `deleteBusiness` | Delete business | `$id: UUID!` | `business_delete` | ---- -## Category -*Manages categories for courses, roles, etc.* +## category ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listCategories` | Retrieves all categories. | - | `[Category]` | -| `getCategoryById` | Fetches a single category by ID. | `id: UUID!` | `Category` | -| `filterCategories` | Searches categories by a custom `categoryId` string or label. | `categoryId`, `label` | `[Category]` | +|------|---------|------------|---------| +| `listCategories` | List categories | — | `categories` | +| `getCategoryById` | Get category by id | `$id: UUID!` | `category` | +| `filterCategories` | Filter categories | `$categoryId: String`
`$label: String` | `categories` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createCategory` | Adds a new category. | `categoryId`, `label`, `icon` | `Category` | -| `updateCategory` | Modifies an existing category. | `id`, `label`, `icon`, ... | `Category` | -| `deleteCategory` | Removes a category. | `id: UUID!` | `Category` | +|------|---------|------------|---------| +| `createCategory` | Create category | `$categoryId: String!`
`$label: String!`
`$icon: String` | `category_insert` | +| `updateCategory` | Update category | `$id: UUID!`
`$categoryId: String`
`$label: String`
`$icon: String` | `category_update` | +| `deleteCategory` | Delete category | `$id: UUID!` | `category_delete` | ---- -## Certificate -*Manages staff certifications.* +## certificate ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listCertificates` | Retrieves all certificates. | - | `[Certificate]` | -| `getCertificateById`| Fetches a single certificate by ID. | `id: UUID!` | `Certificate` | -| `listCertificatesByStaffId`| Lists all certificates for a specific staff member. | `staffId: UUID!` | `[Certificate]` | +|------|---------|------------|---------| +| `listCertificates` | List certificates | — | `certificates` | +| `getCertificateById` | Get certificate by id | `$id: UUID!` | `certificate` | +| `listCertificatesByStaffId` | List certificates by staff id | `$staffId: UUID!` | `certificates` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `CreateCertificate` | Adds a new certificate for a staff member. | `name`, `status`, `staffId`, ... | `Certificate` | -| `UpdateCertificate` | Modifies an existing certificate. | `id`, `name`, `status`, `expiry`, ... | `Certificate` | -| `DeleteCertificate` | Removes a certificate. | `id: UUID!` | `Certificate` | +|------|---------|------------|---------| +| `CreateCertificate` | Create certificate | `$name: String!`
`$description: String`
`$expiry: Timestamp`
`$status: CertificateStatus!`
`$fileUrl: String`
`$icon: String`
`$certificationType: ComplianceType`
`$issuer: String`
`$staffId: UUID!`
`$validationStatus: ValidationStatus`
`$certificateNumber: String` | `certificate_insert` | +| `UpdateCertificate` | Update certificate | `$id: UUID!`
`$name: String`
`$description: String`
`$expiry: Timestamp`
`$status: CertificateStatus`
`$fileUrl: String`
`$icon: String`
`$staffId: UUID`
`$certificationType: ComplianceType`
`$issuer: String`
`$validationStatus: ValidationStatus`
`$certificateNumber: String` | `certificate_update` | +| `DeleteCertificate` | Delete certificate | `$id: UUID!` | `certificate_delete` | ---- -## ClientFeedback -*Manages feedback from businesses about vendors.* +## clientFeedback ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listClientFeedbacks` | Retrieves all feedback records. | `offset`, `limit` | `[ClientFeedback]` | -| `getClientFeedbackById`| Fetches a single feedback record by ID. | `id: UUID!` | `ClientFeedback` | -| `listClientFeedbacksByBusinessId`| Lists all feedback submitted by a business. | `businessId`, `offset`, `limit` | `[ClientFeedback]` | -| `listClientFeedbacksByVendorId`| Lists all feedback received by a vendor. | `vendorId`, `offset`, `limit` | `[ClientFeedback]` | -| `listClientFeedbacksByBusinessAndVendor`| Lists feedback between a specific business-vendor pair. | `businessId`, `vendorId`, ... | `[ClientFeedback]` | -| `filterClientFeedbacks`| Searches feedback by rating and date range. | `ratingMin`, `ratingMax`, `dateFrom`, `dateTo`, ... | `[ClientFeedback]` | -| `listClientFeedbackRatingsByVendorId`| Retrieves all ratings for a vendor to calculate averages. | `vendorId`, `dateFrom`, `dateTo` | `[ClientFeedback]` | +|------|---------|------------|---------| +| `listClientFeedbacks` | List client feedbacks | `$offset: Int`
`$limit: Int` | `clientFeedbacks` | +| `getClientFeedbackById` | Get client feedback by id | `$id: UUID!` | `clientFeedback` | +| `listClientFeedbacksByBusinessId` | List client feedbacks by business id | `$businessId: UUID!`
`$offset: Int`
`$limit: Int` | `clientFeedbacks` | +| `listClientFeedbacksByVendorId` | List client feedbacks by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `clientFeedbacks` | +| `listClientFeedbacksByBusinessAndVendor` | List client feedbacks by business and vendor | `$businessId: UUID!`
`$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `clientFeedbacks` | +| `filterClientFeedbacks` | Filter client feedbacks | `$businessId: UUID`
`$vendorId: UUID`
`$ratingMin: Int`
`$ratingMax: Int`
`$dateFrom: Timestamp`
`$dateTo: Timestamp`
`$offset: Int`
`$limit: Int` | `clientFeedbacks` | +| `listClientFeedbackRatingsByVendorId` | List client feedback ratings by vendor id | `$vendorId: UUID!`
`$dateFrom: Timestamp`
`$dateTo: Timestamp` | `clientFeedbacks` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createClientFeedback`| Submits new feedback. | `businessId`, `vendorId`, `rating`, `comment`, ... | `ClientFeedback` | -| `updateClientFeedback`| Modifies existing feedback. | `id`, `rating`, `comment`, ... | `ClientFeedback` | -| `deleteClientFeedback`| Removes feedback. | `id: UUID!` | `ClientFeedback` | +|------|---------|------------|---------| +| `createClientFeedback` | Create client feedback | `$businessId: UUID!`
`$vendorId: UUID!`
`$rating: Int`
`$comment: String`
`$date: Timestamp`
`$createdBy: String` | `clientFeedback_insert` | +| `updateClientFeedback` | Update client feedback | `$id: UUID!`
`$businessId: UUID`
`$vendorId: UUID`
`$rating: Int`
`$comment: String`
`$date: Timestamp`
`$createdBy: String` | `clientFeedback_update` | +| `deleteClientFeedback` | Delete client feedback | `$id: UUID!` | `clientFeedback_delete` | ---- -## Contact -*Manages staff emergency contacts.* +## conversation ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listEmergencyContacts`| Retrieves all emergency contacts. | - | `[EmergencyContact]` | -| `getEmergencyContactById`| Fetches a single contact by ID. | `id: UUID!` | `EmergencyContact` | -| `getEmergencyContactsByStaffId`| Lists all emergency contacts for a staff member. | `staffId: UUID!` | `[EmergencyContact]` | +|------|---------|------------|---------| +| `listConversations` | List conversations | `$offset: Int`
`$limit: Int` | `conversations` | +| `getConversationById` | Get conversation by id | `$id: UUID!` | `conversation` | +| `listConversationsByType` | List conversations by type | `$conversationType: ConversationType!`
`$offset: Int`
`$limit: Int` | `conversations` | +| `listConversationsByStatus` | List conversations by status | `$status: ConversationStatus!`
`$offset: Int`
`$limit: Int` | `conversations` | +| `filterConversations` | Filter conversations | `$status: ConversationStatus`
`$conversationType: ConversationType`
`$isGroup: Boolean`
`$lastMessageAfter: Timestamp`
`$lastMessageBefore: Timestamp`
`$offset: Int`
`$limit: Int` | `conversations` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createEmergencyContact`| Adds a new emergency contact. | `name`, `phone`, `relationship`, `staffId` | `EmergencyContact` | -| `updateEmergencyContact`| Modifies an existing emergency contact. | `id`, `name`, `phone`, `relationship` | `EmergencyContact` | -| `deleteEmergencyContact`| Removes an emergency contact. | `id: UUID!` | `EmergencyContact` | +|------|---------|------------|---------| +| `createConversation` | Create conversation | `$subject: String`
`$status: ConversationStatus`
`$conversationType: ConversationType`
`$isGroup: Boolean`
`$groupName: String`
`$lastMessage: String`
`$lastMessageAt: Timestamp` | `conversation_insert` | +| `updateConversation` | Update conversation | `$id: UUID!`
`$subject: String`
`$status: ConversationStatus`
`$conversationType: ConversationType`
`$isGroup: Boolean`
`$groupName: String`
`$lastMessage: String`
`$lastMessageAt: Timestamp` | `conversation_update` | +| `updateConversationLastMessage` | Update conversation last message | `$id: UUID!`
`$lastMessage: String`
`$lastMessageAt: Timestamp` | `conversation_update` | +| `deleteConversation` | Delete conversation | `$id: UUID!` | `conversation_delete` | ---- -## Conversation -*Manages conversation metadata.* +## course ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listConversations` | Retrieves all conversations. | `offset`, `limit` | `[Conversation]` | -| `getConversationById`| Fetches a single conversation by ID. | `id: UUID!` | `Conversation` | -| `listConversationsByType`| Lists conversations of a specific type. | `conversationType`, `offset`, `limit` | `[Conversation]` | -| `listConversationsByStatus`| Lists conversations with a specific status. | `status`, `offset`, `limit` | `[Conversation]` | -| `filterConversations`| Searches conversations by status, type, and date range. | `status`, `conversationType`, `isGroup`, ... | `[Conversation]` | +|------|---------|------------|---------| +| `listCourses` | List courses | — | `courses` | +| `getCourseById` | Get course by id | `$id: UUID!` | `course` | +| `filterCourses` | Filter courses | `$categoryId: UUID`
`$isCertification: Boolean`
`$levelRequired: String`
`$completed: Boolean` | `courses` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createConversation` | Creates a new conversation thread. | `subject`, `status`, `conversationType`, ... | `Conversation` | -| `updateConversation` | Modifies an existing conversation. | `id`, `subject`, `status`, ... | `Conversation` | -| `updateConversationLastMessage`| Updates the last message preview for a conversation. | `id`, `lastMessage`, `lastMessageAt` | `Conversation` | -| `deleteConversation` | Removes a conversation. | `id: UUID!` | `Conversation` | +|------|---------|------------|---------| +| `createCourse` | Create course | `$title: String`
`$description: String`
`$thumbnailUrl: String`
`$durationMinutes: Int`
`$xpReward: Int`
`$categoryId: UUID!`
`$levelRequired: String`
`$isCertification: Boolean` | `course_insert` | +| `updateCourse` | Update course | `$id: UUID!`
`$title: String`
`$description: String`
`$thumbnailUrl: String`
`$durationMinutes: Int`
`$xpReward: Int`
`$categoryId: UUID!`
`$levelRequired: String`
`$isCertification: Boolean` | `course_update` | +| `deleteCourse` | Delete course | `$id: UUID!` | `course_delete` | ---- -## Course -*Manages training courses.* +## customRateCard ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listCourses` | Retrieves all courses. | - | `[Course]` | -| `getCourseById` | Fetches a single course by ID. | `id: UUID!` | `Course` | -| `filterCourses` | Searches courses by category, certification status, or level. | `categoryId`, `isCertification`, `levelRequired`, ... | `[Course]` | +|------|---------|------------|---------| +| `listCustomRateCards` | List custom rate cards | — | `customRateCards` | +| `getCustomRateCardById` | Get custom rate card by id | `$id: UUID!` | `customRateCard` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createCourse` | Adds a new course. | `title`, `categoryId`, `xpReward`, ... | `Course` | -| `updateCourse` | Modifies an existing course. | `id`, `title`, `description`, ... | `Course` | -| `deleteCourse` | Removes a course. | `id: UUID!` | `Course` | +|------|---------|------------|---------| +| `createCustomRateCard` | Create custom rate card | `$name: String!`
`$baseBook: String`
`$discount: Float`
`$isDefault: Boolean` | `customRateCard_insert` | +| `updateCustomRateCard` | Update custom rate card | `$id: UUID!`
`$name: String`
`$baseBook: String`
`$discount: Float`
`$isDefault: Boolean` | `customRateCard_update` | +| `deleteCustomRateCard` | Delete custom rate card | `$id: UUID!` | `customRateCard_delete` | ---- -## CustomRateCard -*Manages custom rate cards.* +## document ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listCustomRateCards`| Retrieves all custom rate cards. | - | `[CustomRateCard]` | -| `getCustomRateCardById`| Fetches a single rate card by ID. | `id: UUID!` | `CustomRateCard` | +|------|---------|------------|---------| +| `listDocuments` | List documents | — | `documents` | +| `getDocumentById` | Get document by id | `$id: UUID!` | `document` | +| `filterDocuments` | Filter documents | `$documentType: DocumentType` | `documents` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createCustomRateCard`| Adds a new rate card. | `name`, `baseBook`, `discount`, `isDefault` | `CustomRateCard` | -| `updateCustomRateCard`| Modifies an existing rate card. | `id`, `name`, `discount`, ... | `CustomRateCard` | -| `deleteCustomRateCard`| Removes a rate card. | `id: UUID!` | `CustomRateCard` | +|------|---------|------------|---------| +| `createDocument` | Create document | `$documentType: DocumentType!`
`$name: String!`
`$description: String` | `document_insert` | +| `updateDocument` | Update document | `$id: UUID!`
`$documentType: DocumentType`
`$name: String`
`$description: String` | `document_update` | +| `deleteDocument` | Delete document | `$id: UUID!` | `document_delete` | ---- -## Document -*Manages document types and definitions.* +## emergencyContact ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listDocuments` | Retrieves all document definitions. | - | `[Document]` | -| `getDocumentById` | Fetches a single document definition by ID. | `id: UUID!` | `Document` | -| `filterDocuments` | Searches document definitions by type. | `documentType` | `[Document]` | +|------|---------|------------|---------| +| `listEmergencyContacts` | List emergency contacts | — | `emergencyContacts` | +| `getEmergencyContactById` | Get emergency contact by id | `$id: UUID!` | `emergencyContact` | +| `getEmergencyContactsByStaffId` | Get emergency contacts by staff id | `$staffId: UUID!` | `emergencyContacts` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createDocument` | Adds a new document definition. | `documentType`, `name`, `description` | `Document` | -| `updateDocument` | Modifies an existing document definition. | `id`, `name`, `description`, ... | `Document` | -| `deleteDocument` | Removes a document definition. | `id: UUID!` | `Document` | +|------|---------|------------|---------| +| `createEmergencyContact` | Create emergency contact | `$name: String!`
`$phone: String!`
`$relationship: RelationshipType!`
`$staffId: UUID!` | `emergencyContact_insert` | +| `updateEmergencyContact` | Update emergency contact | `$id: UUID!`
`$name: String`
`$phone: String`
`$relationship: RelationshipType` | `emergencyContact_update` | +| `deleteEmergencyContact` | Delete emergency contact | `$id: UUID!` | `emergencyContact_delete` | ---- -## FaqData -*Manages Frequently Asked Questions.* +## faqData ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listFaqDatas` | Retrieves all FAQ data. | - | `[FaqData]` | -| `getFaqDataById` | Fetches a single FAQ data set by ID. | `id: UUID!` | `FaqData` | -| `filterFaqDatas`| Searches FAQ data by category. | `category` | `[FaqData]` | +|------|---------|------------|---------| +| `listFaqDatas` | List faq datas | — | `faqDatas` | +| `getFaqDataById` | Get faq data by id | `$id: UUID!` | `faqData` | +| `filterFaqDatas` | Filter faq datas | `$category: String` | `faqDatas` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createFaqData` | Adds new FAQ data. | `category`, `questions` | `FaqData` | -| `updateFaqData` | Modifies existing FAQ data. | `id`, `category`, `questions` | `FaqData` | -| `deleteFaqData` | Removes FAQ data. | `id: UUID!` | `FaqData` | +|------|---------|------------|---------| +| `createFaqData` | Create faq data | `$category: String!`
`$questions: [Any!]` | `faqData_insert` | +| `updateFaqData` | Update faq data | `$id: UUID!`
`$category: String`
`$questions: [Any!]` | `faqData_update` | +| `deleteFaqData` | Delete faq data | `$id: UUID!` | `faqData_delete` | ---- -## Hub -*Manages physical locations or hubs.* +## hub ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listHubs` | Retrieves all hubs. | - | `[Hub]` | -| `getHubById` | Fetches a single hub by ID. | `id: UUID!` | `Hub` | -| `getHubsByOwnerId` | Lists all hubs for a specific owner. | `ownerId: UUID!` | `[Hub]` | -| `filterHubs` | Searches hubs by owner, name, or NFC tag ID. | `ownerId`, `name`, `nfcTagId` | `[Hub]` | +|------|---------|------------|---------| +| `listHubs` | List hubs | — | `hubs` | +| `getHubById` | Get hub by id | `$id: UUID!` | `hub` | +| `getHubsByOwnerId` | Get hubs by owner id | `$ownerId: UUID!` | `hubs` | +| `filterHubs` | Filter hubs | `$ownerId: UUID`
`$name: String`
`$nfcTagId: String` | `hubs` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createHub` | Adds a new hub. | `name`, `ownerId`, `address`, ... | `Hub` | -| `updateHub` | Modifies an existing hub. | `id`, `name`, `address`, ... | `Hub` | -| `deleteHub` | Removes a hub. | `id: UUID!` | `Hub` | +|------|---------|------------|---------| +| `createHub` | Create hub | `$name: String!`
`$locationName: String`
`$address: String`
`$nfcTagId: String`
`$ownerId: UUID!` | `hub_insert` | +| `updateHub` | Update hub | `$id: UUID!`
`$name: String`
`$locationName: String`
`$address: String`
`$nfcTagId: String`
`$ownerId: UUID` | `hub_update` | +| `deleteHub` | Delete hub | `$id: UUID!` | `hub_delete` | ---- -## Invoice -*Manages billing and invoices.* +## invoice ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listInvoices` | Retrieves all invoices. | `offset`, `limit` | `[Invoice]` | -| `getInvoiceById` | Fetches a single invoice by ID. | `id: UUID!` | `Invoice` | -| `listInvoicesByVendorId`| Lists invoices for a vendor. | `vendorId`, `offset`, `limit` | `[Invoice]` | -| `listInvoicesByBusinessId`| Lists invoices for a business. | `businessId`, `offset`, `limit` | `[Invoice]` | -| `listInvoicesByOrderId`| Lists invoices for an order. | `orderId`, `offset`, `limit` | `[Invoice]` | -| `listInvoicesByStatus`| Lists invoices by status. | `status`, `offset`, `limit` | `[Invoice]` | -| `filterInvoices` | Searches invoices by various criteria and date ranges. | `vendorId`, `businessId`, `status`, `issueDateFrom`, ... | `[Invoice]` | -| `listOverdueInvoices`| Retrieves all overdue, unpaid invoices. | `now: Timestamp!`, `offset`, `limit` | `[Invoice]` | +|------|---------|------------|---------| +| `listInvoices` | List invoices | `$offset: Int`
`$limit: Int` | `invoices` | +| `getInvoiceById` | Get invoice by id | `$id: UUID!` | `invoice` | +| `listInvoicesByVendorId` | List invoices by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `invoices` | +| `listInvoicesByBusinessId` | List invoices by business id | `$businessId: UUID!`
`$offset: Int`
`$limit: Int` | `invoices` | +| `listInvoicesByOrderId` | List invoices by order id | `$orderId: UUID!`
`$offset: Int`
`$limit: Int` | `invoices` | +| `listInvoicesByStatus` | List invoices by status | `$status: InvoiceStatus!`
`$offset: Int`
`$limit: Int` | `invoices` | +| `filterInvoices` | Filter invoices | `$vendorId: UUID`
`$businessId: UUID`
`$orderId: UUID`
`$status: InvoiceStatus`
`$issueDateFrom: Timestamp`
`$issueDateTo: Timestamp`
`$dueDateFrom: Timestamp`
`$dueDateTo: Timestamp`
`$offset: Int`
`$limit: Int` | `invoices` | +| `listOverdueInvoices` | List overdue invoices | `$now: Timestamp!`
`$offset: Int`
`$limit: Int` | `invoices` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createInvoice` | Creates a new invoice. | `status`, `vendorId`, `businessId`, `orderId`, ... | `Invoice` | -| `updateInvoice` | Modifies an existing invoice. | `id`, `status`, `notes`, `disputeReason`, ... | `Invoice` | -| `deleteInvoice` | Deletes an invoice. | `id: UUID!` | `Invoice` | +|------|---------|------------|---------| +| `createInvoice` | Create invoice | `$status: InvoiceStatus!`
`$vendorId: UUID!`
`$businessId: UUID!`
`$orderId: UUID!`
`$paymentTerms: InovicePaymentTerms`
`$invoiceNumber: String!`
`$issueDate: Timestamp!`
`$dueDate: Timestamp!`
`$hub: String`
`$managerName: String`
`$vendorNumber: String`
`$roles: Any`
`$charges: Any`
`$otherCharges: Float`
`$subtotal: Float`
`$amount: Float!`
`$notes: String`
`$staffCount: Int`
`$chargesCount: Int` | `invoice_insert` | +| `updateInvoice` | Update invoice | `$id: UUID!`
`$status: InvoiceStatus`
`$vendorId: UUID`
`$businessId: UUID`
`$orderId: UUID`
`$paymentTerms: InovicePaymentTerms`
`$invoiceNumber: String`
`$issueDate: Timestamp`
`$dueDate: Timestamp`
`$hub: String`
`$managerName: String`
`$vendorNumber: String`
`$roles: Any`
`$charges: Any`
`$otherCharges: Float`
`$subtotal: Float`
`$amount: Float`
`$notes: String`
`$staffCount: Int`
`$chargesCount: Int`
`$disputedItems: Any`
`$disputeReason: String`
`$disputeDetails: String` | `invoice_update` | +| `deleteInvoice` | Delete invoice | `$id: UUID!` | `invoice_delete` | ---- -## InvoiceTemplate -*Manages templates for creating invoices.* +## invoiceTemplate ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listInvoiceTemplates`| Retrieves all invoice templates. | `offset`, `limit` | `[InvoiceTemplate]` | -| `getInvoiceTemplateById`| Fetches a single template by ID. | `id: UUID!` | `InvoiceTemplate` | -| `listInvoiceTemplatesByOwnerId`| Lists templates for a specific owner. | `ownerId`, `offset`, `limit` | `[InvoiceTemplate]` | -| `listInvoiceTemplatesByVendorId`| Lists templates tied to a vendor. | `vendorId`, `offset`, `limit` | `[InvoiceTemplate]` | -| `listInvoiceTemplatesByBusinessId`| Lists templates tied to a business. | `businessId`, `offset`, `limit` | `[InvoiceTemplate]` | -| `listInvoiceTemplatesByOrderId`| Lists templates tied to an order. | `orderId`, `offset`, `limit` | `[InvoiceTemplate]` | -| `searchInvoiceTemplatesByOwnerAndName`| Searches for a template by name for a specific owner. | `ownerId`, `name`, `offset`, `limit` | `[InvoiceTemplate]` | +|------|---------|------------|---------| +| `listInvoiceTemplates` | List invoice templates | `$offset: Int`
`$limit: Int` | `invoiceTemplates` | +| `getInvoiceTemplateById` | Get invoice template by id | `$id: UUID!` | `invoiceTemplate` | +| `listInvoiceTemplatesByOwnerId` | List invoice templates by owner id | `$ownerId: UUID!`
`$offset: Int`
`$limit: Int` | `invoiceTemplates` | +| `listInvoiceTemplatesByVendorId` | List invoice templates by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `invoiceTemplates` | +| `listInvoiceTemplatesByBusinessId` | List invoice templates by business id | `$businessId: UUID!`
`$offset: Int`
`$limit: Int` | `invoiceTemplates` | +| `listInvoiceTemplatesByOrderId` | List invoice templates by order id | `$orderId: UUID!`
`$offset: Int`
`$limit: Int` | `invoiceTemplates` | +| `searchInvoiceTemplatesByOwnerAndName` | Search invoice templates by owner and name | `$ownerId: UUID!`
`$name: String!`
`$offset: Int`
`$limit: Int` | `invoiceTemplates` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createInvoiceTemplate`| Adds a new invoice template. | `name`, `ownerId`, ... | `InvoiceTemplate` | -| `updateInvoiceTemplate`| Modifies an existing template. | `id`, `name`, ... | `InvoiceTemplate` | -| `deleteInvoiceTemplate`| Removes a template. | `id: UUID!` | `InvoiceTemplate` | +|------|---------|------------|---------| +| `createInvoiceTemplate` | Create invoice template | `$name: String!`
`$ownerId: UUID!`
`$vendorId: UUID`
`$businessId: UUID`
`$orderId: UUID`
`$paymentTerms: InovicePaymentTermsTemp`
`$invoiceNumber: String`
`$issueDate: Timestamp`
`$dueDate: Timestamp`
`$hub: String`
`$managerName: String`
`$vendorNumber: String`
`$roles: Any`
`$charges: Any`
`$otherCharges: Float`
`$subtotal: Float`
`$amount: Float`
`$notes: String`
`$staffCount: Int`
`$chargesCount: Int` | `invoiceTemplate_insert` | +| `updateInvoiceTemplate` | Update invoice template | `$id: UUID!`
`$name: String`
`$ownerId: UUID`
`$vendorId: UUID`
`$businessId: UUID`
`$orderId: UUID`
`$paymentTerms: InovicePaymentTermsTemp`
`$invoiceNumber: String`
`$issueDate: Timestamp`
`$dueDate: Timestamp`
`$hub: String`
`$managerName: String`
`$vendorNumber: String`
`$roles: Any`
`$charges: Any`
`$otherCharges: Float`
`$subtotal: Float`
`$amount: Float`
`$notes: String`
`$staffCount: Int`
`$chargesCount: Int` | `invoiceTemplate_update` | +| `deleteInvoiceTemplate` | Delete invoice template | `$id: UUID!` | `invoiceTemplate_delete` | ---- -## Level -*Manages experience levels for staff.* +## level ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listLevels` | Retrieves all levels. | - | `[Level]` | -| `getLevelById` | Fetches a single level by ID. | `id: UUID!` | `Level` | -| `filterLevels` | Searches levels by name or XP required. | `name`, `xpRequired` | `[Level]` | +|------|---------|------------|---------| +| `listLevels` | List levels | — | `levels` | +| `getLevelById` | Get level by id | `$id: UUID!` | `level` | +| `filterLevels` | Filter levels | `$name: String`
`$xpRequired: Int` | `levels` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createLevel` | Adds a new level. | `name`, `xpRequired`, `icon`, `colors` | `Level` | -| `updateLevel` | Modifies an existing level. | `id`, `name`, `xpRequired`, ... | `Level` | -| `deleteLevel` | Removes a level. | `id: UUID!` | `Level` | +|------|---------|------------|---------| +| `createLevel` | Create level | `$name: String!`
`$xpRequired: Int!`
`$icon: String`
`$colors: Any` | `level_insert` | +| `updateLevel` | Update level | `$id: UUID!`
`$name: String`
`$xpRequired: Int`
`$icon: String`
`$colors: Any` | `level_update` | +| `deleteLevel` | Delete level | `$id: UUID!` | `level_delete` | ---- -## MemberTask -*Junction table linking Team Members to Tasks.* +## memberTask ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `getMyTasks` | Retrieves all tasks assigned to a team member. | `teamMemberId: UUID!` | `[MemberTask]` | -| `getMemberTaskByIdKey`| Fetches a single assignment by team member and task ID. | `teamMemberId`, `taskId` | `MemberTask` | -| `getMemberTasksByTaskId`| Lists all members assigned to a specific task. | `taskId: UUID!` | `[MemberTask]` | +|------|---------|------------|---------| +| `getMyTasks` | Get my tasks | `$teamMemberId: UUID!` | `memberTasks` | +| `getMemberTaskByIdKey` | Get member task by id key | `$teamMemberId: UUID!`
`$taskId: UUID!` | `memberTask` | +| `getMemberTasksByTaskId` | Get member tasks by task id | `$taskId: UUID!` | `memberTasks` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createMemberTask` | Assigns a task to a team member. | `teamMemberId`, `taskId` | `MemberTask` | -| `deleteMemberTask` | Unassigns a task from a team member. | `teamMemberId`, `taskId` | `MemberTask` | +|------|---------|------------|---------| +| `createMemberTask` | Create member task | `$teamMemberId: UUID!`
`$taskId: UUID!` | `memberTask_insert` | +| `deleteMemberTask` | Delete member task | `$teamMemberId: UUID!`
`$taskId: UUID!` | `memberTask_delete` | ---- -## Message -*Manages individual messages within a conversation.* +## message ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listMessages` | Retrieves all messages. | - | `[Message]` | -| `getMessageById` | Fetches a single message by ID. | `id: UUID!` | `Message` | -| `getMessagesByConversationId`| Lists all messages for a specific conversation. | `conversationId: UUID!` | `[Message]` | +|------|---------|------------|---------| +| `listMessages` | List messages | — | `messages` | +| `getMessageById` | Get message by id | `$id: UUID!` | `message` | +| `getMessagesByConversationId` | Get messages by conversation id | `$conversationId: UUID!` | `messages` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createMessage` | Sends a new message to a conversation. | `conversationId`, `senderId`, `content`, `isSystem` | `Message` | -| `updateMessage` | Modifies an existing message. | `id`, `content`, ... | `Message` | -| `deleteMessage` | Deletes a message. | `id: UUID!` | `Message` | +|------|---------|------------|---------| +| `createMessage` | Create message | `$conversationId: UUID!`
`$senderId: String!`
`$content: String!`
`$isSystem: Boolean` | `message_insert` | +| `updateMessage` | Update message | `$id: UUID!`
`$conversationId: UUID`
`$senderId: String`
`$content: String`
`$isSystem: Boolean` | `message_update` | +| `deleteMessage` | Delete message | `$id: UUID!` | `message_delete` | ---- -## Order -*Manages work orders from businesses.* +## order ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listOrders` | Retrieves all orders. | `offset`, `limit` | `[Order]` | -| `getOrderById` | Fetches a single order by ID. | `id: UUID!` | `Order` | -| `getOrdersByBusinessId`| Lists orders for a business. | `businessId`, `offset`, `limit` | `[Order]` | -| `getOrdersByVendorId`| Lists orders for a vendor. | `vendorId`, `offset`, `limit` | `[Order]` | -| `getOrdersByStatus`| Lists orders by status. | `status`, `offset`, `limit` | `[Order]` | -| `getOrdersByDateRange`| Lists orders within a date range. | `start`, `end`, `offset`, `limit` | `[Order]` | -| `getRapidOrders` | Retrieves all orders marked as "RAPID". | `offset`, `limit` | `[Order]` | +|------|---------|------------|---------| +| `listOrders` | List orders | `$offset: Int`
`$limit: Int` | `orders` | +| `getOrderById` | Get order by id | `$id: UUID!` | `order` | +| `getOrdersByBusinessId` | Get orders by business id | `$businessId: UUID!`
`$offset: Int`
`$limit: Int` | `orders` | +| `getOrdersByVendorId` | Get orders by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `orders` | +| `getOrdersByStatus` | Get orders by status | `$status: OrderStatus!`
`$offset: Int`
`$limit: Int` | `orders` | +| `getOrdersByDateRange` | Get orders by date range | `$start: Timestamp!`
`$end: Timestamp!`
`$offset: Int`
`$limit: Int` | `orders` | +| `getRapidOrders` | Get rapid orders | `$offset: Int`
`$limit: Int` | `orders` | +| `listOrdersByBusinessAndTeamHub` | List orders by business and team hub | `$businessId: UUID!`
`$teamHubId: UUID!`
`$offset: Int`
`$limit: Int` | `orders` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createOrder` | Creates a new order. | `vendorId`, `businessId`, `orderType`, `eventName`, ... | `Order` | -| `updateOrder` | Modifies an existing order. | `id`, `status`, `eventName`, ... | `Order` | -| `deleteOrder` | Deletes an order. | `id: UUID!` | `Order` | +|------|---------|------------|---------| +| `createOrder` | Create order | `$vendorId: UUID`
`$businessId: UUID!`
`$orderType: OrderType!`
`$status: OrderStatus`
`$date: Timestamp`
`$startDate: Timestamp`
`$endDate: Timestamp`
`$duration: OrderDuration`
`$lunchBreak: Int`
`$total: Float`
`$eventName: String`
`$assignedStaff: Any`
`$shifts: Any`
`$requested: Int`
`$teamHubId: UUID!`
`$recurringDays: Any`
`$permanentStartDate: Timestamp`
`$permanentDays: Any`
`$notes: String`
`$detectedConflicts: Any`
`$poReference: String` | `order_insert` | +| `updateOrder` | Update order | `$id: UUID!`
`$vendorId: UUID`
`$businessId: UUID`
`$status: OrderStatus`
`$date: Timestamp`
`$startDate: Timestamp`
`$endDate: Timestamp`
`$total: Float`
`$eventName: String`
`$assignedStaff: Any`
`$shifts: Any`
`$requested: Int`
`$teamHubId: UUID!`
`$recurringDays: Any`
`$permanentDays: Any`
`$notes: String`
`$detectedConflicts: Any`
`$poReference: String` | `order_update` | +| `deleteOrder` | Delete order | `$id: UUID!` | `order_delete` | ---- -## RecentPayment -*Tracks recent payments related to invoices and applications.* +## recentPayment ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listRecentPayments` | Retrieves all recent payments. | `offset`, `limit` | `[RecentPayment]` | -| `getRecentPaymentById`| Fetches a single payment by ID. | `id: UUID!` | `RecentPayment` | -| `listRecentPaymentsByStaffId`| Lists all recent payments for a staff member. | `staffId`, `offset`, `limit` | `[RecentPayment]` | -| `listRecentPaymentsByApplicationId`| Lists payments for an application. | `applicationId`, `offset`, `limit` | `[RecentPayment]` | -| `listRecentPaymentsByInvoiceId`| Lists all payments made for an invoice. | `invoiceId`, `offset`, `limit` | `[RecentPayment]` | -| `listRecentPaymentsByStatus`| Lists recent payments by status. | `status`, `offset`, `limit` | `[RecentPayment]` | -| `listRecentPaymentsByInvoiceIds`| Lists payments for a set of invoices. | `invoiceIds`, `offset`, `limit` | `[RecentPayment]` | +|------|---------|------------|---------| +| `listRecentPayments` | List recent payments | `$offset: Int`
`$limit: Int` | `recentPayments` | +| `getRecentPaymentById` | Get recent payment by id | `$id: UUID!` | `recentPayment` | +| `listRecentPaymentsByStaffId` | List recent payments by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `recentPayments` | +| `listRecentPaymentsByApplicationId` | List recent payments by application id | `$applicationId: UUID!`
`$offset: Int`
`$limit: Int` | `recentPayments` | +| `listRecentPaymentsByInvoiceId` | List recent payments by invoice id | `$invoiceId: UUID!`
`$offset: Int`
`$limit: Int` | `recentPayments` | +| `listRecentPaymentsByStatus` | List recent payments by status | `$status: RecentPaymentStatus!`
`$offset: Int`
`$limit: Int` | `recentPayments` | +| `listRecentPaymentsByInvoiceIds` | List recent payments by invoice ids | `$invoiceIds: [UUID!]!`
`$offset: Int`
`$limit: Int` | `recentPayments` | +| `listRecentPaymentsByBusinessId` | List recent payments by business id | `$businessId: UUID!`
`$offset: Int`
`$limit: Int` | `recentPayments` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createRecentPayment` | Creates a new recent payment record. | `staffId`, `applicationId`, `invoiceId`, `status`, ... | `RecentPayment` | -| `updateRecentPayment` | Modifies an existing payment record. | `id`, `status`, ... | `RecentPayment` | -| `deleteRecentPayment` | Deletes a payment record. | `id: UUID!` | `RecentPayment` | +|------|---------|------------|---------| +| `createRecentPayment` | Create recent payment | `$workedTime: String`
`$status: RecentPaymentStatus`
`$staffId: UUID!`
`$applicationId: UUID!`
`$invoiceId: UUID!` | `recentPayment_insert` | +| `updateRecentPayment` | Update recent payment | `$id: UUID!`
`$workedTime: String`
`$status: RecentPaymentStatus`
`$staffId: UUID`
`$applicationId: UUID`
`$invoiceId: UUID` | `recentPayment_update` | +| `deleteRecentPayment` | Delete recent payment | `$id: UUID!` | `recentPayment_delete` | ---- -## Reports -*Provides source data queries for client-side reports.* +## reports ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listShiftsForCoverage`| Fetches shift data for coverage reports. | `businessId`, `startDate`, `endDate` | `[Shift]` | -| `listApplicationsForCoverage`| Fetches application data for coverage reports. | `shiftIds` | `[Application]` | -| `listShiftsForDailyOpsByBusiness`| Fetches shift data for daily ops reports (business view). | `businessId`, `date` | `[Shift]` | -| `listShiftsForDailyOpsByVendor`| Fetches shift data for daily ops reports (vendor view). | `vendorId`, `date` | `[Shift]` | -| `listApplicationsForDailyOps`| Fetches application data for daily ops reports. | `shiftIds` | `[Application]` | -| `listShiftsForForecastByBusiness`| Fetches shift data for forecast reports (business view). | `businessId`, `startDate`, `endDate` | `[Shift]` | -| `listShiftsForForecastByVendor`| Fetches shift data for forecast reports (vendor view). | `vendorId`, `startDate`, `endDate` | `[Shift]` | -| `listShiftsForNoShowRangeByBusiness`| Fetches shift IDs for no-show reports (business view). | `businessId`, `startDate`, `endDate` | `[Shift]` | -| `listShiftsForNoShowRangeByVendor`| Fetches shift IDs for no-show reports (vendor view). | `vendorId`, `startDate`, `endDate` | `[Shift]` | -| `listApplicationsForNoShowRange`| Fetches application data for no-show reports. | `shiftIds` | `[Application]` | -| `listStaffForNoShowReport`| Fetches staff data for no-show reports. | `staffIds` | `[Staff]` | -| `listInvoicesForSpendByBusiness`| Fetches invoice data for spending reports (business view). | `businessId`, `startDate`, `endDate` | `[Invoice]` | -| `listInvoicesForSpendByVendor`| Fetches invoice data for spending reports (vendor view). | `vendorId`, `startDate`, `endDate` | `[Invoice]` | -| `listInvoicesForSpendByOrder`| Fetches invoice data for spending reports by order. | `orderId`, `startDate`, `endDate` | `[Invoice]` | -| `listTimesheetsForSpend`| Fetches timesheet (shift role) data for spending reports. | `startTime`, `endTime` | `[ShiftRole]` | -| `listShiftsForPerformanceByBusiness`| Fetches shift data for performance reports (business view). | `businessId`, `startDate`, `endDate` | `[Shift]` | -| `listShiftsForPerformanceByVendor`| Fetches shift data for performance reports (vendor view). | `vendorId`, `startDate`, `endDate` | `[Shift]` | -| `listApplicationsForPerformance`| Fetches application data for performance reports. | `shiftIds` | `[Application]` | -| `listStaffForPerformance`| Fetches staff data for performance reports. | `staffIds` | `[Staff]` | - -### Mutations -> No mutations found. - ---- -## Role -*Manages job roles and their pay rates.* - -### Queries -| Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listRoles` | Retrieves all roles. | - | `[Role]` | -| `getRoleById` | Fetches a single role by ID. | `id: UUID!` | `Role` | -| `listRolesByVendorId`| Lists all roles for a specific vendor. | `vendorId: UUID!` | `[Role]` | -| `listRolesByroleCategoryId`| Lists all roles within a specific category. | `roleCategoryId: UUID!` | `[Role]` | +|------|---------|------------|---------| +| `listShiftsForCoverage` | List shifts for coverage | `$businessId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listApplicationsForCoverage` | List applications for coverage | `$shiftIds: [UUID!]!` | `applications` | +| `listShiftsForDailyOpsByBusiness` | List shifts for daily ops by business | `$businessId: UUID!`
`$date: Timestamp!` | `shifts` | +| `listShiftsForDailyOpsByVendor` | List shifts for daily ops by vendor | `$vendorId: UUID!`
`$date: Timestamp!` | `shifts` | +| `listApplicationsForDailyOps` | List applications for daily ops | `$shiftIds: [UUID!]!` | `applications` | +| `listShiftsForForecastByBusiness` | List shifts for forecast by business | `$businessId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listShiftsForForecastByVendor` | List shifts for forecast by vendor | `$vendorId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listShiftsForNoShowRangeByBusiness` | List shifts for no show range by business | `$businessId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listShiftsForNoShowRangeByVendor` | List shifts for no show range by vendor | `$vendorId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listApplicationsForNoShowRange` | List applications for no show range | `$shiftIds: [UUID!]!` | `applications` | +| `listStaffForNoShowReport` | List staff for no show report | `$staffIds: [UUID!]!` | `staffs` | +| `listInvoicesForSpendByBusiness` | List invoices for spend by business | `$businessId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `invoices` | +| `listInvoicesForSpendByVendor` | List invoices for spend by vendor | `$vendorId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `invoices` | +| `listInvoicesForSpendByOrder` | List invoices for spend by order | `$orderId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `invoices` | +| `listTimesheetsForSpend` | List timesheets for spend | `$startTime: Timestamp!`
`$endTime: Timestamp!` | `shiftRoles` | +| `listShiftsForPerformanceByBusiness` | List shifts for performance by business | `$businessId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listShiftsForPerformanceByVendor` | List shifts for performance by vendor | `$vendorId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listApplicationsForPerformance` | List applications for performance | `$shiftIds: [UUID!]!` | `applications` | +| `listStaffForPerformance` | List staff for performance | `$staffIds: [UUID!]!` | `staffs` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createRole` | Adds a new role. | `name`, `costPerHour`, `vendorId`, `roleCategoryId` | `Role` | -| `updateRole` | Modifies an existing role. | `id`, `name`, `costPerHour`, `roleCategoryId` | `Role` | -| `deleteRole` | Removes a role. | `id: UUID!` | `Role` | +|------|---------|------------|---------| +| — | — | — | — | ---- -## RoleCategory -*Manages categories for roles.* +Notes: Used by Reports. + +## role ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listRoleCategories` | Retrieves all role categories. | - | `[RoleCategory]` | -| `getRoleCategoryById`| Fetches a single role category by ID. | `id: UUID!` | `RoleCategory` | -| `getRoleCategoriesByCategory`| Lists role categories by type. | `category: RoleCategoryType!` | `[RoleCategory]` | +|------|---------|------------|---------| +| `listRoles` | List roles | — | `roles` | +| `getRoleById` | Get role by id | `$id: UUID!` | `role` | +| `listRolesByVendorId` | List roles by vendor id | `$vendorId: UUID!` | `roles` | +| `listRolesByroleCategoryId` | List roles byrole category id | `$roleCategoryId: UUID!` | `roles` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createRoleCategory` | Adds a new role category. | `roleName`, `category` | `RoleCategory` | -| `updateRoleCategory` | Modifies an existing role category. | `id`, `roleName`, `category` | `RoleCategory` | -| `deleteRoleCategory` | Removes a role category. | `id: UUID!` | `RoleCategory` | +|------|---------|------------|---------| +| `createRole` | Create role | `$name: String!`
`$costPerHour: Float!`
`$vendorId: UUID!`
`$roleCategoryId: UUID!` | `role_insert` | +| `updateRole` | Update role | `$id: UUID!`
`$name: String`
`$costPerHour: Float`
`$roleCategoryId: UUID!` | `role_update` | +| `deleteRole` | Delete role | `$id: UUID!` | `role_delete` | ---- -## Shift -*Manages individual shifts within an order.* +## roleCategory ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listShifts` | Retrieves all shifts. | `offset`, `limit` | `[Shift]` | -| `getShiftById` | Fetches a single shift by ID. | `id: UUID!` | `Shift` | -| `filterShifts` | Searches shifts by status, order, and date range. | `status`, `orderId`, `dateFrom`, `dateTo`, ... | `[Shift]` | -| `getShiftsByBusinessId`| Lists shifts for a business within a date range. | `businessId`, `dateFrom`, `dateTo`, ... | `[Shift]` | -| `getShiftsByVendorId`| Lists shifts for a vendor within a date range. | `vendorId`, `dateFrom`, `dateTo`, ... | `[Shift]` | +|------|---------|------------|---------| +| `listRoleCategories` | List role categories | — | `roleCategories` | +| `getRoleCategoryById` | Get role category by id | `$id: UUID!` | `roleCategory` | +| `getRoleCategoriesByCategory` | Get role categories by category | `$category: RoleCategoryType!` | `roleCategories` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createShift` | Creates a new shift for an order. | `title`, `orderId`, `startTime`, `endTime`, ... | `Shift` | -| `updateShift` | Modifies an existing shift. | `id`, `title`, `status`, ... | `Shift` | -| `deleteShift` | Deletes a shift. | `id: UUID!` | `Shift` | +|------|---------|------------|---------| +| `createRoleCategory` | Create role category | `$roleName: String!`
`$category: RoleCategoryType!` | `roleCategory_insert` | +| `updateRoleCategory` | Update role category | `$id: UUID!`
`$roleName: String`
`$category: RoleCategoryType` | `roleCategory_update` | +| `deleteRoleCategory` | Delete role category | `$id: UUID!` | `roleCategory_delete` | ---- -## ShiftRole -*Junction table for roles needed in a shift.* +## shift ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `getShiftRoleById` | Fetches a single shift role by its composite key. | `shiftId`, `roleId` | `ShiftRole` | -| `listShiftRolesByShiftId`| Lists all roles needed for a specific shift. | `shiftId`, `offset`, `limit` | `[ShiftRole]` | -| `listShiftRolesByRoleId`| Lists all shifts that require a specific role. | `roleId`, `offset`, `limit` | `[ShiftRole]` | -| `listShiftRolesByShiftIdAndTimeRange`| Lists roles for a shift within a time range. | `shiftId`, `start`, `end`, ... | `[ShiftRole]` | +|------|---------|------------|---------| +| `listShifts` | List shifts | `$offset: Int`
`$limit: Int` | `shifts` | +| `getShiftById` | Get shift by id | `$id: UUID!` | `shift` | +| `filterShifts` | Filter shifts | `$status: ShiftStatus`
`$orderId: UUID`
`$dateFrom: Timestamp`
`$dateTo: Timestamp`
`$offset: Int`
`$limit: Int` | `shifts` | +| `getShiftsByBusinessId` | Get shifts by business id | `$businessId: UUID!`
`$dateFrom: Timestamp`
`$dateTo: Timestamp`
`$offset: Int`
`$limit: Int` | `shifts` | +| `getShiftsByVendorId` | Get shifts by vendor id | `$vendorId: UUID!`
`$dateFrom: Timestamp`
`$dateTo: Timestamp`
`$offset: Int`
`$limit: Int` | `shifts` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createShiftRole` | Adds a role requirement to a shift. | `shiftId`, `roleId`, `count`, ... | `ShiftRole` | -| `updateShiftRole` | Modifies a role requirement on a shift. | `shiftId`, `roleId`, `count`, ... | `ShiftRole` | -| `deleteShiftRole` | Removes a role requirement from a shift. | `shiftId`, `roleId` | `ShiftRole` | +|------|---------|------------|---------| +| `createShift` | Create shift | `$title: String!`
`$orderId: UUID!`
`$date: Timestamp`
`$startTime: Timestamp`
`$endTime: Timestamp`
`$hours: Float`
`$cost: Float`
`$location: String`
`$locationAddress: String`
`$latitude: Float`
`$longitude: Float`
`$placeId: String`
`$city: String`
`$state: String`
`$street: String`
`$country: String`
`$description: String`
`$status: ShiftStatus`
`$workersNeeded: Int`
`$filled: Int`
`$filledAt: Timestamp`
`$managers: [Any!]`
`$durationDays: Int`
`$createdBy: String` | `shift_insert` | +| `updateShift` | Update shift | `$id: UUID!`
`$title: String`
`$orderId: UUID`
`$date: Timestamp`
`$startTime: Timestamp`
`$endTime: Timestamp`
`$hours: Float`
`$cost: Float`
`$location: String`
`$locationAddress: String`
`$latitude: Float`
`$longitude: Float`
`$placeId: String`
`$city: String`
`$state: String`
`$street: String`
`$country: String`
`$description: String`
`$status: ShiftStatus`
`$workersNeeded: Int`
`$filled: Int`
`$filledAt: Timestamp`
`$managers: [Any!]`
`$durationDays: Int` | `shift_update` | +| `deleteShift` | Delete shift | `$id: UUID!` | `shift_delete` | ---- -## Staff -*Manages staff profiles.* +## shiftRole ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listStaff` | Retrieves all staff members. | - | `[Staff]` | -| `getStaffById` | Fetches a single staff member by ID. | `id: UUID!` | `Staff` | -| `getStaffByUserId` | Fetches the staff profile for a user. | `userId: String!` | `[Staff]` | -| `filterStaff` | Searches for staff by owner, name, level, or email. | `ownerId`, `fullName`, `level`, `email` | `[Staff]` | +|------|---------|------------|---------| +| `getShiftRoleById` | Get shift role by id | `$shiftId: UUID!`
`$roleId: UUID!` | `shiftRole` | +| `listShiftRolesByShiftId` | List shift roles by shift id | `$shiftId: UUID!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `listShiftRolesByRoleId` | List shift roles by role id | `$roleId: UUID!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `listShiftRolesByShiftIdAndTimeRange` | List shift roles by shift id and time range | `$shiftId: UUID!`
`$start: Timestamp!`
`$end: Timestamp!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `listShiftRolesByVendorId` | List shift roles by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `listShiftRolesByBusinessAndDateRange` | List shift roles by business and date range | `$businessId: UUID!`
`$start: Timestamp!`
`$end: Timestamp!`
`$offset: Int`
`$limit: Int`
`$status: ShiftStatus` | `shiftRoles` | +| `listShiftRolesByBusinessAndOrder` | List shift roles by business and order | `$businessId: UUID!`
`$orderId: UUID!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `listShiftRolesByBusinessDateRangeCompletedOrders` | List shift roles by business date range completed orders | `$businessId: UUID!`
`$start: Timestamp!`
`$end: Timestamp!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `listShiftRolesByBusinessAndDatesSummary` | List shift roles by business and dates summary | `$businessId: UUID!`
`$start: Timestamp!`
`$end: Timestamp!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `getCompletedShiftsByBusinessId` | Get completed shifts by business id | `$businessId: UUID!`
`$dateFrom: Timestamp!`
`$dateTo: Timestamp!`
`$offset: Int`
`$limit: Int` | `shifts` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `CreateStaff` | Creates a new staff profile. | `userId`, `fullName`, ... | `Staff` | -| `UpdateStaff` | Modifies an existing staff profile. | `id`, `fullName`, `phone`, `email`, ... | `Staff` | -| `DeleteStaff` | Deletes a staff profile. | `id: UUID!` | `Staff` | +|------|---------|------------|---------| +| `createShiftRole` | Create shift role | `$shiftId: UUID!`
`$roleId: UUID!`
`$count: Int!`
`$assigned: Int`
`$startTime: Timestamp`
`$endTime: Timestamp`
`$hours: Float`
`$department: String`
`$uniform: String`
`$breakType: BreakDuration`
`$isBreakPaid: Boolean`
`$totalValue: Float` | `shiftRole_insert` | +| `updateShiftRole` | Update shift role | `$shiftId: UUID!`
`$roleId: UUID!`
`$count: Int`
`$assigned: Int`
`$startTime: Timestamp`
`$endTime: Timestamp`
`$hours: Float`
`$department: String`
`$uniform: String`
`$breakType: BreakDuration`
`$isBreakPaid: Boolean`
`$totalValue: Float` | `shiftRole_update` | +| `deleteShiftRole` | Delete shift role | `$shiftId: UUID!`
`$roleId: UUID!` | `shiftRole_delete` | ---- -## StaffAvailability -*Manages staff availability schedules.* +## staff ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listStaffAvailabilities`| Retrieves all availability records. | `offset`, `limit` | `[StaffAvailability]` | -| `listStaffAvailabilitiesByStaffId`| Lists all availability for a staff member. | `staffId`, `offset`, `limit` | `[StaffAvailability]` | -| `getStaffAvailabilityByKey`| Fetches availability for a specific day and time slot. | `staffId`, `day`, `slot` | `StaffAvailability` | -| `listStaffAvailabilitiesByDay`| Lists all staff availability for a specific day. | `day`, `offset`, `limit` | `[StaffAvailability]` | +|------|---------|------------|---------| +| `listStaff` | List staff | — | `staffs` | +| `getStaffById` | Get staff by id | `$id: UUID!` | `staff` | +| `getStaffByUserId` | Get staff by user id | `$userId: String!` | `staffs` | +| `filterStaff` | Filter staff | `$ownerId: UUID`
`$fullName: String`
`$level: String`
`$email: String` | `staffs` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createStaffAvailability`| Creates a new availability record. | `staffId`, `day`, `slot`, `status`, ... | `StaffAvailability` | -| `updateStaffAvailability`| Updates an availability record. | `staffId`, `day`, `slot`, `status`, ... | `StaffAvailability` | -| `deleteStaffAvailability`| Deletes an availability record. | `staffId`, `day`, `slot` | `StaffAvailability` | +|------|---------|------------|---------| +| `CreateStaff` | Create staff | `$userId: String!`
`$fullName: String!`
`$level: String`
`$role: String`
`$phone: String`
`$email: String`
`$photoUrl: String`
`$totalShifts: Int`
`$averageRating: Float`
`$onTimeRate: Int`
`$noShowCount: Int`
`$cancellationCount: Int`
`$reliabilityScore: Int`
`$bio: String`
`$skills: [String!]`
`$industries: [String!]`
`$preferredLocations: [String!]`
`$maxDistanceMiles: Int`
`$languages: Any`
`$itemsAttire: Any`
`$xp: Int`
`$badges: Any`
`$isRecommended: Boolean`
`$ownerId: UUID`
`$department: DepartmentType`
`$hubId: UUID`
`$manager: UUID`
`$english: EnglishProficiency`
`$backgroundCheckStatus: BackgroundCheckStatus`
`$employmentType: EmploymentType`
`$initial: String`
`$englishRequired: Boolean`
`$city: String`
`$addres: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String` | `staff_insert` | +| `UpdateStaff` | Update staff | `$id: UUID!`
`$userId: String`
`$fullName: String`
`$level: String`
`$role: String`
`$phone: String`
`$email: String`
`$photoUrl: String`
`$totalShifts: Int`
`$averageRating: Float`
`$onTimeRate: Int`
`$noShowCount: Int`
`$cancellationCount: Int`
`$reliabilityScore: Int`
`$bio: String`
`$skills: [String!]`
`$industries: [String!]`
`$preferredLocations: [String!]`
`$maxDistanceMiles: Int`
`$languages: Any`
`$itemsAttire: Any`
`$xp: Int`
`$badges: Any`
`$isRecommended: Boolean`
`$ownerId: UUID`
`$department: DepartmentType`
`$hubId: UUID`
`$manager: UUID`
`$english: EnglishProficiency`
`$backgroundCheckStatus: BackgroundCheckStatus`
`$employmentType: EmploymentType`
`$initial: String`
`$englishRequired: Boolean`
`$city: String`
`$addres: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String` | `staff_update` | +| `DeleteStaff` | Delete staff | `$id: UUID!` | `staff_delete` | ---- -## StaffAvailabilityStats -*Manages staff availability statistics.* +## staffAvailability ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listStaffAvailabilityStats`| Retrieves all staff availability stats. | `offset`, `limit` | `[StaffAvailabilityStats]` | -| `getStaffAvailabilityStatsByStaffId`| Fetches stats for a specific staff member. | `staffId: UUID!` | `StaffAvailabilityStats` | -| `filterStaffAvailabilityStats`| Searches stats based on various metrics and date ranges. | `needWorkIndexMin`, `utilizationMin`, `lastShiftAfter`, ... | `[StaffAvailabilityStats]` | +|------|---------|------------|---------| +| `listStaffAvailabilities` | List staff availabilities | `$offset: Int`
`$limit: Int` | `staffAvailabilities` | +| `listStaffAvailabilitiesByStaffId` | List staff availabilities by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `staffAvailabilities` | +| `getStaffAvailabilityByKey` | Get staff availability by key | `$staffId: UUID!`
`$day: DayOfWeek!`
`$slot: AvailabilitySlot!` | `staffAvailability` | +| `listStaffAvailabilitiesByDay` | List staff availabilities by day | `$day: DayOfWeek!`
`$offset: Int`
`$limit: Int` | `staffAvailabilities` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createStaffAvailabilityStats`| Creates a new stats record for a staff member. | `staffId`, `needWorkIndex`, `utilizationPercentage`, ... | `StaffAvailabilityStats` | -| `updateStaffAvailabilityStats`| Updates a stats record for a staff member. | `staffId`, `needWorkIndex`, `utilizationPercentage`, ... | `StaffAvailabilityStats` | -| `deleteStaffAvailabilityStats`| Deletes a stats record for a staff member. | `staffId: UUID!` | `StaffAvailabilityStats` | +|------|---------|------------|---------| +| `createStaffAvailability` | Create staff availability | `$staffId: UUID!`
`$day: DayOfWeek!`
`$slot: AvailabilitySlot!`
`$status: AvailabilityStatus`
`$notes: String` | `staffAvailability_insert` | +| `updateStaffAvailability` | Update staff availability | `$staffId: UUID!`
`$day: DayOfWeek!`
`$slot: AvailabilitySlot!`
`$status: AvailabilityStatus`
`$notes: String` | `staffAvailability_update` | +| `deleteStaffAvailability` | Delete staff availability | `$staffId: UUID!`
`$day: DayOfWeek!`
`$slot: AvailabilitySlot!` | `staffAvailability_delete` | ---- -## StaffCourse -*Tracks staff progress in courses.* +## staffAvailabilityStats ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `getStaffCourseById` | Fetches a single staff course record by ID. | `id: UUID!` | `StaffCourse` | -| `listStaffCoursesByStaffId`| Lists all courses a staff member is enrolled in. | `staffId`, `offset`, `limit` | `[StaffCourse]` | -| `listStaffCoursesByCourseId`| Lists all staff enrolled in a specific course. | `courseId`, `offset`, `limit` | `[StaffCourse]` | -| `getStaffCourseByStaffAndCourse`| Fetches the enrollment record for a specific staff/course pair. | `staffId`, `courseId` | `[StaffCourse]` | +|------|---------|------------|---------| +| `listStaffAvailabilityStats` | List staff availability stats | `$offset: Int`
`$limit: Int` | `staffAvailabilityStatss` | +| `getStaffAvailabilityStatsByStaffId` | Get staff availability stats by staff id | `$staffId: UUID!` | `staffAvailabilityStats` | +| `filterStaffAvailabilityStats` | Filter staff availability stats | `$needWorkIndexMin: Int`
`$needWorkIndexMax: Int`
`$utilizationMin: Int`
`$utilizationMax: Int`
`$acceptanceRateMin: Int`
`$acceptanceRateMax: Int`
`$lastShiftAfter: Timestamp`
`$lastShiftBefore: Timestamp`
`$offset: Int`
`$limit: Int` | `staffAvailabilityStatss` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createStaffCourse` | Enrolls a staff member in a course. | `staffId`, `courseId`, `progressPercent`, ... | `StaffCourse` | -| `updateStaffCourse` | Updates progress for a staff member in a course. | `id`, `progressPercent`, `completed`, ... | `StaffCourse` | -| `deleteStaffCourse` | Deletes a staff course enrollment. | `id: UUID!` | `StaffCourse` | +|------|---------|------------|---------| +| `createStaffAvailabilityStats` | Create staff availability stats | `$staffId: UUID!`
`$needWorkIndex: Int`
`$utilizationPercentage: Int`
`$predictedAvailabilityScore: Int`
`$scheduledHoursThisPeriod: Int`
`$desiredHoursThisPeriod: Int`
`$lastShiftDate: Timestamp`
`$acceptanceRate: Int` | `staffAvailabilityStats_insert` | +| `updateStaffAvailabilityStats` | Update staff availability stats | `$staffId: UUID!`
`$needWorkIndex: Int`
`$utilizationPercentage: Int`
`$predictedAvailabilityScore: Int`
`$scheduledHoursThisPeriod: Int`
`$desiredHoursThisPeriod: Int`
`$lastShiftDate: Timestamp`
`$acceptanceRate: Int` | `staffAvailabilityStats_update` | +| `deleteStaffAvailabilityStats` | Delete staff availability stats | `$staffId: UUID!` | `staffAvailabilityStats_delete` | ---- -## StaffDocument -*Manages documents submitted by staff.* +## staffCourse ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `getStaffDocumentByKey`| Fetches a submitted document by staff and document ID. | `staffId`, `documentId` | `StaffDocument` | -| `listStaffDocumentsByStaffId`| Lists all documents submitted by a staff member. | `staffId`, `offset`, `limit` | `[StaffDocument]` | -| `listStaffDocumentsByDocumentType`| Lists submitted documents of a specific type. | `documentType`, `offset`, `limit` | `[StaffDocument]` | -| `listStaffDocumentsByStatus`| Lists submitted documents by status. | `status`, `offset`, `limit` | `[StaffDocument]` | +|------|---------|------------|---------| +| `getStaffCourseById` | Get staff course by id | `$id: UUID!` | `staffCourse` | +| `listStaffCoursesByStaffId` | List staff courses by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `staffCourses` | +| `listStaffCoursesByCourseId` | List staff courses by course id | `$courseId: UUID!`
`$offset: Int`
`$limit: Int` | `staffCourses` | +| `getStaffCourseByStaffAndCourse` | Get staff course by staff and course | `$staffId: UUID!`
`$courseId: UUID!` | `staffCourses` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createStaffDocument` | Creates a record for a document submitted by staff. | `staffId`, `documentId`, `status`, ... | `StaffDocument` | -| `updateStaffDocument` | Updates the status of a submitted document. | `staffId`, `documentId`, `status`, `documentUrl`, ... | `StaffDocument` | -| `deleteStaffDocument` | Deletes a submitted document record. | `staffId`, `documentId` | `StaffDocument` | +|------|---------|------------|---------| +| `createStaffCourse` | Create staff course | `$staffId: UUID!`
`$courseId: UUID!`
`$progressPercent: Int`
`$completed: Boolean`
`$completedAt: Timestamp`
`$startedAt: Timestamp`
`$lastAccessedAt: Timestamp` | `staffCourse_insert` | +| `updateStaffCourse` | Update staff course | `$id: UUID!`
`$progressPercent: Int`
`$completed: Boolean`
`$completedAt: Timestamp`
`$startedAt: Timestamp`
`$lastAccessedAt: Timestamp` | `staffCourse_update` | +| `deleteStaffCourse` | Delete staff course | `$id: UUID!` | `staffCourse_delete` | ---- -## StaffRole -*Junction table linking Staff to Roles.* +## staffDocument ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listStaffRoles` | Retrieves all staff role assignments. | `offset`, `limit` | `[StaffRole]` | -| `getStaffRoleByKey`| Fetches a single assignment by staff and role ID. | `staffId`, `roleId` | `StaffRole` | -| `listStaffRolesByStaffId`| Lists all roles for a specific staff member. | `staffId`, `offset`, `limit` | `[StaffRole]` | -| `listStaffRolesByRoleId`| Lists all staff who have a specific role. | `roleId`, `offset`, `limit` | `[StaffRole]` | -| `filterStaffRoles` | Searches for assignments by staff and/or role. | `staffId`, `roleId`, `offset`, `limit` | `[StaffRole]` | +|------|---------|------------|---------| +| `getStaffDocumentByKey` | Get staff document by key | `$staffId: UUID!`
`$documentId: UUID!` | `staffDocument` | +| `listStaffDocumentsByStaffId` | List staff documents by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `staffDocuments` | +| `listStaffDocumentsByDocumentType` | List staff documents by document type | `$documentType: DocumentType!`
`$offset: Int`
`$limit: Int` | `staffDocuments` | +| `listStaffDocumentsByStatus` | List staff documents by status | `$status: DocumentStatus!`
`$offset: Int`
`$limit: Int` | `staffDocuments` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createStaffRole` | Assigns a role to a staff member. | `staffId`, `roleId`, `roleType` | `StaffRole` | -| `deleteStaffRole` | Removes a role from a staff member. | `staffId`, `roleId` | `StaffRole` | +|------|---------|------------|---------| +| `createStaffDocument` | Create staff document | `$staffId: UUID!`
`$staffName: String!`
`$documentId: UUID!`
`$status: DocumentStatus!`
`$documentUrl: String`
`$expiryDate: Timestamp` | `staffDocument_insert` | +| `updateStaffDocument` | Update staff document | `$staffId: UUID!`
`$documentId: UUID!`
`$status: DocumentStatus`
`$documentUrl: String`
`$expiryDate: Timestamp` | `staffDocument_update` | +| `deleteStaffDocument` | Delete staff document | `$staffId: UUID!`
`$documentId: UUID!` | `staffDocument_delete` | ---- -## Task -*Manages tasks.* +## staffRole ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listTasks` | Retrieves all tasks. | - | `[Task]` | -| `getTaskById` | Fetches a single task by ID. | `id: UUID!` | `Task` | -| `getTasksByOwnerId`| Lists all tasks for a specific owner. | `ownerId: UUID!` | `[Task]` | -| `filterTasks` | Searches tasks by status or priority. | `status`, `priority` | `[Task]` | +|------|---------|------------|---------| +| `listStaffRoles` | List staff roles | `$offset: Int`
`$limit: Int` | `staffRoles` | +| `getStaffRoleByKey` | Get staff role by key | `$staffId: UUID!`
`$roleId: UUID!` | `staffRole` | +| `listStaffRolesByStaffId` | List staff roles by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `staffRoles` | +| `listStaffRolesByRoleId` | List staff roles by role id | `$roleId: UUID!`
`$offset: Int`
`$limit: Int` | `staffRoles` | +| `filterStaffRoles` | Filter staff roles | `$staffId: UUID`
`$roleId: UUID`
`$offset: Int`
`$limit: Int` | `staffRoles` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createTask` | Adds a new task. | `taskName`, `priority`, `status`, `ownerId`, ... | `Task` | -| `updateTask` | Modifies an existing task. | `id`, `taskName`, `status`, ... | `Task` | -| `deleteTask` | Deletes a task. | `id: UUID!` | `Task` | +|------|---------|------------|---------| +| `createStaffRole` | Create staff role | `$staffId: UUID!`
`$roleId: UUID!`
`$roleType: RoleType` | `staffRole_insert` | +| `deleteStaffRole` | Delete staff role | `$staffId: UUID!`
`$roleId: UUID!` | `staffRole_delete` | ---- -## TaskComment -*Manages comments on tasks.* +## task ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listTaskComments` | Retrieves all task comments. | - | `[TaskComment]` | -| `getTaskCommentById`| Fetches a single comment by ID. | `id: UUID!` | `TaskComment` | -| `getTaskCommentsByTaskId`| Lists all comments for a specific task. | `taskId: UUID!` | `[TaskComment]` | +|------|---------|------------|---------| +| `listTasks` | List tasks | — | `tasks` | +| `getTaskById` | Get task by id | `$id: UUID!` | `task` | +| `getTasksByOwnerId` | Get tasks by owner id | `$ownerId: UUID!` | `tasks` | +| `filterTasks` | Filter tasks | `$status: TaskStatus`
`$priority: TaskPriority` | `tasks` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createTaskComment` | Adds a new comment to a task. | `taskId`, `teamMemberId`, `comment`, ... | `TaskComment` | -| `updateTaskComment` | Modifies an existing comment. | `id`, `comment`, ... | `TaskComment` | -| `deleteTaskComment` | Deletes a comment. | `id: UUID!` | `TaskComment` | +|------|---------|------------|---------| +| `createTask` | Create task | `$taskName: String!`
`$description: String`
`$priority: TaskPriority!`
`$status: TaskStatus!`
`$dueDate: Timestamp`
`$progress: Int`
`$orderIndex: Int`
`$commentCount: Int`
`$attachmentCount: Int`
`$files: Any`
`$ownerId:UUID!` | `task_insert` | +| `updateTask` | Update task | `$id: UUID!`
`$taskName: String`
`$description: String`
`$priority: TaskPriority`
`$status: TaskStatus`
`$dueDate: Timestamp`
`$progress: Int`
`$assignedMembers: Any`
`$orderIndex: Int`
`$commentCount: Int`
`$attachmentCount: Int`
`$files: Any` | `task_update` | +| `deleteTask` | Delete task | `$id: UUID!` | `task_delete` | ---- -## TaxForm -*Manages staff tax forms.* +## task_comment ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listTaxForms` | Retrieves all tax forms. | - | `[TaxForm]` | -| `getTaxFormById` | Fetches a single tax form by ID. | `id: UUID!` | `TaxForm` | -| `getTaxFormsBystaffId`| Lists all tax forms for a specific staff member. | `staffId: UUID!` | `[TaxForm]` | -| `filterTaxForms` | Searches tax forms by type, status, or staff. | `formType`, `status`, `staffId` | `[TaxForm]` | +|------|---------|------------|---------| +| `listTaskComments` | List task comments | — | `taskComments` | +| `getTaskCommentById` | Get task comment by id | `$id: UUID!` | `taskComment` | +| `getTaskCommentsByTaskId` | Get task comments by task id | `$taskId: UUID!` | `taskComments` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createTaxForm` | Adds a new tax form record. | `formType`, `title`, `staffId`, `formData`, ... | `TaxForm` | -| `updateTaxForm` | Modifies an existing tax form record. | `id`, `status`, `formData`, ... | `TaxForm` | -| `deleteTaxForm` | Deletes a tax form record. | `id: UUID!` | `TaxForm` | +|------|---------|------------|---------| +| `createTaskComment` | Create task comment | `$taskId: UUID!`
`$teamMemberId: UUID!`
`$comment: String!`
`$isSystem: Boolean` | `taskComment_insert` | +| `updateTaskComment` | Update task comment | `$id: UUID!`
`$comment: String`
`$isSystem: Boolean` | `taskComment_update` | +| `deleteTaskComment` | Delete task comment | `$id: UUID!` | `taskComment_delete` | ---- -## Team -*Manages teams.* +## taxForm ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listTeams` | Retrieves all teams. | - | `[Team]` | -| `getTeamById` | Fetches a single team by ID. | `id: UUID!` | `Team` | -| `getTeamsByOwnerId`| Lists all teams for a specific owner. | `ownerId: String!` | `[Team]` | +|------|---------|------------|---------| +| `listTaxForms` | List tax forms | `$offset: Int`
`$limit: Int` | `taxForms` | +| `getTaxFormById` | Get tax form by id | `$id: UUID!` | `taxForm` | +| `getTaxFormsByStaffId` | Get tax forms by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `taxForms` | +| `listTaxFormsWhere` | List tax forms where | `$formType: TaxFormType`
`$status: TaxFormStatus`
`$staffId: UUID`
`$offset: Int`
`$limit: Int` | `taxForms` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createTeam` | Adds a new team. | `teamName`, `ownerId`, `ownerName`, ... | `Team` | -| `updateTeam` | Modifies an existing team. | `id`, `teamName`, ... | `Team` | -| `deleteTeam` | Deletes a team. | `id: UUID!` | `Team` | +|------|---------|------------|---------| +| `createTaxForm` | Create tax form | `$formType: TaxFormType!`
`$firstName: String!`
`$lastName: String!`
`$mInitial: String`
`$oLastName: String`
`$dob: Timestamp`
`$socialSN: Int!`
`$email: String`
`$phone: String`
`$address: String!`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$city: String`
`$apt: String`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String`
`$marital: MaritalStatus`
`$multipleJob: Boolean`
`$childrens: Int`
`$otherDeps: Int`
`$totalCredits: Float`
`$otherInconme: Float`
`$deductions: Float`
`$extraWithholding: Float`
`$citizen: CitizenshipStatus`
`$uscis: String`
`$passportNumber: String`
`$countryIssue: String`
`$prepartorOrTranslator: Boolean`
`$signature: String`
`$date: Timestamp`
`$status: TaxFormStatus!`
`$staffId: UUID!`
`$createdBy: String` | `taxForm_insert` | +| `updateTaxForm` | Update tax form | `$id: UUID!`
`$formType: TaxFormType`
`$firstName: String`
`$lastName: String`
`$mInitial: String`
`$oLastName: String`
`$dob: Timestamp`
`$socialSN: Int`
`$email: String`
`$phone: String`
`$address: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$city: String`
`$apt: String`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String`
`$marital: MaritalStatus`
`$multipleJob: Boolean`
`$childrens: Int`
`$otherDeps: Int`
`$totalCredits: Float`
`$otherInconme: Float`
`$deductions: Float`
`$extraWithholding: Float`
`$citizen: CitizenshipStatus`
`$uscis: String`
`$passportNumber: String`
`$countryIssue: String`
`$prepartorOrTranslator: Boolean`
`$signature: String`
`$date: Timestamp`
`$status: TaxFormStatus` | `taxForm_update` | +| `deleteTaxForm` | Delete tax form | `$id: UUID!` | `taxForm_delete` | ---- -## TeamHub -*Manages hubs within a team.* +## team ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listTeamHubs` | Retrieves all team hubs. | - | `[TeamHub]` | -| `getTeamHubById` | Fetches a single team hub by ID. | `id: UUID!` | `TeamHub` | -| `getTeamHubsByTeamId`| Lists all hubs for a specific team. | `teamId: UUID!` | `[TeamHub]` | +|------|---------|------------|---------| +| `listTeams` | List teams | — | `teams` | +| `getTeamById` | Get team by id | `$id: UUID!` | `team` | +| `getTeamsByOwnerId` | Get teams by owner id | `$ownerId: UUID!` | `teams` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createTeamHub` | Adds a new hub to a team. | `teamId`, `hubName`, `address`, ... | `TeamHub` | -| `updateTeamHub` | Modifies an existing team hub. | `id`, `hubName`, `address`, ... | `TeamHub` | -| `deleteTeamHub` | Deletes a team hub. | `id: UUID!` | `TeamHub` | +|------|---------|------------|---------| +| `createTeam` | Create team | `$teamName: String!`
`$ownerId: UUID!`
`$ownerName: String!`
`$ownerRole: String!`
`$email: String`
`$companyLogo: String`
`$totalMembers: Int`
`$activeMembers: Int`
`$totalHubs: Int`
`$departments: Any`
`$favoriteStaffCount: Int`
`$blockedStaffCount: Int`
`$favoriteStaff: Any`
`$blockedStaff: Any` | `team_insert` | +| `updateTeam` | Update team | `$id: UUID!`
`$teamName: String`
`$ownerName: String`
`$ownerRole: String`
`$companyLogo: String`
`$totalMembers: Int`
`$activeMembers: Int`
`$totalHubs: Int`
`$departments: Any`
`$favoriteStaffCount: Int`
`$blockedStaffCount: Int`
`$favoriteStaff: Any`
`$blockedStaff: Any` | `team_update` | +| `deleteTeam` | Delete team | `$id: UUID!` | `team_delete` | ---- -## TeamHudDeparment -*Manages departments within a Team Hub.* +## teamHub ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listTeamHudDepartments`| Retrieves all team hub departments. | `offset`, `limit` | `[TeamHudDepartment]` | -| `getTeamHudDepartmentById`| Fetches a single department by ID. | `id: UUID!` | `TeamHudDepartment` | -| `listTeamHudDepartmentsByTeamHubId`| Lists all departments for a specific team hub. | `teamHubId`, `offset`, `limit` | `[TeamHudDepartment]` | +|------|---------|------------|---------| +| `listTeamHubs` | List team hubs | `$offset: Int`
`$limit: Int` | `teamHubs` | +| `getTeamHubById` | Get team hub by id | `$id: UUID!` | `teamHub` | +| `getTeamHubsByTeamId` | Get team hubs by team id | `$teamId: UUID!`
`$offset: Int`
`$limit: Int` | `teamHubs` | +| `listTeamHubsByOwnerId` | List team hubs by owner id | `$ownerId: UUID!`
`$offset: Int`
`$limit: Int` | `teamHubs` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createTeamHudDepartment`| Adds a new department to a team hub. | `name`, `teamHubId`, `costCenter` | `TeamHudDepartment` | -| `updateTeamHudDepartment`| Modifies an existing department. | `id`, `name`, `costCenter`, ... | `TeamHudDepartment` | -| `deleteTeamHudDepartment`| Deletes a department. | `id: UUID!` | `TeamHudDepartment` | +|------|---------|------------|---------| +| `createTeamHub` | Create team hub | `$teamId: UUID!`
`$hubName: String!`
`$address: String!`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$city: String`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String`
`$managerName: String`
`$isActive: Boolean`
`$departments: Any` | `teamHub_insert` | +| `updateTeamHub` | Update team hub | `$id: UUID!`
`$teamId: UUID`
`$hubName: String`
`$address: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$city: String`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String`
`$managerName: String`
`$isActive: Boolean`
`$departments: Any` | `teamHub_update` | +| `deleteTeamHub` | Delete team hub | `$id: UUID!` | `teamHub_delete` | ---- -## TeamMember -*Manages members of a team.* +## teamHudDeparment ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listTeamMembers` | Retrieves all team members. | - | `[TeamMember]` | -| `getTeamMemberById` | Fetches a single team member by ID. | `id: UUID!` | `TeamMember` | -| `getTeamMembersByTeamId`| Lists all members for a specific team. | `teamId: UUID!` | `[TeamMember]` | +|------|---------|------------|---------| +| `listTeamHudDepartments` | List team hud departments | `$offset: Int`
`$limit: Int` | `teamHudDepartments` | +| `getTeamHudDepartmentById` | Get team hud department by id | `$id: UUID!` | `teamHudDepartment` | +| `listTeamHudDepartmentsByTeamHubId` | List team hud departments by team hub id | `$teamHubId: UUID!`
`$offset: Int`
`$limit: Int` | `teamHudDepartments` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createTeamMember` | Adds a new member to a team. | `teamId`, `userId`, `role`, ... | `TeamMember` | -| `updateTeamMember` | Modifies an existing team member. | `id`, `role`, `title`, ... | `TeamMember` | -| `updateTeamMemberInviteStatus`| Updates the invitation status for a member. | `id`, `inviteStatus` | `TeamMember` | -| `acceptInviteByCode`| Accepts an invitation to join a team. | `inviteCode: UUID!` | `TeamMember` | -| `cancelInviteByCode`| Cancels a pending invitation. | `inviteCode: UUID!` | `TeamMember` | -| `deleteTeamMember` | Removes a member from a team. | `id: UUID!` | `TeamMember` | +|------|---------|------------|---------| +| `createTeamHudDepartment` | Create team hud department | `$name: String!`
`$costCenter: String`
`$teamHubId: UUID!` | `teamHudDepartment_insert` | +| `updateTeamHudDepartment` | Update team hud department | `$id: UUID!`
`$name: String`
`$costCenter: String`
`$teamHubId: UUID` | `teamHudDepartment_update` | +| `deleteTeamHudDepartment` | Delete team hud department | `$id: UUID!` | `teamHudDepartment_delete` | ---- -## User -*Manages system users.* +## teamMember ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listUsers` | Retrieves all users. | - | `[User]` | -| `getUserById` | Fetches a single user by their ID (Firebase UID). | `id: String!` | `User` | -| `filterUsers` | Searches for users by ID, email, or role. | `id`, `email`, `role`, `userRole` | `[User]` | +|------|---------|------------|---------| +| `listTeamMembers` | List team members | — | `teamMembers` | +| `getTeamMemberById` | Get team member by id | `$id: UUID!` | `teamMember` | +| `getTeamMembersByTeamId` | Get team members by team id | `$teamId: UUID!` | `teamMembers` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `CreateUser` | Creates a new user. | `id`, `email`, `fullName`, `role` | `User` | -| `UpdateUser` | Modifies an existing user. | `id`, `email`, `fullName`, `role` | `User` | -| `DeleteUser` | Deletes a user. | `id: String!` | `User` | +|------|---------|------------|---------| +| `createTeamMember` | Create team member | `$teamId: UUID!`
`$role: TeamMemberRole!`
`$title: String`
`$department: String`
`$teamHubId: UUID`
`$isActive: Boolean`
`$userId: String!`
`$inviteStatus: TeamMemberInviteStatus` | `teamMember_insert` | +| `updateTeamMember` | Update team member | `$id: UUID!`
`$role: TeamMemberRole`
`$title: String`
`$department: String`
`$teamHubId: UUID`
`$isActive: Boolean`
`$inviteStatus: TeamMemberInviteStatus` | `teamMember_update` | +| `updateTeamMemberInviteStatus` | Update team member invite status | `$id: UUID!`
`$inviteStatus: TeamMemberInviteStatus!` | `teamMember_update` | +| `acceptInviteByCode` | Accept invite by code | `$inviteCode: UUID!` | `teamMember_updateMany` | +| `cancelInviteByCode` | Cancel invite by code | `$inviteCode: UUID!` | `teamMember_updateMany` | +| `deleteTeamMember` | Delete team member | `$id: UUID!` | `teamMember_delete` | ---- -## UserConversation -*Manages user-specific state for conversations.* +## user ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listUserConversations`| Retrieves all user conversation records. | `offset`, `limit` | `[UserConversation]` | -| `getUserConversationByKey`| Fetches a record by conversation and user ID. | `conversationId`, `userId` | `UserConversation` | -| `listUserConversationsByUserId`| Lists all conversations a user is part of. | `userId`, `offset`, `limit` | `[UserConversation]` | -| `listUnreadUserConversationsByUserId`| Lists conversations with unread messages for a user. | `userId`, `offset`, `limit` | `[UserConversation]` | -| `listUserConversationsByConversationId`| Lists all participants of a conversation. | `conversationId`, `offset`, `limit` | `[UserConversation]` | -| `filterUserConversations`| Searches records by user, conversation, and read status. | `userId`, `conversationId`, `unreadMin`, ... | `[UserConversation]` | +|------|---------|------------|---------| +| `listUsers` | List users | — | `users` | +| `getUserById` | Get user by id | `$id: String!` | `user` | +| `filterUsers` | Filter users | `$id: String`
`$email: String`
`$role: UserBaseRole`
`$userRole: String` | `users` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createUserConversation`| Adds a user to a conversation. | `conversationId`, `userId`, `unreadCount`, ... | `UserConversation` | -| `updateUserConversation`| Updates a user's state in a conversation. | `conversationId`, `userId`, `unreadCount`, ... | `UserConversation` | -| `markConversationAsRead`| Marks a conversation as read for a user. | `conversationId`, `userId`, `lastReadAt` | `UserConversation` | -| `incrementUnreadForUser`| Increments the unread count for a user. | `conversationId`, `userId`, `unreadCount` | `UserConversation` | -| `deleteUserConversation`| Removes a user from a conversation. | `conversationId`, `userId` | `UserConversation` | +|------|---------|------------|---------| +| `CreateUser` | Create user | `$id: String!`
`$email: String`
`$fullName: String`
`$role: UserBaseRole!`
`$userRole: String`
`$photoUrl: String` | `user_insert` | +| `UpdateUser` | Update user | `$id: String!`
`$email: String`
`$fullName: String`
`$role: UserBaseRole`
`$userRole: String`
`$photoUrl: String` | `user_update` | +| `DeleteUser` | Delete user | `$id: String!` | `user_delete` | ---- -## Vendor -*Manages vendor/partner entities.* +## userConversation ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listVendors` | Retrieves all vendors. | - | `[Vendor]` | -| `getVendorById` | Fetches a single vendor by ID. | `id: UUID!` | `Vendor` | -| `getVendorByUserId` | Fetches vendor profiles for a user. | `userId: String!` | `[Vendor]` | +|------|---------|------------|---------| +| `listUserConversations` | List user conversations | `$offset: Int`
`$limit: Int` | `userConversations` | +| `getUserConversationByKey` | Get user conversation by key | `$conversationId: UUID!`
`$userId: String!` | `userConversation` | +| `listUserConversationsByUserId` | List user conversations by user id | `$userId: String!`
`$offset: Int`
`$limit: Int` | `userConversations` | +| `listUnreadUserConversationsByUserId` | List unread user conversations by user id | `$userId: String!`
`$offset: Int`
`$limit: Int` | `userConversations` | +| `listUserConversationsByConversationId` | List user conversations by conversation id | `$conversationId: UUID!`
`$offset: Int`
`$limit: Int` | `userConversations` | +| `filterUserConversations` | Filter user conversations | `$userId: String`
`$conversationId: UUID`
`$unreadMin: Int`
`$unreadMax: Int`
`$lastReadAfter: Timestamp`
`$lastReadBefore: Timestamp`
`$offset: Int`
`$limit: Int` | `userConversations` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createVendor` | Creates a new vendor profile. | `userId`, `companyName`, ... | `Vendor` | -| `updateVendor` | Modifies an existing vendor profile. | `id`, `companyName`, `email`, ... | `Vendor` | -| `deleteVendor` | Deletes a vendor profile. | `id: UUID!` | `Vendor` | +|------|---------|------------|---------| +| `createUserConversation` | Create user conversation | `$conversationId: UUID!`
`$userId: String!`
`$unreadCount: Int`
`$lastReadAt: Timestamp` | `userConversation_insert` | +| `updateUserConversation` | Update user conversation | `$conversationId: UUID!`
`$userId: String!`
`$unreadCount: Int`
`$lastReadAt: Timestamp` | `userConversation_update` | +| `markConversationAsRead` | Mark conversation as read | `$conversationId: UUID!`
`$userId: String!`
`$lastReadAt: Timestamp` | `userConversation_update` | +| `incrementUnreadForUser` | Increment unread for user | `$conversationId: UUID!`
`$userId: String!`
`$unreadCount: Int!` | `userConversation_update` | +| `deleteUserConversation` | Delete user conversation | `$conversationId: UUID!`
`$userId: String!` | `userConversation_delete` | ---- -## VendorBenefitPlan -*Manages benefit plans offered by vendors.* +## vendor ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listVendorBenefitPlans`| Retrieves all vendor benefit plans. | `offset`, `limit` | `[VendorBenefitPlan]` | -| `getVendorBenefitPlanById`| Fetches a single benefit plan by ID. | `id: UUID!` | `VendorBenefitPlan` | -| `listVendorBenefitPlansByVendorId`| Lists all benefit plans for a vendor. | `vendorId`, `offset`, `limit` | `[VendorBenefitPlan]` | -| `listActiveVendorBenefitPlansByVendorId`| Lists active benefit plans for a vendor. | `vendorId`, `offset`, `limit` | `[VendorBenefitPlan]` | -| `filterVendorBenefitPlans`| Searches benefit plans by vendor, title, and active status. | `vendorId`, `title`, `isActive`, ... | `[VendorBenefitPlan]` | +|------|---------|------------|---------| +| `getVendorById` | Get vendor by id | `$id: UUID!` | `vendor` | +| `getVendorByUserId` | Get vendor by user id | `$userId: String!` | `vendors` | +| `listVendors` | List vendors | — | `vendors` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createVendorBenefitPlan`| Adds a new benefit plan. | `vendorId`, `title`, `isActive`, ... | `VendorBenefitPlan` | -| `updateVendorBenefitPlan`| Modifies an existing benefit plan. | `id`, `title`, `isActive`, ... | `VendorBenefitPlan` | -| `deleteVendorBenefitPlan`| Deletes a benefit plan. | `id: UUID!` | `VendorBenefitPlan` | +|------|---------|------------|---------| +| `createVendor` | Create vendor | `$userId: String!`
`$companyName: String!`
`$email: String`
`$phone: String`
`$photoUrl: String`
`$address: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$street: String`
`$country: String`
`$zipCode: String`
`$billingAddress: String`
`$timezone: String`
`$legalName: String`
`$doingBusinessAs: String`
`$region: String`
`$state: String`
`$city: String`
`$serviceSpecialty: String`
`$approvalStatus: ApprovalStatus`
`$isActive: Boolean`
`$markup: Float`
`$fee: Float`
`$csat: Float`
`$tier: VendorTier` | `vendor_insert` | +| `updateVendor` | Update vendor | `$id: UUID!`
`$companyName: String`
`$email: String`
`$phone: String`
`$photoUrl: String`
`$address: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$street: String`
`$country: String`
`$zipCode: String`
`$billingAddress: String`
`$timezone: String`
`$legalName: String`
`$doingBusinessAs: String`
`$region: String`
`$state: String`
`$city: String`
`$serviceSpecialty: String`
`$approvalStatus: ApprovalStatus`
`$isActive: Boolean`
`$markup: Float`
`$fee: Float`
`$csat: Float`
`$tier: VendorTier` | `vendor_update` | +| `deleteVendor` | Delete vendor | `$id: UUID!` | `vendor_delete` | ---- -## VendorRate -*Manages vendor rates.* +## vendorBenefitPlan ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `listVendorRates` | Retrieves all vendor rates. | - | `[VendorRate]` | -| `getVendorRateById` | Fetches a single vendor rate by ID. | `id: UUID!` | `VendorRate` | +|------|---------|------------|---------| +| `listVendorBenefitPlans` | List vendor benefit plans | `$offset: Int`
`$limit: Int` | `vendorBenefitPlans` | +| `getVendorBenefitPlanById` | Get vendor benefit plan by id | `$id: UUID!` | `vendorBenefitPlan` | +| `listVendorBenefitPlansByVendorId` | List vendor benefit plans by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `vendorBenefitPlans` | +| `listActiveVendorBenefitPlansByVendorId` | List active vendor benefit plans by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `vendorBenefitPlans` | +| `filterVendorBenefitPlans` | Filter vendor benefit plans | `$vendorId: UUID`
`$title: String`
`$isActive: Boolean`
`$offset: Int`
`$limit: Int` | `vendorBenefitPlans` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createVendorRate` | Adds a new vendor rate. | `vendorId`, `roleName`, `clientRate`, `employeeWage`, ... | `VendorRate` | -| `updateVendorRate` | Modifies an existing vendor rate. | `id`, `clientRate`, `employeeWage`, ... | `VendorRate` | -| `deleteVendorRate` | Deletes a vendor rate. | `id: UUID!` | `VendorRate` | +|------|---------|------------|---------| +| `createVendorBenefitPlan` | Create vendor benefit plan | `$vendorId: UUID!`
`$title: String!`
`$description: String`
`$requestLabel: String`
`$total: Int`
`$isActive: Boolean`
`$createdBy: String` | `vendorBenefitPlan_insert` | +| `updateVendorBenefitPlan` | Update vendor benefit plan | `$id: UUID!`
`$vendorId: UUID`
`$title: String`
`$description: String`
`$requestLabel: String`
`$total: Int`
`$isActive: Boolean`
`$createdBy: String` | `vendorBenefitPlan_update` | +| `deleteVendorBenefitPlan` | Delete vendor benefit plan | `$id: UUID!` | `vendorBenefitPlan_delete` | ---- -## WorkForce -*Manages the workforce, linking staff to vendors.* +## vendorRate ### Queries | Name | Purpose | Parameters | Returns | -|---|---|---|---| -| `getWorkforceById` | Fetches a single workforce member by ID. | `id: UUID!` | `Workforce` | -| `getWorkforceByVendorAndStaff`| Fetches the workforce record for a specific vendor/staff pair. | `vendorId`, `staffId` | `[Workforce]` | -| `listWorkforceByVendorId`| Lists all workforce members for a vendor. | `vendorId`, `offset`, `limit` | `[Workforce]` | -| `listWorkforceByStaffId`| Lists all vendor associations for a staff member. | `staffId`, `offset`, `limit` | `[Workforce]` | -| `getWorkforceByVendorAndNumber`| Checks for workforce number uniqueness within a vendor. | `vendorId`, `workforceNumber` | `[Workforce]` | +|------|---------|------------|---------| +| `listVendorRates` | List vendor rates | — | `vendorRates` | +| `getVendorRateById` | Get vendor rate by id | `$id: UUID!` | `vendorRate` | ### Mutations | Name | Purpose | Parameters | Affects | -|---|---|---|---| -| `createWorkforce` | Adds a new staff member to a vendor's workforce. | `vendorId`, `staffId`, `workforceNumber`, ... | `Workforce` | -| `updateWorkforce` | Modifies a workforce member's details. | `id`, `workforceNumber`, `status`, ... | `Workforce` | -| `deactivateWorkforce`| Sets a workforce member's status to INACTIVE. | `id: UUID!` | `Workforce` | +|------|---------|------------|---------| +| `createVendorRate` | Create vendor rate | `$vendorId: UUID!`
`$roleName: String`
`$category: CategoryType`
`$clientRate: Float`
`$employeeWage: Float`
`$markupPercentage: Float`
`$vendorFeePercentage: Float`
`$isActive: Boolean`
`$notes: String` | `vendorRate_insert` | +| `updateVendorRate` | Update vendor rate | `$id: UUID!`
`$vendorId: UUID`
`$roleName: String`
`$category: CategoryType`
`$clientRate: Float`
`$employeeWage: Float`
`$markupPercentage: Float`
`$vendorFeePercentage: Float`
`$isActive: Boolean`
`$notes: String` | `vendorRate_update` | +| `deleteVendorRate` | Delete vendor rate | `$id: UUID!` | `vendorRate_delete` | + +## workForce + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `getWorkforceById` | Get workforce by id | `$id: UUID!` | `workforce` | +| `getWorkforceByVendorAndStaff` | Get workforce by vendor and staff | `$vendorId: UUID!`
`$staffId: UUID!` | `workforces` | +| `listWorkforceByVendorId` | List workforce by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `workforces` | +| `listWorkforceByStaffId` | List workforce by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `workforces` | +| `getWorkforceByVendorAndNumber` | Get workforce by vendor and number | `$vendorId: UUID!`
`$workforceNumber: String!` | `workforces` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createWorkforce` | Create workforce | `$vendorId: UUID!`
`$staffId: UUID!`
`$workforceNumber: String!`
`$employmentType: WorkforceEmploymentType` | `workforce_insert` | +| `updateWorkforce` | Update workforce | `$id: UUID!`
`$workforceNumber: String`
`$employmentType: WorkforceEmploymentType`
`$status: WorkforceStatus` | `workforce_update` | +| `deactivateWorkforce` | Deactivate workforce | `$id: UUID!` | `workforce_update` | From c3abb819c915de40cbed35fa0aafce69d61e92c0 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 14:57:47 -0500 Subject: [PATCH 18/53] feat(data-connect): Implement DataConnectService for centralized data operations and refactor ShiftsRepositoryImpl to utilize the new service --- .../data_connect/lib/krow_data_connect.dart | 1 + .../src/services/data_connect_service.dart | 104 ++++++++++ .../mobile/packages/data_connect/pubspec.yaml | 2 + .../shifts_repository_impl.dart | 186 ++++++------------ 4 files changed, 164 insertions(+), 129 deletions(-) create mode 100644 apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart diff --git a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart index 277ad737..d512a29c 100644 --- a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart +++ b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart @@ -13,6 +13,7 @@ export 'src/session/client_session_store.dart'; // Export the generated Data Connect SDK export 'src/dataconnect_generated/generated.dart'; +export 'src/services/data_connect_service.dart'; export 'src/session/staff_session_store.dart'; export 'src/mixins/data_error_handler.dart'; diff --git a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart new file mode 100644 index 00000000..cdfe2813 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; +import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; +import 'package:krow_core/core.dart'; + +import '../../krow_data_connect.dart' as dc; +import '../mixins/data_error_handler.dart'; + +/// A centralized service for interacting with Firebase Data Connect. +/// +/// This service provides common utilities and context management for all repositories. +class DataConnectService with DataErrorHandler { + DataConnectService._(); + + /// The singleton instance of the [DataConnectService]. + static final DataConnectService instance = DataConnectService._(); + + /// The Data Connect connector used for data operations. + final dc.ExampleConnector connector = dc.ExampleConnector.instance; + + /// The Firebase Auth instance. + final firebase_auth.FirebaseAuth _auth = firebase_auth.FirebaseAuth.instance; + + /// Cache for the current staff ID to avoid redundant lookups. + String? _cachedStaffId; + + /// Gets the current staff ID from session store or persistent storage. + Future getStaffId() async { + // 1. Check Session Store + final dc.StaffSession? session = dc.StaffSessionStore.instance.session; + if (session?.staff?.id != null) { + return session!.staff!.id; + } + + // 2. Check Cache + if (_cachedStaffId != null) return _cachedStaffId!; + + // 3. Fetch from Data Connect using Firebase UID + final firebase_auth.User? user = _auth.currentUser; + if (user == null) { + throw Exception('User is not authenticated'); + } + + try { + final fdc.QueryResult< + dc.GetStaffByUserIdData, + dc.GetStaffByUserIdVariables + > + response = await executeProtected( + () => connector.getStaffByUserId(userId: user.uid).execute(), + ); + + if (response.data.staffs.isNotEmpty) { + _cachedStaffId = response.data.staffs.first.id; + return _cachedStaffId!; + } + } catch (e) { + throw Exception('Failed to fetch staff ID from Data Connect: $e'); + } + + // 4. Fallback (should ideally not happen if DB is seeded) + return user.uid; + } + + /// Converts a Data Connect timestamp/string/json to a [DateTime]. + DateTime? toDateTime(dynamic t) { + if (t == null) return null; + DateTime? dt; + if (t is fdc.Timestamp) { + dt = t.toDateTime(); + } else if (t is String) { + dt = DateTime.tryParse(t); + } else { + try { + dt = DateTime.tryParse(t.toJson() as String); + } catch (_) { + try { + dt = DateTime.tryParse(t.toString()); + } catch (e) { + dt = null; + } + } + } + + if (dt != null) { + return DateTimeUtils.toDeviceTime(dt); + } + return null; + } + + /// Converts a [DateTime] to a Firebase Data Connect [Timestamp]. + fdc.Timestamp toTimestamp(DateTime dateTime) { + final DateTime utc = dateTime.toUtc(); + final int seconds = utc.millisecondsSinceEpoch ~/ 1000; + final int nanoseconds = (utc.microsecondsSinceEpoch % 1000000) * 1000; + return fdc.Timestamp(nanoseconds, seconds); + } + + /// Clears the internal cache (e.g., on logout). + void clearCache() { + _cachedStaffId = null; + } +} diff --git a/apps/mobile/packages/data_connect/pubspec.yaml b/apps/mobile/packages/data_connect/pubspec.yaml index d2b83d6a..48d0039b 100644 --- a/apps/mobile/packages/data_connect/pubspec.yaml +++ b/apps/mobile/packages/data_connect/pubspec.yaml @@ -16,3 +16,5 @@ dependencies: flutter_modular: ^6.3.0 firebase_data_connect: ^0.2.2+2 firebase_core: ^4.4.0 + firebase_auth: ^6.1.4 + krow_core: ^0.0.1 diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart index d500819a..64a112ca 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart @@ -1,85 +1,20 @@ import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart'; import 'package:intl/intl.dart'; -import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; -import 'package:krow_core/core.dart'; import '../../domain/repositories/shifts_repository_interface.dart'; class ShiftsRepositoryImpl - with dc.DataErrorHandler implements ShiftsRepositoryInterface { - final dc.ExampleConnector _dataConnect; - final firebase_auth.FirebaseAuth _auth = firebase_auth.FirebaseAuth.instance; + final dc.DataConnectService _service; - ShiftsRepositoryImpl() : _dataConnect = dc.ExampleConnector.instance; + ShiftsRepositoryImpl() : _service = dc.DataConnectService.instance; // Cache: ShiftID -> ApplicationID (For Accept/Decline) final Map _shiftToAppIdMap = {}; // Cache: ApplicationID -> RoleID (For Accept/Decline w/ Update mutation) final Map _appToRoleIdMap = {}; - String? _cachedStaffId; - - Future _getStaffId() async { - // 1. Check Session Store - final dc.StaffSession? session = dc.StaffSessionStore.instance.session; - if (session?.staff?.id != null) { - return session!.staff!.id; - } - - // 2. Check Cache - if (_cachedStaffId != null) return _cachedStaffId!; - - // 3. Fetch from Data Connect using Firebase UID - final firebase_auth.User? user = _auth.currentUser; - if (user == null) { - throw Exception('User is not authenticated'); - } - - try { - final fdc.QueryResult response = await executeProtected(() => _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 - } - - // 4. Fallback (should ideally not happen if DB is seeded) - return user.uid; - } - - DateTime? _toDateTime(dynamic t) { - if (t == null) return null; - DateTime? dt; - if (t is fdc.Timestamp) { - dt = t.toDateTime(); - } else if (t is String) { - dt = DateTime.tryParse(t); - } else { - try { - dt = DateTime.tryParse(t.toJson() as String); - } catch (_) { - try { - dt = DateTime.tryParse(t.toString()); - } catch (e) { - dt = null; - } - } - } - - if (dt != null) { - final local = DateTimeUtils.toDeviceTime(dt); - - return local; - } - return null; - } - @override Future> getMyShifts({ required DateTime start, @@ -100,8 +35,8 @@ class ShiftsRepositoryImpl @override Future> getHistoryShifts() async { - final staffId = await _getStaffId(); - final fdc.QueryResult response = await executeProtected(() => _dataConnect + final staffId = await _service.getStaffId(); + final fdc.QueryResult response = await _service.executeProtected(() => _service.connector .listCompletedApplicationsByStaffId(staffId: staffId) .execute()); final List shifts = []; @@ -116,10 +51,10 @@ class ShiftsRepositoryImpl ? app.shift.order.eventName! : app.shift.order.business.businessName; final String title = '$roleName - $orderName'; - final DateTime? shiftDate = _toDateTime(app.shift.date); - final DateTime? startDt = _toDateTime(app.shiftRole.startTime); - final DateTime? endDt = _toDateTime(app.shiftRole.endTime); - final DateTime? createdDt = _toDateTime(app.createdAt); + final DateTime? shiftDate = _service.toDateTime(app.shift.date); + final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime); + final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime); + final DateTime? createdDt = _service.toDateTime(app.createdAt); shifts.add( Shift( @@ -157,12 +92,12 @@ class ShiftsRepositoryImpl DateTime? start, DateTime? end, }) async { - final staffId = await _getStaffId(); - var query = _dataConnect.getApplicationsByStaffId(staffId: staffId); + final staffId = await _service.getStaffId(); + var query = _service.connector.getApplicationsByStaffId(staffId: staffId); if (start != null && end != null) { - query = query.dayStart(_toTimestamp(start)).dayEnd(_toTimestamp(end)); + query = query.dayStart(_service.toTimestamp(start)).dayEnd(_service.toTimestamp(end)); } - final fdc.QueryResult response = await executeProtected(() => query.execute()); + final fdc.QueryResult response = await _service.executeProtected(() => query.execute()); final apps = response.data.applications; final List shifts = []; @@ -177,10 +112,10 @@ class ShiftsRepositoryImpl ? app.shift.order.eventName! : app.shift.order.business.businessName; final String title = '$roleName - $orderName'; - final DateTime? shiftDate = _toDateTime(app.shift.date); - final DateTime? startDt = _toDateTime(app.shiftRole.startTime); - final DateTime? endDt = _toDateTime(app.shiftRole.endTime); - final DateTime? createdDt = _toDateTime(app.createdAt); + final DateTime? shiftDate = _service.toDateTime(app.shift.date); + final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime); + final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime); + final DateTime? createdDt = _service.toDateTime(app.createdAt); // Override status to reflect the application state (e.g., CHECKED_OUT, CONFIRMED) final bool hasCheckIn = app.checkInTime != null; @@ -226,13 +161,6 @@ class ShiftsRepositoryImpl return shifts; } - fdc.Timestamp _toTimestamp(DateTime dateTime) { - final DateTime utc = dateTime.toUtc(); - final int seconds = utc.millisecondsSinceEpoch ~/ 1000; - final int nanoseconds = (utc.microsecondsSinceEpoch % 1000000) * 1000; - return fdc.Timestamp(nanoseconds, seconds); - } - String _mapStatus(dc.ApplicationStatus status) { switch (status) { case dc.ApplicationStatus.CONFIRMED: @@ -255,7 +183,7 @@ class ShiftsRepositoryImpl return []; } - final fdc.QueryResult result = await executeProtected(() => _dataConnect + final fdc.QueryResult result = await _service.executeProtected(() => _service.connector .listShiftRolesByVendorId(vendorId: vendorId) .execute()); final allShiftRoles = result.data.shiftRoles; @@ -263,10 +191,10 @@ class ShiftsRepositoryImpl final List mappedShifts = []; for (final sr in allShiftRoles) { - final DateTime? shiftDate = _toDateTime(sr.shift.date); - final startDt = _toDateTime(sr.startTime); - final endDt = _toDateTime(sr.endTime); - final createdDt = _toDateTime(sr.createdAt); + final DateTime? shiftDate = _service.toDateTime(sr.shift.date); + final startDt = _service.toDateTime(sr.startTime); + final endDt = _service.toDateTime(sr.endTime); + final createdDt = _service.toDateTime(sr.createdAt); mappedShifts.add( Shift( @@ -319,21 +247,21 @@ class ShiftsRepositoryImpl Future _getShiftDetails(String shiftId, {String? roleId}) async { if (roleId != null && roleId.isNotEmpty) { - final roleResult = await executeProtected(() => _dataConnect + final roleResult = await _service.executeProtected(() => _service.connector .getShiftRoleById(shiftId: shiftId, roleId: roleId) .execute()); final sr = roleResult.data.shiftRole; if (sr == null) return null; - final DateTime? startDt = _toDateTime(sr.startTime); - final DateTime? endDt = _toDateTime(sr.endTime); - final DateTime? createdDt = _toDateTime(sr.createdAt); + final DateTime? startDt = _service.toDateTime(sr.startTime); + final DateTime? endDt = _service.toDateTime(sr.endTime); + final DateTime? createdDt = _service.toDateTime(sr.createdAt); - final String staffId = await _getStaffId(); + final String staffId = await _service.getStaffId(); bool hasApplied = false; String status = 'open'; - final apps = await executeProtected(() => - _dataConnect.getApplicationsByStaffId(staffId: staffId).execute()); + final apps = await _service.executeProtected(() => + _service.connector.getApplicationsByStaffId(staffId: staffId).execute()); final app = apps.data.applications .where( (a) => a.shiftId == shiftId && a.shiftRole.roleId == roleId, @@ -378,7 +306,7 @@ class ShiftsRepositoryImpl } final fdc.QueryResult result = - await executeProtected(() => _dataConnect.getShiftById(id: shiftId).execute()); + await _service.executeProtected(() => _service.connector.getShiftById(id: shiftId).execute()); final s = result.data.shift; if (s == null) return null; @@ -386,8 +314,8 @@ class ShiftsRepositoryImpl int? filled; Break? breakInfo; try { - final rolesRes = await executeProtected(() => - _dataConnect.listShiftRolesByShiftId(shiftId: shiftId).execute()); + final rolesRes = await _service.executeProtected(() => + _service.connector.listShiftRolesByShiftId(shiftId: shiftId).execute()); if (rolesRes.data.shiftRoles.isNotEmpty) { required = 0; filled = 0; @@ -404,9 +332,9 @@ class ShiftsRepositoryImpl } } catch (_) {} - final startDt = _toDateTime(s.startTime); - final endDt = _toDateTime(s.endTime); - final createdDt = _toDateTime(s.createdAt); + final startDt = _service.toDateTime(s.startTime); + final endDt = _service.toDateTime(s.endTime); + final createdDt = _service.toDateTime(s.createdAt); return Shift( id: s.id, @@ -437,14 +365,14 @@ class ShiftsRepositoryImpl bool isInstantBook = false, String? roleId, }) async { - final staffId = await _getStaffId(); + final staffId = await _service.getStaffId(); String targetRoleId = roleId ?? ''; if (targetRoleId.isEmpty) { throw Exception('Missing role id.'); } - final roleResult = await executeProtected(() => _dataConnect + final roleResult = await _service.executeProtected(() => _service.connector .getShiftRoleById(shiftId: shiftId, roleId: targetRoleId) .execute()); final role = roleResult.data.shiftRole; @@ -452,12 +380,12 @@ class ShiftsRepositoryImpl throw Exception('Shift role not found'); } final shiftResult = - await executeProtected(() => _dataConnect.getShiftById(id: shiftId).execute()); + await _service.executeProtected(() => _service.connector.getShiftById(id: shiftId).execute()); final shift = shiftResult.data.shift; if (shift == null) { throw Exception('Shift not found'); } - final DateTime? shiftDate = _toDateTime(shift.date); + final DateTime? shiftDate = _service.toDateTime(shift.date); if (shiftDate != null) { final DateTime dayStartUtc = DateTime.utc( shiftDate.year, @@ -475,16 +403,16 @@ class ShiftsRepositoryImpl 999, ); - final dayApplications = await executeProtected(() => _dataConnect + final dayApplications = await _service.executeProtected(() => _service.connector .vaidateDayStaffApplication(staffId: staffId) - .dayStart(_toTimestamp(dayStartUtc)) - .dayEnd(_toTimestamp(dayEndUtc)) + .dayStart(_service.toTimestamp(dayStartUtc)) + .dayEnd(_service.toTimestamp(dayEndUtc)) .execute()); if (dayApplications.data.applications.isNotEmpty) { throw Exception('The user already has a shift that day.'); } } - final existingApplicationResult = await executeProtected(() => _dataConnect + final existingApplicationResult = await _service.executeProtected(() => _service.connector .getApplicationByStaffShiftAndRole( staffId: staffId, shiftId: shiftId, @@ -505,7 +433,7 @@ class ShiftsRepositoryImpl bool updatedRole = false; bool updatedShift = false; try { - final appResult = await executeProtected(() => _dataConnect + final appResult = await _service.executeProtected(() => _service.connector .createApplication( shiftId: shiftId, staffId: staffId, @@ -517,24 +445,24 @@ class ShiftsRepositoryImpl .execute()); appId = appResult.data.application_insert.id; - await executeProtected(() => _dataConnect + await _service.executeProtected(() => _service.connector .updateShiftRole(shiftId: shiftId, roleId: targetRoleId) .assigned(assigned + 1) .execute()); updatedRole = true; - await executeProtected( - () => _dataConnect.updateShift(id: shiftId).filled(filled + 1).execute()); + await _service.executeProtected( + () => _service.connector.updateShift(id: shiftId).filled(filled + 1).execute()); updatedShift = true; } catch (e) { if (updatedShift) { try { - await _dataConnect.updateShift(id: shiftId).filled(filled).execute(); + await _service.connector.updateShift(id: shiftId).filled(filled).execute(); } catch (_) {} } if (updatedRole) { try { - await _dataConnect + await _service.connector .updateShiftRole(shiftId: shiftId, roleId: targetRoleId) .assigned(assigned) .execute(); @@ -542,7 +470,7 @@ class ShiftsRepositoryImpl } if (appId != null) { try { - await _dataConnect.deleteApplication(id: appId).execute(); + await _service.connector.deleteApplication(id: appId).execute(); } catch (_) {} } rethrow; @@ -576,9 +504,9 @@ class ShiftsRepositoryImpl roleId = _appToRoleIdMap[appId]; } else { // Fallback fetch - final staffId = await _getStaffId(); - final apps = await executeProtected(() => - _dataConnect.getApplicationsByStaffId(staffId: staffId).execute()); + final staffId = await _service.getStaffId(); + final apps = await _service.executeProtected(() => + _service.connector.getApplicationsByStaffId(staffId: staffId).execute()); final app = apps.data.applications .where((a) => a.shiftId == shiftId) .firstOrNull; @@ -591,12 +519,12 @@ class ShiftsRepositoryImpl if (appId == null || roleId == null) { // If we are rejecting and can't find an application, create one as rejected (declining an available shift) if (newStatus == dc.ApplicationStatus.REJECTED) { - final rolesResult = await executeProtected(() => - _dataConnect.listShiftRolesByShiftId(shiftId: shiftId).execute()); + final rolesResult = await _service.executeProtected(() => + _service.connector.listShiftRolesByShiftId(shiftId: shiftId).execute()); if (rolesResult.data.shiftRoles.isNotEmpty) { final role = rolesResult.data.shiftRoles.first; - final staffId = await _getStaffId(); - await executeProtected(() => _dataConnect + final staffId = await _service.getStaffId(); + await _service.executeProtected(() => _service.connector .createApplication( shiftId: shiftId, staffId: staffId, @@ -611,7 +539,7 @@ class ShiftsRepositoryImpl throw Exception("Application not found for shift $shiftId"); } - await executeProtected(() => _dataConnect + await _service.executeProtected(() => _service.connector .updateApplicationStatus(id: appId!) .status(newStatus) .execute()); From b6e8f63d7efda1f92d47826d4c2028ee66f0ac4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Salazar?= <73718835+joshrs23@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:58:58 -0500 Subject: [PATCH 19/53] adding isProfileVisible to the queries of staff --- backend/dataconnect/connector/staff/queries.gql | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/dataconnect/connector/staff/queries.gql b/backend/dataconnect/connector/staff/queries.gql index a3d38d86..aecf8891 100644 --- a/backend/dataconnect/connector/staff/queries.gql +++ b/backend/dataconnect/connector/staff/queries.gql @@ -8,6 +8,7 @@ query listStaff @auth(level: USER) { phone email photoUrl + isProfileVisible totalShifts averageRating @@ -60,6 +61,7 @@ query getStaffById($id: UUID!) @auth(level: USER) { phone email photoUrl + isProfileVisible totalShifts averageRating @@ -113,6 +115,7 @@ query getStaffByUserId($userId: String!) @auth(level: USER) { phone email photoUrl + isProfileVisible totalShifts averageRating @@ -178,6 +181,7 @@ query filterStaff( phone email photoUrl + isProfileVisible averageRating reliabilityScore totalShifts From 3245c957f6cb465973696bbae4e66ebce5ea3d9d Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 15:39:40 -0500 Subject: [PATCH 20/53] feat(data-connect): Add run method for centralized error handling and authentication checks --- .../lib/src/services/data_connect_service.dart | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart index cdfe2813..c91c34d1 100644 --- a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart +++ b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; import '../../krow_data_connect.dart' as dc; import '../mixins/data_error_handler.dart'; @@ -20,6 +21,7 @@ class DataConnectService with DataErrorHandler { final dc.ExampleConnector connector = dc.ExampleConnector.instance; /// The Firebase Auth instance. + firebase_auth.FirebaseAuth get auth => _auth; final firebase_auth.FirebaseAuth _auth = firebase_auth.FirebaseAuth.instance; /// Cache for the current staff ID to avoid redundant lookups. @@ -97,6 +99,20 @@ class DataConnectService with DataErrorHandler { return fdc.Timestamp(nanoseconds, seconds); } + // --- 3. Unified Execution --- + // Repositories call this to benefit from centralized error handling/logging + Future run( + Future Function() action, { + bool requiresAuthentication = true, + }) { + if (requiresAuthentication && auth.currentUser == null) { + throw const NotAuthenticatedException( + technicalMessage: 'User must be authenticated to perform this action', + ); + } + return executeProtected(action); + } + /// Clears the internal cache (e.g., on logout). void clearCache() { _cachedStaffId = null; From d0585d12abfbecc87578f8d5f1a392697464e99c Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 15:47:01 -0500 Subject: [PATCH 21/53] feat(auth): Refactor AuthRepositoryImpl and ProfileSetupRepositoryImpl to use DataConnectService for authentication and data operations --- .../auth_repository_impl.dart | 103 ++++++++++-------- .../profile_setup_repository_impl.dart | 32 +++--- .../lib/src/staff_authentication_module.dart | 15 +-- 3 files changed, 76 insertions(+), 74 deletions(-) diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index 0d4bca6b..b247880e 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -10,20 +10,14 @@ import '../../domain/ui_entities/auth_mode.dart'; import '../../domain/repositories/auth_repository_interface.dart'; /// Implementation of [AuthRepositoryInterface]. -class AuthRepositoryImpl - with DataErrorHandler - implements AuthRepositoryInterface { - AuthRepositoryImpl({ - required this.firebaseAuth, - required this.dataConnect, - }); +class AuthRepositoryImpl implements AuthRepositoryInterface { + AuthRepositoryImpl() : _service = DataConnectService.instance; - final FirebaseAuth firebaseAuth; - final ExampleConnector dataConnect; + final DataConnectService _service; Completer? _pendingVerification; @override - Stream get currentUser => firebaseAuth + Stream get currentUser => _service.auth .authStateChanges() .map((User? firebaseUser) { if (firebaseUser == null) { @@ -44,7 +38,7 @@ class AuthRepositoryImpl final Completer completer = Completer(); _pendingVerification = completer; - await firebaseAuth.verifyPhoneNumber( + await _service.auth.verifyPhoneNumber( phoneNumber: phoneNumber, verificationCompleted: (PhoneAuthCredential credential) { // Skip auto-verification for test numbers to allow manual code entry @@ -101,7 +95,8 @@ class AuthRepositoryImpl @override Future signOut() { StaffSessionStore.instance.clear(); - return firebaseAuth.signOut(); + _service.clearCache(); + return _service.auth.signOut(); } /// Verifies an OTP code and returns the authenticated user. @@ -115,10 +110,10 @@ class AuthRepositoryImpl verificationId: verificationId, smsCode: smsCode, ); - final UserCredential userCredential = await executeProtected( + final UserCredential userCredential = await _service.run( () async { try { - return await firebaseAuth.signInWithCredential(credential); + return await _service.auth.signInWithCredential(credential); } on FirebaseAuthException catch (e) { if (e.code == 'invalid-verification-code') { throw const domain.InvalidCredentialsException( @@ -128,45 +123,56 @@ class AuthRepositoryImpl rethrow; } }, + requiresAuthentication: false, ); final User? firebaseUser = userCredential.user; if (firebaseUser == null) { throw const domain.SignInFailedException( - technicalMessage: 'Phone verification failed, no Firebase user received.', + technicalMessage: + 'Phone verification failed, no Firebase user received.', ); } final QueryResult response = - await executeProtected(() => dataConnect - .getUserById( - id: firebaseUser.uid, - ) - .execute()); + await _service.run( + () => _service.connector + .getUserById( + id: firebaseUser.uid, + ) + .execute(), + requiresAuthentication: false, + ); final GetUserByIdUser? user = response.data.user; GetStaffByUserIdStaffs? staffRecord; if (mode == AuthMode.signup) { if (user == null) { - await executeProtected(() => dataConnect - .createUser( - id: firebaseUser.uid, - role: UserBaseRole.USER, - ) - .userRole('STAFF') - .execute()); + await _service.run( + () => _service.connector + .createUser( + id: firebaseUser.uid, + role: UserBaseRole.USER, + ) + .userRole('STAFF') + .execute(), + requiresAuthentication: false, + ); } else { // User exists in PostgreSQL. Check if they have a STAFF profile. final QueryResult - staffResponse = await executeProtected(() => dataConnect - .getStaffByUserId( - userId: firebaseUser.uid, - ) - .execute()); + staffResponse = await _service.run( + () => _service.connector + .getStaffByUserId( + userId: firebaseUser.uid, + ) + .execute(), + requiresAuthentication: false, + ); if (staffResponse.data.staffs.isNotEmpty) { // If profile exists, they should use Login mode. - await firebaseAuth.signOut(); + await _service.auth.signOut(); throw const domain.AccountExistsException( technicalMessage: 'This user already has a staff profile. Please log in.', @@ -177,35 +183,44 @@ class AuthRepositoryImpl // they are allowed to "Sign Up" for Staff. // We update their userRole to 'BOTH'. if (user.userRole == 'BUSINESS') { - await executeProtected(() => - dataConnect.updateUser(id: firebaseUser.uid).userRole('BOTH').execute()); + await _service.run( + () => _service.connector + .updateUser(id: firebaseUser.uid) + .userRole('BOTH') + .execute(), + requiresAuthentication: false, + ); } } } else { if (user == null) { - await firebaseAuth.signOut(); + await _service.auth.signOut(); throw const domain.UserNotFoundException( technicalMessage: 'Authenticated user profile not found in database.', ); } // Allow STAFF or BOTH roles to log in to the Staff App if (user.userRole != 'STAFF' && user.userRole != 'BOTH') { - await firebaseAuth.signOut(); + await _service.auth.signOut(); throw const domain.UnauthorizedAppException( technicalMessage: 'User is not authorized for this app.', ); } final QueryResult - staffResponse = await executeProtected(() => dataConnect - .getStaffByUserId( - userId: firebaseUser.uid, - ) - .execute()); + staffResponse = await _service.run( + () => _service.connector + .getStaffByUserId( + userId: firebaseUser.uid, + ) + .execute(), + requiresAuthentication: false, + ); if (staffResponse.data.staffs.isEmpty) { - await firebaseAuth.signOut(); + await _service.auth.signOut(); throw const domain.UserNotFoundException( - technicalMessage: 'Your account is not registered yet. Please register first.', + technicalMessage: + 'Your account is not registered yet. Please register first.', ); } staffRecord = staffResponse.data.staffs.first; diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart index 1aeabdc2..fe25eea3 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart @@ -1,18 +1,13 @@ -import 'package:firebase_auth/firebase_auth.dart' as auth; import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; import 'package:krow_domain/krow_domain.dart'; +import 'package:firebase_auth/firebase_auth.dart' as auth; import '../../domain/repositories/profile_setup_repository.dart'; class ProfileSetupRepositoryImpl implements ProfileSetupRepository { - final auth.FirebaseAuth _firebaseAuth; - final ExampleConnector _dataConnect; + final DataConnectService _service; - ProfileSetupRepositoryImpl({ - required auth.FirebaseAuth firebaseAuth, - required ExampleConnector dataConnect, - }) : _firebaseAuth = firebaseAuth, - _dataConnect = dataConnect; + ProfileSetupRepositoryImpl() : _service = DataConnectService.instance; @override Future submitProfile({ @@ -23,17 +18,19 @@ class ProfileSetupRepositoryImpl implements ProfileSetupRepository { required List industries, required List skills, }) async { - final auth.User? firebaseUser = _firebaseAuth.currentUser; - if (firebaseUser == null) { - throw Exception('User not authenticated.'); - } + return _service.run(() async { + final auth.User? firebaseUser = _service.auth.currentUser; + if (firebaseUser == null) { + throw const NotAuthenticatedException( + technicalMessage: 'User not authenticated.'); + } - final StaffSession? session = StaffSessionStore.instance.session; - final String email = session?.user.email ?? ''; - final String? phone = firebaseUser.phoneNumber; + final StaffSession? session = StaffSessionStore.instance.session; + final String email = session?.user.email ?? ''; + final String? phone = firebaseUser.phoneNumber; - final fdc.OperationResult - result = await _dataConnect + final fdc.OperationResult result = + await _service.connector .createStaff( userId: firebaseUser.uid, fullName: fullName, @@ -63,5 +60,6 @@ class ProfileSetupRepositoryImpl implements ProfileSetupRepository { StaffSession(user: session.user, staff: staff, ownerId: session.ownerId), ); } + }); } } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart b/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart index ef1f34da..c5380d68 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; -import 'package:firebase_auth/firebase_auth.dart' as firebase; import 'package:staff_authentication/src/data/repositories_impl/auth_repository_impl.dart'; import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart'; import 'package:staff_authentication/src/domain/usecases/sign_in_with_phone_usecase.dart'; @@ -28,18 +27,8 @@ class StaffAuthenticationModule extends Module { @override void binds(Injector i) { // Repositories - i.addLazySingleton( - () => AuthRepositoryImpl( - firebaseAuth: firebase.FirebaseAuth.instance, - dataConnect: ExampleConnector.instance, - ), - ); - i.addLazySingleton( - () => ProfileSetupRepositoryImpl( - firebaseAuth: firebase.FirebaseAuth.instance, - dataConnect: ExampleConnector.instance, - ), - ); + i.addLazySingleton(AuthRepositoryImpl.new); + i.addLazySingleton(ProfileSetupRepositoryImpl.new); i.addLazySingleton(PlaceRepositoryImpl.new); // UseCases From 1f7134799b5396abb053f0c12d364e048f89f007 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 15:48:45 -0500 Subject: [PATCH 22/53] feat(availability): Refactor AvailabilityRepositoryImpl to use DataConnectService and simplify dependency injection --- .../availability_repository_impl.dart | 50 +++++-------------- .../lib/src/staff_availability_module.dart | 7 +-- 2 files changed, 13 insertions(+), 44 deletions(-) diff --git a/apps/mobile/packages/features/staff/availability/lib/src/data/repositories_impl/availability_repository_impl.dart b/apps/mobile/packages/features/staff/availability/lib/src/data/repositories_impl/availability_repository_impl.dart index c68ae129..4c7a1afe 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/data/repositories_impl/availability_repository_impl.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/data/repositories_impl/availability_repository_impl.dart @@ -1,4 +1,3 @@ -import 'package:firebase_auth/firebase_auth.dart' as firebase; import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart'; @@ -10,44 +9,19 @@ import '../../domain/repositories/availability_repository.dart'; /// not specific date availability. Therefore, updating availability for a specific /// date will update the availability for that Day of Week globally (Recurring). class AvailabilityRepositoryImpl - with dc.DataErrorHandler implements AvailabilityRepository { - final dc.ExampleConnector _dataConnect; - final firebase.FirebaseAuth _firebaseAuth; - String? _cachedStaffId; + final dc.DataConnectService _service; - AvailabilityRepositoryImpl({ - required dc.ExampleConnector dataConnect, - required firebase.FirebaseAuth firebaseAuth, - }) : _dataConnect = dataConnect, - _firebaseAuth = firebaseAuth; - - Future _getStaffId() async { - if (_cachedStaffId != null) return _cachedStaffId!; - - final firebase.User? user = _firebaseAuth.currentUser; - if (user == null) { - throw NotAuthenticatedException( - technicalMessage: 'User not authenticated'); - } - - final QueryResult result = - await _dataConnect.getStaffByUserId(userId: user.uid).execute(); - if (result.data.staffs.isEmpty) { - throw const ServerException(technicalMessage: 'Staff profile not found'); - } - _cachedStaffId = result.data.staffs.first.id; - return _cachedStaffId!; - } + AvailabilityRepositoryImpl() : _service = dc.DataConnectService.instance; @override Future> getAvailability(DateTime start, DateTime end) async { - return executeProtected(() async { - final String staffId = await _getStaffId(); + return _service.run(() async { + final String staffId = await _service.getStaffId(); // 1. Fetch Weekly recurring availability final QueryResult result = - await _dataConnect.listStaffAvailabilitiesByStaffId(staffId: staffId).limit(100).execute(); + await _service.connector.listStaffAvailabilitiesByStaffId(staffId: staffId).limit(100).execute(); final List items = result.data.staffAvailabilities; @@ -124,8 +98,8 @@ class AvailabilityRepositoryImpl @override Future updateDayAvailability(DayAvailability availability) async { - return executeProtected(() async { - final String staffId = await _getStaffId(); + return _service.run(() async { + final String staffId = await _service.getStaffId(); final dc.DayOfWeek dow = _toBackendDay(availability.date.weekday); // Update each slot in the backend. @@ -143,8 +117,8 @@ class AvailabilityRepositoryImpl @override Future> applyQuickSet(DateTime start, DateTime end, String type) async { - return executeProtected(() async { - final String staffId = await _getStaffId(); + return _service.run(() async { + final String staffId = await _service.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. @@ -204,7 +178,7 @@ class AvailabilityRepositoryImpl Future _upsertSlot(String staffId, dc.DayOfWeek day, dc.AvailabilitySlot slot, dc.AvailabilityStatus status) async { // Check if exists - final result = await _dataConnect.getStaffAvailabilityByKey( + final result = await _service.connector.getStaffAvailabilityByKey( staffId: staffId, day: day, slot: slot, @@ -212,14 +186,14 @@ class AvailabilityRepositoryImpl if (result.data.staffAvailability != null) { // Update - await _dataConnect.updateStaffAvailability( + await _service.connector.updateStaffAvailability( staffId: staffId, day: day, slot: slot, ).status(status).execute(); } else { // Create - await _dataConnect.createStaffAvailability( + await _service.connector.createStaffAvailability( staffId: staffId, day: day, slot: slot, diff --git a/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart b/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart index 88458885..98937517 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart @@ -18,12 +18,7 @@ class StaffAvailabilityModule extends Module { @override void binds(Injector i) { // Repository - i.add( - () => AvailabilityRepositoryImpl( - dataConnect: ExampleConnector.instance, - firebaseAuth: FirebaseAuth.instance, - ), - ); + i.add(AvailabilityRepositoryImpl.new); // UseCases i.add(GetWeeklyAvailabilityUseCase.new); From 66859e4241ca804a5a2e766389c34d5baee94a7a Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 16:00:27 -0500 Subject: [PATCH 23/53] feat(clock-in): Refactor ClockInRepositoryImpl to utilize DataConnectService and simplify dependency injection --- .../clock_in_repository_impl.dart | 308 ++++++++---------- .../lib/src/staff_clock_in_module.dart | 5 +- 2 files changed, 135 insertions(+), 178 deletions(-) diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart index 6caf9a50..ea0e990f 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/data/repositories_impl/clock_in_repository_impl.dart @@ -1,69 +1,17 @@ import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart'; -import 'package:krow_core/core.dart'; import '../../domain/repositories/clock_in_repository_interface.dart'; /// Implementation of [ClockInRepositoryInterface] using Firebase Data Connect. -class ClockInRepositoryImpl - with dc.DataErrorHandler - implements ClockInRepositoryInterface { +class ClockInRepositoryImpl implements ClockInRepositoryInterface { + ClockInRepositoryImpl() : _service = dc.DataConnectService.instance; - ClockInRepositoryImpl({ - required dc.ExampleConnector dataConnect, - }) : _dataConnect = dataConnect; - final dc.ExampleConnector _dataConnect; + final dc.DataConnectService _service; final Map _shiftToApplicationId = {}; String? _activeApplicationId; - Future _getStaffId() async { - final dc.StaffSession? session = dc.StaffSessionStore.instance.session; - final String? staffId = session?.staff?.id; - if (staffId != null && staffId.isNotEmpty) { - return staffId; - } - throw Exception('Staff session not found'); - } - - /// Helper to convert Data Connect fdc.Timestamp to DateTime - DateTime? _toDateTime(dynamic t) { - if (t == null) return null; - DateTime? dt; - if (t is DateTime) { - dt = t; - } else if (t is String) { - dt = DateTime.tryParse(t); - } else { - try { - if (t is fdc.Timestamp) { - dt = t.toDateTime(); - } - } catch (_) {} - - try { - if (dt == null && t.runtimeType.toString().contains('Timestamp')) { - dt = (t as dynamic).toDate(); - } - } catch (_) {} - - try { - dt ??= DateTime.tryParse(t.toString()); - } catch (_) {} - } - - if (dt != null) { - return DateTimeUtils.toDeviceTime(dt); - } - return null; - } - - /// Helper to create fdc.Timestamp from DateTime - fdc.Timestamp _fromDateTime(DateTime d) { - // Assuming fdc.Timestamp.fromJson takes an ISO string - return fdc.Timestamp.fromJson(d.toUtc().toIso8601String()); - } - ({fdc.Timestamp start, fdc.Timestamp end}) _utcDayRange(DateTime localDay) { final DateTime dayStartUtc = DateTime.utc( localDay.year, @@ -81,8 +29,8 @@ class ClockInRepositoryImpl 999, ); return ( - start: _fromDateTime(dayStartUtc), - end: _fromDateTime(dayEndUtc), + start: _service.toTimestamp(dayStartUtc), + end: _service.toTimestamp(dayEndUtc), ); } @@ -93,26 +41,29 @@ class ClockInRepositoryImpl final DateTime now = DateTime.now(); final ({fdc.Timestamp start, fdc.Timestamp end}) range = _utcDayRange(now); final fdc.QueryResult result = await executeProtected( - () => _dataConnect + dc.GetApplicationsByStaffIdVariables> result = await _service.run( + () => _service.connector .getApplicationsByStaffId(staffId: staffId) .dayStart(range.start) .dayEnd(range.end) .execute(), ); - final List apps = result.data.applications; + final List apps = + result.data.applications; if (apps.isEmpty) return const []; _shiftToApplicationId ..clear() - ..addEntries(apps.map((dc.GetApplicationsByStaffIdApplications app) => MapEntry(app.shiftId, app.id))); + ..addEntries(apps.map((dc.GetApplicationsByStaffIdApplications app) => + MapEntry(app.shiftId, app.id))); - apps.sort((dc.GetApplicationsByStaffIdApplications a, dc.GetApplicationsByStaffIdApplications b) { + apps.sort((dc.GetApplicationsByStaffIdApplications a, + dc.GetApplicationsByStaffIdApplications b) { final DateTime? aTime = - _toDateTime(a.shift.startTime) ?? _toDateTime(a.shift.date); + _service.toDateTime(a.shift.startTime) ?? _service.toDateTime(a.shift.date); final DateTime? bTime = - _toDateTime(b.shift.startTime) ?? _toDateTime(b.shift.date); + _service.toDateTime(b.shift.startTime) ?? _service.toDateTime(b.shift.date); if (aTime == null && bTime == null) return 0; if (aTime == null) return -1; if (bTime == null) return 1; @@ -124,118 +75,124 @@ class ClockInRepositoryImpl return apps; } - - @override Future> getTodaysShifts() async { - final String staffId = await _getStaffId(); - final List apps = - await _getTodaysApplications(staffId); - if (apps.isEmpty) return const []; + return _service.run(() async { + final String staffId = await _service.getStaffId(); + final List apps = + await _getTodaysApplications(staffId); + if (apps.isEmpty) return const []; - final List shifts = []; - for (final dc.GetApplicationsByStaffIdApplications app in apps) { - final dc.GetApplicationsByStaffIdApplicationsShift shift = app.shift; - final DateTime? startDt = _toDateTime(app.shiftRole.startTime); - final DateTime? endDt = _toDateTime(app.shiftRole.endTime); - final DateTime? createdDt = _toDateTime(app.createdAt); + final List shifts = []; + for (final dc.GetApplicationsByStaffIdApplications app in apps) { + final dc.GetApplicationsByStaffIdApplicationsShift shift = app.shift; + final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime); + final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime); + final DateTime? createdDt = _service.toDateTime(app.createdAt); - final String roleName = app.shiftRole.role.name; - final String orderName = - (shift.order.eventName ?? '').trim().isNotEmpty - ? shift.order.eventName! - : shift.order.business.businessName; - final String title = '$roleName - $orderName'; - shifts.add( - Shift( - id: shift.id, - title: title, - clientName: shift.order.business.businessName, - logoUrl: shift.order.business.companyLogoUrl ?? '', - hourlyRate: app.shiftRole.role.costPerHour, - location: shift.location ?? '', - locationAddress: shift.order.teamHub.hubName, - date: startDt?.toIso8601String() ?? '', - startTime: startDt?.toIso8601String() ?? '', - endTime: endDt?.toIso8601String() ?? '', - createdDate: createdDt?.toIso8601String() ?? '', - status: shift.status?.stringValue, - description: shift.description, - latitude: shift.latitude, - longitude: shift.longitude, - ), - ); - } + final String roleName = app.shiftRole.role.name; + final String orderName = + (shift.order.eventName ?? '').trim().isNotEmpty + ? shift.order.eventName! + : shift.order.business.businessName; + final String title = '$roleName - $orderName'; + shifts.add( + Shift( + id: shift.id, + title: title, + clientName: shift.order.business.businessName, + logoUrl: shift.order.business.companyLogoUrl ?? '', + hourlyRate: app.shiftRole.role.costPerHour, + location: shift.location ?? '', + locationAddress: shift.order.teamHub.hubName, + date: startDt?.toIso8601String() ?? '', + startTime: startDt?.toIso8601String() ?? '', + endTime: endDt?.toIso8601String() ?? '', + createdDate: createdDt?.toIso8601String() ?? '', + status: shift.status?.stringValue, + description: shift.description, + latitude: shift.latitude, + longitude: shift.longitude, + ), + ); + } - return shifts; + return shifts; + }); } @override Future getAttendanceStatus() async { - final String staffId = await _getStaffId(); - final List apps = - await _getTodaysApplications(staffId); - if (apps.isEmpty) { - return const AttendanceStatus(isCheckedIn: false); - } + return _service.run(() async { + final String staffId = await _service.getStaffId(); + final List apps = + await _getTodaysApplications(staffId); + if (apps.isEmpty) { + return const AttendanceStatus(isCheckedIn: false); + } - dc.GetApplicationsByStaffIdApplications? activeApp; - for (final dc.GetApplicationsByStaffIdApplications app in apps) { - if (app.checkInTime != null && app.checkOutTime == null) { - if (activeApp == null) { - activeApp = app; - } else { - final DateTime? current = _toDateTime(activeApp.checkInTime); - final DateTime? next = _toDateTime(app.checkInTime); - if (current == null || (next != null && next.isAfter(current))) { + dc.GetApplicationsByStaffIdApplications? activeApp; + for (final dc.GetApplicationsByStaffIdApplications app in apps) { + if (app.checkInTime != null && app.checkOutTime == null) { + if (activeApp == null) { activeApp = app; + } else { + final DateTime? current = _service.toDateTime(activeApp.checkInTime); + final DateTime? next = _service.toDateTime(app.checkInTime); + if (current == null || (next != null && next.isAfter(current))) { + activeApp = app; + } } } } - } - if (activeApp == null) { - _activeApplicationId = null; - return const AttendanceStatus(isCheckedIn: false); - } + if (activeApp == null) { + _activeApplicationId = null; + return const AttendanceStatus(isCheckedIn: false); + } - _activeApplicationId = activeApp.id; + _activeApplicationId = activeApp.id; - return AttendanceStatus( - isCheckedIn: true, - checkInTime: _toDateTime(activeApp.checkInTime), - checkOutTime: _toDateTime(activeApp.checkOutTime), - activeShiftId: activeApp.shiftId, - activeApplicationId: activeApp.id, - ); + return AttendanceStatus( + isCheckedIn: true, + checkInTime: _service.toDateTime(activeApp.checkInTime), + checkOutTime: _service.toDateTime(activeApp.checkOutTime), + activeShiftId: activeApp.shiftId, + activeApplicationId: activeApp.id, + ); + }); } @override Future clockIn({required String shiftId, String? notes}) async { - final String staffId = await _getStaffId(); + return _service.run(() async { + final String staffId = await _service.getStaffId(); - final String? cachedAppId = _shiftToApplicationId[shiftId]; - dc.GetApplicationsByStaffIdApplications? app; - if (cachedAppId != null) { - try { - final List apps = await _getTodaysApplications(staffId); - app = apps.firstWhere((dc.GetApplicationsByStaffIdApplications a) => a.id == cachedAppId); - } catch (_) {} - } - app ??= (await _getTodaysApplications(staffId)) - .firstWhere((dc.GetApplicationsByStaffIdApplications a) => a.shiftId == shiftId); + final String? cachedAppId = _shiftToApplicationId[shiftId]; + dc.GetApplicationsByStaffIdApplications? app; + if (cachedAppId != null) { + try { + final List apps = + await _getTodaysApplications(staffId); + app = apps.firstWhere( + (dc.GetApplicationsByStaffIdApplications a) => a.id == cachedAppId); + } catch (_) {} + } + app ??= (await _getTodaysApplications(staffId)).firstWhere( + (dc.GetApplicationsByStaffIdApplications a) => a.shiftId == shiftId); - final fdc.Timestamp checkInTs = _fromDateTime(DateTime.now()); + final fdc.Timestamp checkInTs = _service.toTimestamp(DateTime.now()); - await executeProtected(() => _dataConnect - .updateApplicationStatus( - id: app!.id, - ) - .checkInTime(checkInTs) - .execute()); - _activeApplicationId = app.id; + await _service.run(() => _service.connector + .updateApplicationStatus( + id: app!.id, + ) + .checkInTime(checkInTs) + .execute()); + _activeApplicationId = app.id; - return getAttendanceStatus(); + return getAttendanceStatus(); + }); } @override @@ -244,32 +201,35 @@ class ClockInRepositoryImpl int? breakTimeMinutes, String? applicationId, }) async { - await _getStaffId(); // Validate session - + return _service.run(() async { + await _service.getStaffId(); // Validate session - final String? targetAppId = applicationId ?? _activeApplicationId; - if (targetAppId == null || targetAppId.isEmpty) { - throw Exception('No active application id for checkout'); - } - final fdc.QueryResult appResult = await executeProtected(() => _dataConnect - .getApplicationById(id: targetAppId) - .execute()); - final dc.GetApplicationByIdApplication? app = appResult.data.application; + final String? targetAppId = applicationId ?? _activeApplicationId; + if (targetAppId == null || targetAppId.isEmpty) { + throw Exception('No active application id for checkout'); + } + final fdc.QueryResult appResult = + await _service.run(() => _service.connector + .getApplicationById(id: targetAppId) + .execute()); + final dc.GetApplicationByIdApplication? app = appResult.data.application; - if (app == null) { - throw Exception('Application not found for checkout'); - } - if (app.checkInTime == null || app.checkOutTime != null) { - throw Exception('No active shift found to clock out'); - } + if (app == null) { + throw Exception('Application not found for checkout'); + } + if (app.checkInTime == null || app.checkOutTime != null) { + throw Exception('No active shift found to clock out'); + } - await executeProtected(() => _dataConnect - .updateApplicationStatus( - id: targetAppId, - ) - .checkOutTime(_fromDateTime(DateTime.now())) - .execute()); + await _service.run(() => _service.connector + .updateApplicationStatus( + id: targetAppId, + ) + .checkOutTime(_service.toTimestamp(DateTime.now())) + .execute()); - return getAttendanceStatus(); + return getAttendanceStatus(); + }); } } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart index 37164a81..ffd19c01 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/staff_clock_in_module.dart @@ -1,7 +1,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; import 'data/repositories_impl/clock_in_repository_impl.dart'; import 'domain/repositories/clock_in_repository_interface.dart'; @@ -16,9 +15,7 @@ class StaffClockInModule extends Module { @override void binds(Injector i) { // Repositories - i.add( - () => ClockInRepositoryImpl(dataConnect: ExampleConnector.instance), - ); + i.add(ClockInRepositoryImpl.new); // Use Cases i.add(GetTodaysShiftUseCase.new); From dcb76db1f8e02cecc245ac87aac8778104bc1714 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 16:05:38 -0500 Subject: [PATCH 24/53] feat(home-repository): Refactor HomeRepositoryImpl to utilize DataConnectService for data operations and simplify shift retrieval logic --- .../repositories/home_repository_impl.dart | 113 ++++++++---------- 1 file changed, 50 insertions(+), 63 deletions(-) diff --git a/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart b/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart index 8783e938..8f8bf8a8 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart @@ -1,26 +1,13 @@ -import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:intl/intl.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:krow_core/core.dart'; import 'package:staff_home/src/domain/repositories/home_repository.dart'; -extension TimestampExt on Timestamp { - DateTime toDate() { - return DateTimeUtils.toDeviceTime(toDateTime()); - } -} - class HomeRepositoryImpl - with DataErrorHandler implements HomeRepository { - HomeRepositoryImpl(); + HomeRepositoryImpl() : _service = DataConnectService.instance; - String get _currentStaffId { - final session = StaffSessionStore.instance.session; - if (session?.staff?.id == null) throw Exception('User not logged in'); - return session!.staff!.id; - } + final DataConnectService _service; @override Future> getTodayShifts() async { @@ -33,59 +20,57 @@ class HomeRepositoryImpl } Future> _getShiftsForDate(DateTime date) async { - final staffId = _currentStaffId; + return _service.run(() async { + final staffId = await _service.getStaffId(); - // Create start and end timestamps for the target date - final DateTime start = DateTime(date.year, date.month, date.day); - final DateTime end = - DateTime(date.year, date.month, date.day, 23, 59, 59, 999); + // Create start and end timestamps for the target date + final DateTime start = DateTime(date.year, date.month, date.day); + final DateTime end = + DateTime(date.year, date.month, date.day, 23, 59, 59, 999); - final response = await executeProtected(() => ExampleConnector.instance - .getApplicationsByStaffId(staffId: staffId) - .dayStart(_toTimestamp(start)) - .dayEnd(_toTimestamp(end)) - .execute()); + final response = await _service.run(() => _service.connector + .getApplicationsByStaffId(staffId: staffId) + .dayStart(_service.toTimestamp(start)) + .dayEnd(_service.toTimestamp(end)) + .execute()); - // Filter for CONFIRMED applications (same logic as shifts_repository_impl) - final apps = response.data.applications.where((app) => - (app.status is Known && - (app.status as Known).value == ApplicationStatus.CONFIRMED)); + // Filter for CONFIRMED applications (same logic as shifts_repository_impl) + final apps = response.data.applications.where((app) => + (app.status is Known && + (app.status as Known).value == ApplicationStatus.CONFIRMED)); - final List shifts = []; - for (final app in apps) { - shifts.add(_mapApplicationToShift(app)); - } + final List shifts = []; + for (final app in apps) { + shifts.add(_mapApplicationToShift(app)); + } - return shifts; - } - - Timestamp _toTimestamp(DateTime dateTime) { - final DateTime utc = dateTime.toUtc(); - final int seconds = utc.millisecondsSinceEpoch ~/ 1000; - final int nanoseconds = (utc.microsecondsSinceEpoch % 1000000) * 1000; - return Timestamp(nanoseconds, seconds); + return shifts; + }); } @override Future> getRecommendedShifts() async { // Logic: List ALL open shifts (simple recommendation engine) // Limitation: listShifts might return ALL shifts. We should ideally filter by status=PUBLISHED. - final response = await executeProtected(() => ExampleConnector.instance.listShifts().execute()); + return _service.run(() async { + final response = + await _service.run(() => _service.connector.listShifts().execute()); - return response.data.shifts - .where((s) { - final isOpen = - s.status is Known && (s.status as Known).value == ShiftStatus.OPEN; - if (!isOpen) return false; + return response.data.shifts + .where((s) { + final isOpen = s.status is Known && + (s.status as Known).value == ShiftStatus.OPEN; + if (!isOpen) return false; - final start = s.startTime?.toDate(); - if (start == null) return false; + final start = _service.toDateTime(s.startTime); + if (start == null) return false; - return start.isAfter(DateTime.now()); - }) - .take(10) - .map((s) => _mapConnectorShiftToDomain(s)) - .toList(); + return start.isAfter(DateTime.now()); + }) + .take(10) + .map((s) => _mapConnectorShiftToDomain(s)) + .toList(); + }); } @override @@ -100,7 +85,7 @@ class HomeRepositoryImpl Shift _mapApplicationToShift(GetApplicationsByStaffIdApplications app) { final s = app.shift; final r = app.shiftRole; - + return ShiftAdapter.fromApplicationData( shiftId: s.id, roleId: r.roleId, @@ -110,10 +95,10 @@ class HomeRepositoryImpl costPerHour: r.role.costPerHour, shiftLocation: s.location, teamHubName: s.order.teamHub.hubName, - shiftDate: s.date?.toDate(), - startTime: r.startTime?.toDate(), - endTime: r.endTime?.toDate(), - createdAt: app.createdAt?.toDate(), + shiftDate: _service.toDateTime(s.date), + startTime: _service.toDateTime(r.startTime), + endTime: _service.toDateTime(r.endTime), + createdAt: _service.toDateTime(app.createdAt), status: 'confirmed', description: s.description, durationDays: s.durationDays, @@ -132,10 +117,12 @@ class HomeRepositoryImpl hourlyRate: s.cost ?? 0.0, location: s.location ?? 'Unknown', locationAddress: s.locationAddress ?? '', - date: s.date?.toDate().toIso8601String() ?? '', - startTime: DateFormat('HH:mm').format(s.startTime?.toDate() ?? DateTime.now()), - endTime: DateFormat('HH:mm').format(s.endTime?.toDate() ?? DateTime.now()), - createdDate: s.createdAt?.toDate().toIso8601String() ?? '', + date: _service.toDateTime(s.date)?.toIso8601String() ?? '', + startTime: DateFormat('HH:mm') + .format(_service.toDateTime(s.startTime) ?? DateTime.now()), + endTime: DateFormat('HH:mm') + .format(_service.toDateTime(s.endTime) ?? DateTime.now()), + createdDate: _service.toDateTime(s.createdAt)?.toIso8601String() ?? '', tipsAvailable: false, mealProvided: false, managers: [], From a10617f17d196663011736babc190eb71adb5090 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 16:10:47 -0500 Subject: [PATCH 25/53] feat(payments-repository): Refactor PaymentsRepositoryImpl to utilize DataConnectService for payment operations and simplify staff ID retrieval --- .../payments_repository_impl.dart | 98 +++---------------- 1 file changed, 13 insertions(+), 85 deletions(-) diff --git a/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart b/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart index a0791ab5..42cdb1af 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/data/repositories/payments_repository_impl.dart @@ -1,97 +1,26 @@ -import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; - +import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; -import 'package:krow_core/core.dart'; + import '../../domain/repositories/payments_repository.dart'; class PaymentsRepositoryImpl - with dc.DataErrorHandler implements PaymentsRepository { - PaymentsRepositoryImpl() : _dataConnect = dc.ExampleConnector.instance; - final dc.ExampleConnector _dataConnect; - final firebase_auth.FirebaseAuth _auth = firebase_auth.FirebaseAuth.instance; - - String? _cachedStaffId; - - Future _getStaffId() async { - // 1. Check Session Store - final dc.StaffSession? session = dc.StaffSessionStore.instance.session; - if (session?.staff?.id != null) { - return session!.staff!.id; - } - - // 2. Check Cache - if (_cachedStaffId != null) return _cachedStaffId!; - - // 3. Fetch from Data Connect using Firebase UID - final firebase_auth.User? user = _auth.currentUser; - if (user == null) { - throw const NotAuthenticatedException( - technicalMessage: 'User is not authenticated', - ); - } - - // 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 response = await _dataConnect.getStaffByUserId(userId: user.uid).execute(); - if (response.data.staffs.isNotEmpty) { - _cachedStaffId = response.data.staffs.first.id; - return _cachedStaffId!; - } - - // 4. Fallback - return user.uid; - } - - /// Helper to convert Data Connect Timestamp to DateTime - DateTime? _toDateTime(dynamic t) { - if (t == null) return null; - DateTime? dt; - if (t is DateTime) { - dt = t; - } else if (t is String) { - dt = DateTime.tryParse(t); - } else { - try { - if (t is Timestamp) { - dt = t.toDateTime(); - } - } catch (_) {} - - try { - if (dt == null && t.runtimeType.toString().contains('Timestamp')) { - dt = (t as dynamic).toDate(); - } - } catch (_) {} - - try { - dt ??= DateTime.tryParse(t.toString()); - } catch (_) {} - } - - if (dt != null) { - return DateTimeUtils.toDeviceTime(dt); - } - return null; - } + PaymentsRepositoryImpl() : _service = DataConnectService.instance; + final DataConnectService _service; @override Future getPaymentSummary() async { - return executeProtected(() async { - final String currentStaffId = await _getStaffId(); + return _service.run(() async { + final String currentStaffId = await _service.getStaffId(); // Fetch recent payments with a limit - // Note: limit is chained on the query builder - final QueryResult result = - await _dataConnect.listRecentPaymentsByStaffId( + final response = await _service.connector.listRecentPaymentsByStaffId( staffId: currentStaffId, ).limit(100).execute(); - final List payments = result.data.recentPayments; + final List payments = response.data.recentPayments; double weekly = 0; double monthly = 0; @@ -103,7 +32,7 @@ class PaymentsRepositoryImpl 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 DateTime? date = _service.toDateTime(p.invoice.issueDate) ?? _service.toDateTime(p.createdAt); final double amount = p.invoice.amount; final String? status = p.status?.stringValue; @@ -129,11 +58,10 @@ class PaymentsRepositoryImpl @override Future> getPaymentHistory(String period) async { - return executeProtected(() async { - final String currentStaffId = await _getStaffId(); + return _service.run(() async { + final String currentStaffId = await _service.getStaffId(); - final QueryResult response = - await _dataConnect + final response = await _service.connector .listRecentPaymentsByStaffId(staffId: currentStaffId) .execute(); @@ -144,7 +72,7 @@ class PaymentsRepositoryImpl assignmentId: payment.applicationId, amount: payment.invoice.amount, status: PaymentAdapter.toPaymentStatus(payment.status?.stringValue ?? 'UNKNOWN'), - paidAt: _toDateTime(payment.invoice.issueDate), + paidAt: _service.toDateTime(payment.invoice.issueDate), ); }).toList(); }); From 0fc317e1da0e66ee2e09d76ef85eddf36b73b499 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 16:19:27 -0500 Subject: [PATCH 26/53] feat(profile-repository): Refactor ProfileRepositoryImpl to utilize DataConnectService and simplify authentication handling --- .../repositories/profile_repository_impl.dart | 33 +++++-------------- .../profile/lib/src/staff_profile_module.dart | 7 +--- 2 files changed, 10 insertions(+), 30 deletions(-) diff --git a/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart b/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart index 32bbcec5..42aa3a17 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart @@ -1,4 +1,3 @@ -import 'package:firebase_auth/firebase_auth.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -15,38 +14,23 @@ 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 - with DataErrorHandler implements ProfileRepositoryInterface { /// Creates a [ProfileRepositoryImpl]. - /// - /// Requires a [ExampleConnector] from the data_connect package and [FirebaseAuth]. - const ProfileRepositoryImpl({ - required this.connector, - required this.firebaseAuth, - }); + ProfileRepositoryImpl() : _service = DataConnectService.instance; - /// The Data Connect connector used for data operations. - final ExampleConnector connector; - - /// The Firebase Auth instance. - final FirebaseAuth firebaseAuth; + final DataConnectService _service; @override Future getStaffProfile() async { - 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(); + return _service.run(() async { + final staffId = await _service.getStaffId(); + final response = await _service.connector.getStaffById(id: staffId).execute(); - if (response.data.staffs.isEmpty) { + if (response.data.staff == null) { throw const ServerException(technicalMessage: 'Staff not found'); } - final GetStaffByUserIdStaffs rawStaff = response.data.staffs.first; + final GetStaffByIdStaff rawStaff = response.data.staff!; // Map the raw data connect object to the Domain Entity return Staff( @@ -71,7 +55,8 @@ class ProfileRepositoryImpl @override Future signOut() async { try { - await firebaseAuth.signOut(); + await _service.auth.signOut(); + _service.clearCache(); } catch (e) { throw Exception('Error signing out: ${e.toString()}'); } diff --git a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart index 992c80f1..88f56cc5 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; -import 'package:firebase_auth/firebase_auth.dart'; import 'data/repositories/profile_repository_impl.dart'; import 'domain/repositories/profile_repository.dart'; @@ -25,10 +23,7 @@ class StaffProfileModule extends Module { void binds(Injector i) { // Repository implementation - delegates to data_connect i.addLazySingleton( - () => ProfileRepositoryImpl( - connector: ExampleConnector.instance, - firebaseAuth: FirebaseAuth.instance, - ), + ProfileRepositoryImpl.new, ); // Use cases - depend on repository interface From 572ade95b9b94cbe50bc6a70fce2f3bd9570cc0e Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 16:22:14 -0500 Subject: [PATCH 27/53] feat(tax-forms-repository): Refactor TaxFormsRepositoryImpl to simplify initialization and utilize DataConnectService for data operations --- .../tax_forms_repository_impl.dart | 64 ++++++------------- .../lib/src/staff_tax_forms_module.dart | 9 +-- 2 files changed, 19 insertions(+), 54 deletions(-) diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/repositories/tax_forms_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/repositories/tax_forms_repository_impl.dart index be9a1cc0..c834f02f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/repositories/tax_forms_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/data/repositories/tax_forms_repository_impl.dart @@ -1,7 +1,5 @@ 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'; @@ -10,45 +8,21 @@ import '../../domain/repositories/tax_forms_repository.dart'; import '../mappers/tax_form_mapper.dart'; class TaxFormsRepositoryImpl - with dc.DataErrorHandler implements TaxFormsRepository { - TaxFormsRepositoryImpl({ - required this.firebaseAuth, - required this.dataConnect, - }); + TaxFormsRepositoryImpl() : _service = dc.DataConnectService.instance; - final auth.FirebaseAuth firebaseAuth; - final dc.ExampleConnector dataConnect; - - /// Helper to get the logged-in staff ID. - String _getStaffId() { - final auth.User? user = firebaseAuth.currentUser; - if (user == null) { - throw const NotAuthenticatedException( - technicalMessage: 'Firebase User is null', - ); - } - - final String? staffId = dc.StaffSessionStore.instance.session?.staff?.id; - if (staffId == null || staffId.isEmpty) { - throw const StaffProfileNotFoundException( - technicalMessage: 'Staff ID missing in SessionStore', - ); - } - return staffId; - } + final dc.DataConnectService _service; @override Future> getTaxForms() async { - return executeProtected(() async { - final String staffId = _getStaffId(); - final QueryResult - result = await dataConnect + return _service.run(() async { + final String staffId = await _service.getStaffId(); + final response = await _service.connector .getTaxFormsByStaffId(staffId: staffId) .execute(); final List forms = - result.data.taxForms.map(TaxFormMapper.fromDataConnect).toList(); + response.data.taxForms.map(TaxFormMapper.fromDataConnect).toList(); // Check if required forms exist, create if not. final Set typesPresent = @@ -65,11 +39,9 @@ class TaxFormsRepositoryImpl } if (createdNew) { - final QueryResult< - dc.GetTaxFormsByStaffIdData, - dc.GetTaxFormsByStaffIdVariables> result2 = - await dataConnect.getTaxFormsByStaffId(staffId: staffId).execute(); - return result2.data.taxForms + final response2 = + await _service.connector.getTaxFormsByStaffId(staffId: staffId).execute(); + return response2.data.taxForms .map(TaxFormMapper.fromDataConnect) .toList(); } @@ -79,7 +51,7 @@ class TaxFormsRepositoryImpl } Future _createInitialForm(String staffId, TaxFormType type) async { - await dataConnect + await _service.connector .createTaxForm( staffId: staffId, formType: @@ -95,10 +67,10 @@ class TaxFormsRepositoryImpl @override Future updateI9Form(I9TaxForm form) async { - return executeProtected(() async { + return _service.run(() async { final Map data = form.formData; final dc.UpdateTaxFormVariablesBuilder builder = - dataConnect.updateTaxForm(id: form.id); + _service.connector.updateTaxForm(id: form.id); _mapCommonFields(builder, data); _mapI9Fields(builder, data); await builder.execute(); @@ -107,10 +79,10 @@ class TaxFormsRepositoryImpl @override Future submitI9Form(I9TaxForm form) async { - return executeProtected(() async { + return _service.run(() async { final Map data = form.formData; final dc.UpdateTaxFormVariablesBuilder builder = - dataConnect.updateTaxForm(id: form.id); + _service.connector.updateTaxForm(id: form.id); _mapCommonFields(builder, data); _mapI9Fields(builder, data); await builder.status(dc.TaxFormStatus.SUBMITTED).execute(); @@ -119,10 +91,10 @@ class TaxFormsRepositoryImpl @override Future updateW4Form(W4TaxForm form) async { - return executeProtected(() async { + return _service.run(() async { final Map data = form.formData; final dc.UpdateTaxFormVariablesBuilder builder = - dataConnect.updateTaxForm(id: form.id); + _service.connector.updateTaxForm(id: form.id); _mapCommonFields(builder, data); _mapW4Fields(builder, data); await builder.execute(); @@ -131,10 +103,10 @@ class TaxFormsRepositoryImpl @override Future submitW4Form(W4TaxForm form) async { - return executeProtected(() async { + return _service.run(() async { final Map data = form.formData; final dc.UpdateTaxFormVariablesBuilder builder = - dataConnect.updateTaxForm(id: form.id); + _service.connector.updateTaxForm(id: form.id); _mapCommonFields(builder, data); _mapW4Fields(builder, data); await builder.status(dc.TaxFormStatus.SUBMITTED).execute(); diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/staff_tax_forms_module.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/staff_tax_forms_module.dart index 95fdd71e..c26c007f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/staff_tax_forms_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/staff_tax_forms_module.dart @@ -1,7 +1,5 @@ -import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_domain/krow_domain.dart'; import 'data/repositories/tax_forms_repository_impl.dart'; import 'domain/repositories/tax_forms_repository.dart'; @@ -18,12 +16,7 @@ import 'presentation/pages/tax_forms_page.dart'; class StaffTaxFormsModule extends Module { @override void binds(Injector i) { - i.addLazySingleton( - () => TaxFormsRepositoryImpl( - firebaseAuth: FirebaseAuth.instance, - dataConnect: ExampleConnector.instance, - ), - ); + i.addLazySingleton(TaxFormsRepositoryImpl.new); // Use Cases i.addLazySingleton(GetTaxFormsUseCase.new); From 24a13488dabe919d5fd490c0e045d02878c99add Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 16:27:09 -0500 Subject: [PATCH 28/53] feat(certificates-documents-repositories): Refactor Certificates and Documents repositories to utilize DataConnectService and simplify dependency management --- .../certificates_repository_impl.dart | 35 +++++-------------- .../lib/src/staff_certificates_module.dart | 9 +---- .../documents_repository_impl.dart | 26 ++++---------- .../lib/src/staff_documents_module.dart | 9 +---- 4 files changed, 18 insertions(+), 61 deletions(-) diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart index 411ce9b5..f643a65d 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart @@ -1,8 +1,6 @@ -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:firebase_data_connect/firebase_data_connect.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_domain/krow_domain.dart' as domain; -import 'package:krow_core/core.dart'; import '../../domain/repositories/certificates_repository.dart'; @@ -11,37 +9,22 @@ 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 - with DataErrorHandler implements CertificatesRepository { - /// The generated Data Connect SDK client. - final ExampleConnector _dataConnect; - - /// The Firebase Authentication instance. - final FirebaseAuth _firebaseAuth; + /// The Data Connect service instance. + final DataConnectService _service; /// Creates a [CertificatesRepositoryImpl]. - /// - /// Requires [ExampleConnector] for data access and [FirebaseAuth] for user context. - CertificatesRepositoryImpl({ - required ExampleConnector dataConnect, - required FirebaseAuth firebaseAuth, - }) : _dataConnect = dataConnect, - _firebaseAuth = firebaseAuth; + CertificatesRepositoryImpl() : _service = DataConnectService.instance; @override Future> getCertificates() async { - return executeProtected(() async { - final User? currentUser = _firebaseAuth.currentUser; - if (currentUser == null) { - throw domain.NotAuthenticatedException( - technicalMessage: 'User not authenticated'); - } + return _service.run(() async { + final String staffId = await _service.getStaffId(); // Execute the query via DataConnect generated SDK - final QueryResult result = - await _dataConnect - .listStaffDocumentsByStaffId(staffId: currentUser.uid) + final result = + await _service.connector + .listStaffDocumentsByStaffId(staffId: staffId) .execute(); // Map the generated SDK types to pure Domain entities diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/staff_certificates_module.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/staff_certificates_module.dart index d9d39a6b..1d444c0b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/staff_certificates_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/staff_certificates_module.dart @@ -1,7 +1,5 @@ -import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; import 'data/repositories_impl/certificates_repository_impl.dart'; import 'domain/repositories/certificates_repository.dart'; @@ -12,12 +10,7 @@ import 'presentation/pages/certificates_page.dart'; class StaffCertificatesModule extends Module { @override void binds(Injector i) { - i.addLazySingleton( - () => CertificatesRepositoryImpl( - dataConnect: i.get(), // Assuming ExampleConnector is provided by parent module - firebaseAuth: FirebaseAuth.instance, - ), - ); + i.addLazySingleton(CertificatesRepositoryImpl.new); i.addLazySingleton(GetCertificatesUseCase.new); i.addLazySingleton(CertificatesCubit.new); } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart index 2a82c255..b72458e7 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart @@ -1,39 +1,27 @@ -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:firebase_data_connect/firebase_data_connect.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_domain/krow_domain.dart' as domain; -import 'package:krow_core/core.dart'; import '../../domain/repositories/documents_repository.dart'; /// Implementation of [DocumentsRepository] using Data Connect. class DocumentsRepositoryImpl - with DataErrorHandler implements DocumentsRepository { - final ExampleConnector _dataConnect; - final FirebaseAuth _firebaseAuth; + final DataConnectService _service; - DocumentsRepositoryImpl({ - required ExampleConnector dataConnect, - required FirebaseAuth firebaseAuth, - }) : _dataConnect = dataConnect, - _firebaseAuth = firebaseAuth; + DocumentsRepositoryImpl() : _service = DataConnectService.instance; @override Future> getDocuments() async { - return executeProtected(() async { - final User? currentUser = _firebaseAuth.currentUser; - if (currentUser == null) { - throw domain.NotAuthenticatedException( - technicalMessage: 'User not authenticated'); - } + return _service.run(() async { + final String? staffId = await _service.getStaffId(); /// MOCK IMPLEMENTATION /// To be replaced with real data connect query when available return [ domain.StaffDocument( id: 'doc1', - staffId: currentUser.uid, + staffId: staffId!, documentId: 'd1', name: 'Work Permit', description: 'Valid work permit document', @@ -43,7 +31,7 @@ class DocumentsRepositoryImpl ), domain.StaffDocument( id: 'doc2', - staffId: currentUser.uid, + staffId: staffId!, documentId: 'd2', name: 'Health and Safety Training', description: 'Certificate of completion for health and safety training', diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/staff_documents_module.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/staff_documents_module.dart index b0d63374..d1fcd11a 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/staff_documents_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/staff_documents_module.dart @@ -1,7 +1,5 @@ -import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; import 'data/repositories_impl/documents_repository_impl.dart'; import 'domain/repositories/documents_repository.dart'; import 'domain/usecases/get_documents_usecase.dart'; @@ -11,12 +9,7 @@ import 'presentation/pages/documents_page.dart'; class StaffDocumentsModule extends Module { @override void binds(Injector i) { - i.addLazySingleton( - () => DocumentsRepositoryImpl( - dataConnect: ExampleConnector.instance, - firebaseAuth: FirebaseAuth.instance, - ), - ); + i.addLazySingleton(DocumentsRepositoryImpl.new); i.addLazySingleton(GetDocumentsUseCase.new); i.addLazySingleton(DocumentsCubit.new); } From 8889b8876e08255b87ffd68bad6fce4b81067634 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 16:35:26 -0500 Subject: [PATCH 29/53] feat(time-card-repository): Refactor TimeCardRepositoryImpl to utilize DataConnectService and simplify authentication handling --- .../time_card_repository_impl.dart | 66 +++++++------------ .../lib/src/staff_time_card_module.dart | 11 +--- 2 files changed, 28 insertions(+), 49 deletions(-) diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/data/repositories_impl/time_card_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/data/repositories_impl/time_card_repository_impl.dart index 15823f5b..eee89873 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/data/repositories_impl/time_card_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/data/repositories_impl/time_card_repository_impl.dart @@ -1,63 +1,46 @@ -import 'package:firebase_auth/firebase_auth.dart' as firebase; import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; import 'package:intl/intl.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart'; // ignore: implementation_imports import 'package:krow_domain/src/adapters/financial/time_card_adapter.dart'; -import 'package:krow_core/core.dart'; import '../../domain/repositories/time_card_repository.dart'; /// Implementation of [TimeCardRepository] using Firebase Data Connect. -class TimeCardRepositoryImpl - with dc.DataErrorHandler - implements TimeCardRepository { - final dc.ExampleConnector _dataConnect; - final firebase.FirebaseAuth _firebaseAuth; +class TimeCardRepositoryImpl implements TimeCardRepository { + final dc.DataConnectService _service; /// Creates a [TimeCardRepositoryImpl]. - TimeCardRepositoryImpl({ - required dc.ExampleConnector dataConnect, - required firebase.FirebaseAuth firebaseAuth, - }) : _dataConnect = dataConnect, - _firebaseAuth = firebaseAuth; - - Future _getStaffId() async { - final firebase.User? user = _firebaseAuth.currentUser; - if (user == null) { - throw const NotAuthenticatedException( - technicalMessage: 'User not authenticated'); - } - - final fdc.QueryResult result = - await _dataConnect.getStaffByUserId(userId: user.uid).execute(); - if (result.data.staffs.isEmpty) { - throw const ServerException(technicalMessage: 'Staff profile not found'); - } - return result.data.staffs.first.id; - } + TimeCardRepositoryImpl({dc.DataConnectService? service}) + : _service = service ?? dc.DataConnectService.instance; @override Future> getTimeCards(DateTime month) async { - return executeProtected(() async { - final String staffId = await _getStaffId(); + return _service.run(() async { + final String staffId = await _service.getStaffId(); // Fetch applications. Limit can be adjusted, assuming 100 is safe for now. - final fdc.QueryResult result = - await _dataConnect.getApplicationsByStaffId(staffId: staffId).limit(100).execute(); + final fdc.QueryResult result = + await _service.connector + .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()); + final DateTime? shiftDate = _service.toDateTime(app.shift.date); if (shiftDate == null) return false; - return shiftDate.year == month.year && shiftDate.month == month.month; + 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) ?? ''; + final DateTime shiftDate = _service.toDateTime(app.shift.date)!; + 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; @@ -84,7 +67,8 @@ class TimeCardRepositoryImpl String? _formatTime(fdc.Timestamp? timestamp) { if (timestamp == null) return null; - return DateFormat('HH:mm') - .format(DateTimeUtils.toDeviceTime(timestamp.toDateTime())); + final DateTime? dt = _service.toDateTime(timestamp); + if (dt == null) return null; + return DateFormat('HH:mm').format(dt); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/staff_time_card_module.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/staff_time_card_module.dart index b47632a6..59ff493b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/staff_time_card_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/staff_time_card_module.dart @@ -1,6 +1,6 @@ library staff_time_card; -import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; @@ -24,12 +24,7 @@ class StaffTimeCardModule extends Module { @override void binds(Injector i) { // Repositories - i.add( - () => TimeCardRepositoryImpl( - dataConnect: ExampleConnector.instance, - firebaseAuth: FirebaseAuth.instance, - ), - ); + i.addLazySingleton(TimeCardRepositoryImpl.new); // UseCases i.add(GetTimeCardsUseCase.new); @@ -42,7 +37,7 @@ class StaffTimeCardModule extends Module { void routes(RouteManager r) { r.child( StaffPaths.childRoute(StaffPaths.timeCard, StaffPaths.timeCard), - child: (context) => const TimeCardPage(), + child: (BuildContext context) => const TimeCardPage(), ); } } From 3c5987bde4ab0a9a0fa6d6040bc95345a9324f08 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 16:43:00 -0500 Subject: [PATCH 30/53] feat(bank-account-repository): Refactor BankAccountRepositoryImpl to utilize DataConnectService and remove FirebaseAuth dependency --- .../bank_account_repository_impl.dart | 78 ++++++++----------- .../lib/src/staff_bank_account_module.dart | 11 +-- 2 files changed, 35 insertions(+), 54 deletions(-) diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/data/repositories/bank_account_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/data/repositories/bank_account_repository_impl.dart index e2957389..14614b66 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/data/repositories/bank_account_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/data/repositories/bank_account_repository_impl.dart @@ -1,34 +1,31 @@ -import 'package:firebase_auth/firebase_auth.dart' as auth; import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_domain/krow_domain.dart'; import '../../domain/repositories/bank_account_repository.dart'; /// Implementation of [BankAccountRepository] that integrates with Data Connect. -class BankAccountRepositoryImpl - with DataErrorHandler - implements BankAccountRepository { +class BankAccountRepositoryImpl implements BankAccountRepository { /// Creates a [BankAccountRepositoryImpl]. - const BankAccountRepositoryImpl({ - required this.dataConnect, - required this.firebaseAuth, - }); + BankAccountRepositoryImpl({ + DataConnectService? service, + }) : _service = service ?? DataConnectService.instance; - /// The Data Connect instance. - final ExampleConnector dataConnect; - /// The Firebase Auth instance. - final auth.FirebaseAuth firebaseAuth; + /// The Data Connect service. + final DataConnectService _service; @override Future> getAccounts() async { - return executeProtected(() async { - final String staffId = _getStaffId(); - + return _service.run(() async { + final String staffId = await _service.getStaffId(); + + var x = staffId; + + print(x); final QueryResult - result = await dataConnect + result = await _service.connector .getAccountsByOwnerId(ownerId: staffId) .execute(); - + return result.data.accounts.map((GetAccountsByOwnerIdAccounts account) { return BankAccountAdapter.fromPrimitives( id: account.id, @@ -37,7 +34,9 @@ class BankAccountRepositoryImpl accountNumber: account.accountNumber, last4: account.last4, sortCode: account.routeNumber, - type: account.type is Known ? (account.type as Known).value.name : null, + type: account.type is Known + ? (account.type as Known).value.name + : null, isPrimary: account.isPrimary, ); }).toList(); @@ -46,44 +45,31 @@ class BankAccountRepositoryImpl @override Future addAccount(BankAccount account) async { - return executeProtected(() async { - final String staffId = _getStaffId(); + return _service.run(() async { + final String staffId = await _service.getStaffId(); final QueryResult - existingAccounts = await dataConnect + existingAccounts = await _service.connector .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 _service.connector + .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 const NotAuthenticatedException( - technicalMessage: 'User not authenticated'); - } - - final String? staffId = StaffSessionStore.instance.session?.staff?.id; - if (staffId == null || staffId.isEmpty) { - throw const ServerException(technicalMessage: 'Staff profile is missing or session not initialized.'); - } - return staffId; - } - /// Ensures we have a last4 value, either from input or derived from account number. String _safeLast4(String? last4, String accountNumber) { if (last4 != null && last4.isNotEmpty) { diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/staff_bank_account_module.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/staff_bank_account_module.dart index 2312e299..93e7d69d 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/staff_bank_account_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/staff_bank_account_module.dart @@ -1,4 +1,4 @@ -import 'package:firebase_auth/firebase_auth.dart' as auth; +import 'package:flutter/widgets.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; @@ -17,12 +17,7 @@ class StaffBankAccountModule extends Module { @override void binds(Injector i) { // Repositories - i.addLazySingleton( - () => BankAccountRepositoryImpl( - firebaseAuth: auth.FirebaseAuth.instance, - dataConnect: ExampleConnector.instance, - ), - ); + i.addLazySingleton(BankAccountRepositoryImpl.new); // Use Cases i.addLazySingleton(GetBankAccountsUseCase.new); @@ -41,7 +36,7 @@ class StaffBankAccountModule extends Module { void routes(RouteManager r) { r.child( StaffPaths.childRoute(StaffPaths.bankAccount, StaffPaths.bankAccount), - child: (_) => const BankAccountPage(), + child: (BuildContext context) => const BankAccountPage(), ); } } From 17423c5d66c05faf4e3a220976be37e7b6533072 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 16:54:20 -0500 Subject: [PATCH 31/53] feat: Refactor repositories to utilize DataConnectService and remove FirebaseAuth dependency --- .../attire/lib/src/attire_module.dart | 7 +-- .../attire_repository_impl.dart | 30 +++++++------ .../emergency_contact_repository_impl.dart | 42 +++++------------- .../src/staff_emergency_contact_module.dart | 8 +--- .../experience_repository_impl.dart | 43 +++++++------------ .../lib/staff_profile_experience.dart | 6 +-- .../personal_info_repository_impl.dart | 41 ++++++++---------- .../lib/src/staff_profile_info_module.dart | 8 +--- 8 files changed, 69 insertions(+), 116 deletions(-) diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart index 7da0bc6a..7937e0c1 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart @@ -1,6 +1,5 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; import 'data/repositories_impl/attire_repository_impl.dart'; import 'domain/repositories/attire_repository.dart'; @@ -14,10 +13,8 @@ class StaffAttireModule extends Module { @override void binds(Injector i) { // Repository - i.addLazySingleton( - () => AttireRepositoryImpl(ExampleConnector.instance), - ); - + i.addLazySingleton(AttireRepositoryImpl.new); + // Use Cases i.addLazySingleton(GetAttireOptionsUseCase.new); i.addLazySingleton(SaveAttireUseCase.new); diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart index aec7ee03..cff32f53 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart @@ -6,24 +6,30 @@ import '../../domain/repositories/attire_repository.dart'; /// Implementation of [AttireRepository]. /// -/// Delegates data access to [ExampleConnector] from `data_connect`. +/// Delegates data access to [DataConnectService]. class AttireRepositoryImpl implements AttireRepository { - /// The Data Connect connector instance. - final ExampleConnector _connector; + /// The Data Connect service. + final DataConnectService _service; /// Creates an [AttireRepositoryImpl]. - AttireRepositoryImpl(this._connector); + AttireRepositoryImpl({DataConnectService? service}) + : _service = service ?? DataConnectService.instance; @override Future> getAttireOptions() async { - final QueryResult result = await _connector.listAttireOptions().execute(); - return result.data.attireOptions.map((ListAttireOptionsAttireOptions e) => AttireItem( - id: e.itemId, - label: e.label, - iconName: e.icon, - imageUrl: e.imageUrl, - isMandatory: e.isMandatory ?? false, - )).toList(); + return _service.run(() async { + final QueryResult result = + await _service.connector.listAttireOptions().execute(); + return result.data.attireOptions + .map((ListAttireOptionsAttireOptions e) => AttireItem( + id: e.itemId, + label: e.label, + iconName: e.icon, + imageUrl: e.imageUrl, + isMandatory: e.isMandatory ?? false, + )) + .toList(); + }); } @override diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/data/repositories/emergency_contact_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/data/repositories/emergency_contact_repository_impl.dart index c3ec4792..afea63f9 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/data/repositories/emergency_contact_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/data/repositories/emergency_contact_repository_impl.dart @@ -1,4 +1,3 @@ -import 'package:firebase_auth/firebase_auth.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart'; import '../../domain/repositories/emergency_contact_repository_interface.dart'; @@ -7,38 +6,19 @@ 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; + final dc.DataConnectService _service; /// Creates an [EmergencyContactRepositoryImpl]. EmergencyContactRepositoryImpl({ - required dc.ExampleConnector dataConnect, - required FirebaseAuth firebaseAuth, - }) : _dataConnect = dataConnect, - _firebaseAuth = firebaseAuth; - - Future _getStaffId() async { - final user = _firebaseAuth.currentUser; - 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 const ServerException(technicalMessage: 'Staff profile not found'); - } - return result.data.staffs.first.id; - } + dc.DataConnectService? service, + }) : _service = service ?? dc.DataConnectService.instance; @override Future> getContacts() async { - return executeProtected(() async { - final staffId = await _getStaffId(); - final result = await _dataConnect + return _service.run(() async { + final staffId = await _service.getStaffId(); + final result = await _service.connector .getEmergencyContactsByStaffId(staffId: staffId) .execute(); @@ -55,11 +35,11 @@ class EmergencyContactRepositoryImpl @override Future saveContacts(List contacts) async { - return executeProtected(() async { - final staffId = await _getStaffId(); + return _service.run(() async { + final staffId = await _service.getStaffId(); // 1. Get existing to delete - final existingResult = await _dataConnect + final existingResult = await _service.connector .getEmergencyContactsByStaffId(staffId: staffId) .execute(); final existingIds = @@ -67,7 +47,7 @@ class EmergencyContactRepositoryImpl // 2. Delete all existing await Future.wait(existingIds.map( - (id) => _dataConnect.deleteEmergencyContact(id: id).execute())); + (id) => _service.connector.deleteEmergencyContact(id: id).execute())); // 3. Create new await Future.wait(contacts.map((contact) { @@ -87,7 +67,7 @@ class EmergencyContactRepositoryImpl break; } - return _dataConnect + return _service.connector .createEmergencyContact( name: contact.name, phone: contact.phone, diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/staff_emergency_contact_module.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/staff_emergency_contact_module.dart index 5dfb7a30..3f7bea36 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/staff_emergency_contact_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/staff_emergency_contact_module.dart @@ -1,6 +1,5 @@ -import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; + import 'data/repositories/emergency_contact_repository_impl.dart'; import 'domain/repositories/emergency_contact_repository_interface.dart'; import 'domain/usecases/get_emergency_contacts_usecase.dart'; @@ -13,10 +12,7 @@ class StaffEmergencyContactModule extends Module { void binds(Injector i) { // Repository i.addLazySingleton( - () => EmergencyContactRepositoryImpl( - dataConnect: ExampleConnector.instance, - firebaseAuth: FirebaseAuth.instance, - ), + EmergencyContactRepositoryImpl.new, ); // UseCases diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/data/repositories/experience_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/data/repositories/experience_repository_impl.dart index 159dd31f..4b104d82 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/data/repositories/experience_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/data/repositories/experience_repository_impl.dart @@ -1,42 +1,31 @@ -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'; +import '../../domain/repositories/experience_repository_interface.dart'; + /// Implementation of [ExperienceRepositoryInterface] that delegates to Data Connect. -class ExperienceRepositoryImpl - with dc.DataErrorHandler - implements ExperienceRepositoryInterface { - final dc.ExampleConnector _dataConnect; - // ignore: unused_field - final FirebaseAuth _firebaseAuth; +class ExperienceRepositoryImpl implements ExperienceRepositoryInterface { + final dc.DataConnectService _service; - /// Creates a [ExperienceRepositoryImpl] using Data Connect and Auth. + /// Creates a [ExperienceRepositoryImpl] using Data Connect Service. ExperienceRepositoryImpl({ - required dc.ExampleConnector dataConnect, - required FirebaseAuth firebaseAuth, - }) : _dataConnect = dataConnect, - _firebaseAuth = firebaseAuth; + dc.DataConnectService? service, + }) : _service = service ?? dc.DataConnectService.instance; - Future _getStaff() async { - final user = _firebaseAuth.currentUser; - if (user == null) { - throw const NotAuthenticatedException( - technicalMessage: 'User not authenticated'); - } + Future _getStaff() async { + final staffId = await _service.getStaffId(); final result = - await _dataConnect.getStaffByUserId(userId: user.uid).execute(); - if (result.data.staffs.isEmpty) { + await _service.connector.getStaffById(id: staffId).execute(); + if (result.data.staff == null) { throw const ServerException(technicalMessage: 'Staff profile not found'); } - return result.data.staffs.first; + return result.data.staff!; } @override Future> getIndustries() async { - return executeProtected(() async { + return _service.run(() async { final staff = await _getStaff(); return staff.industries ?? []; }); @@ -44,7 +33,7 @@ class ExperienceRepositoryImpl @override Future> getSkills() async { - return executeProtected(() async { + return _service.run(() async { final staff = await _getStaff(); return staff.skills ?? []; }); @@ -55,9 +44,9 @@ class ExperienceRepositoryImpl List industries, List skills, ) async { - return executeProtected(() async { + return _service.run(() async { final staff = await _getStaff(); - await _dataConnect + await _service.connector .updateStaff(id: staff.id) .industries(industries) .skills(skills) diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/staff_profile_experience.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/staff_profile_experience.dart index ab4c83e9..db83d59f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/staff_profile_experience.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/staff_profile_experience.dart @@ -1,6 +1,5 @@ library staff_profile_experience; -import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; @@ -22,10 +21,7 @@ class StaffProfileExperienceModule extends Module { void binds(Injector i) { // Repository i.addLazySingleton( - () => ExperienceRepositoryImpl( - dataConnect: ExampleConnector.instance, - firebaseAuth: FirebaseAuth.instance, - ), + ExperienceRepositoryImpl.new, ); // UseCases diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/data/repositories/personal_info_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/data/repositories/personal_info_repository_impl.dart index e2e4b5ba..439a3ba2 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/data/repositories/personal_info_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/data/repositories/personal_info_repository_impl.dart @@ -1,4 +1,3 @@ -import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -14,32 +13,24 @@ import '../../domain/repositories/personal_info_repository_interface.dart'; /// - Mapping between data_connect DTOs and domain entities /// - Containing no business logic class PersonalInfoRepositoryImpl - with DataErrorHandler implements PersonalInfoRepositoryInterface { - /// Creates a [PersonalInfoRepositoryImpl]. /// - /// Requires the Firebase Data Connect connector instance and Firebase Auth. + /// Requires the Firebase Data Connect service. PersonalInfoRepositoryImpl({ - required ExampleConnector dataConnect, - required firebase_auth.FirebaseAuth firebaseAuth, - }) : _dataConnect = dataConnect, - _firebaseAuth = firebaseAuth; - final ExampleConnector _dataConnect; - final firebase_auth.FirebaseAuth _firebaseAuth; + DataConnectService? service, + }) : _service = service ?? DataConnectService.instance; + + final DataConnectService _service; @override Future getStaffProfile() async { - return executeProtected(() async { - final firebase_auth.User? user = _firebaseAuth.currentUser; - if (user == null) { - throw NotAuthenticatedException( - technicalMessage: 'User not authenticated'); - } + return _service.run(() async { + final String uid = _service.auth.currentUser!.uid; // Query staff data from Firebase Data Connect final QueryResult result = - await _dataConnect.getStaffByUserId(userId: user.uid).execute(); + await _service.connector.getStaffByUserId(userId: uid).execute(); if (result.data.staffs.isEmpty) { throw const ServerException(technicalMessage: 'Staff profile not found'); @@ -53,10 +44,12 @@ class PersonalInfoRepositoryImpl } @override - Future updateStaffProfile({required String staffId, required Map data}) async { - return executeProtected(() async { + Future updateStaffProfile( + {required String staffId, required Map data}) async { + return _service.run(() async { // Start building the update mutation - UpdateStaffVariablesBuilder updateBuilder = _dataConnect.updateStaff(id: staffId); + UpdateStaffVariablesBuilder updateBuilder = + _service.connector.updateStaff(id: staffId); // Apply updates from map if present if (data.containsKey('name')) { @@ -72,8 +65,9 @@ class PersonalInfoRepositoryImpl updateBuilder = updateBuilder.photoUrl(data['avatar'] as String?); } if (data.containsKey('preferredLocations')) { - // After schema update and SDK regeneration, preferredLocations accepts List - updateBuilder = updateBuilder.preferredLocations(data['preferredLocations'] as List); + // After schema update and SDK regeneration, preferredLocations accepts List + updateBuilder = updateBuilder.preferredLocations( + data['preferredLocations'] as List); } // Execute the update @@ -81,7 +75,8 @@ class PersonalInfoRepositoryImpl await updateBuilder.execute(); if (result.data.staff_update == null) { - throw const ServerException(technicalMessage: 'Failed to update staff profile'); + throw const ServerException( + technicalMessage: 'Failed to update staff profile'); } // Fetch the updated staff profile to return complete entity diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/staff_profile_info_module.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/staff_profile_info_module.dart index 984d010a..47c80748 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/staff_profile_info_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/staff_profile_info_module.dart @@ -1,7 +1,5 @@ -import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; import 'data/repositories/personal_info_repository_impl.dart'; import 'domain/repositories/personal_info_repository_interface.dart'; @@ -25,11 +23,7 @@ class StaffProfileInfoModule extends Module { void binds(Injector i) { // Repository i.addLazySingleton( - () => PersonalInfoRepositoryImpl( - dataConnect: ExampleConnector.instance, - firebaseAuth: FirebaseAuth.instance, - ), - ); + PersonalInfoRepositoryImpl.new); // Use Cases - delegate business logic to repository i.addLazySingleton( From d2cb05fe2e51e043fed73774f307255d26e867c5 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 16:54:59 -0500 Subject: [PATCH 32/53] fix: Update documentation to reflect correct backend communication via DataConnectService --- .../data/repositories_impl/certificates_repository_impl.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart index f643a65d..dfb7e44e 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart @@ -6,7 +6,7 @@ import '../../domain/repositories/certificates_repository.dart'; /// Implementation of [CertificatesRepository] using Data Connect. /// -/// This class handles the communication with the backend via [ExampleConnector]. +/// This class handles the communication with the backend via [DataConnectService]. /// It maps raw generated data types to clean [domain.StaffDocument] entities. class CertificatesRepositoryImpl implements CertificatesRepository { From fdd40ba72c51e70091a0e55742370a71b38699d3 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 17:16:29 -0500 Subject: [PATCH 33/53] feat(data-connect): Implement caching for business ID and enhance error handling in DataConnectService --- .../src/services/data_connect_service.dart | 39 ++- .../billing/lib/src/billing_module.dart | 7 +- .../billing_repository_impl.dart | 255 +++++++++--------- 3 files changed, 154 insertions(+), 147 deletions(-) diff --git a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart index c91c34d1..bad4b174 100644 --- a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart +++ b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart @@ -27,6 +27,9 @@ class DataConnectService with DataErrorHandler { /// Cache for the current staff ID to avoid redundant lookups. String? _cachedStaffId; + /// Cache for the current business ID to avoid redundant lookups. + String? _cachedBusinessId; + /// Gets the current staff ID from session store or persistent storage. Future getStaffId() async { // 1. Check Session Store @@ -41,15 +44,14 @@ class DataConnectService with DataErrorHandler { // 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 fdc.QueryResult< - dc.GetStaffByUserIdData, - dc.GetStaffByUserIdVariables - > - response = await executeProtected( + final fdc.QueryResult + response = await executeProtected( () => connector.getStaffByUserId(userId: user.uid).execute(), ); @@ -65,6 +67,30 @@ class DataConnectService with DataErrorHandler { return user.uid; } + /// Gets the current business ID from session store or persistent storage. + Future getBusinessId() async { + // 1. Check Session Store + final dc.ClientSession? session = dc.ClientSessionStore.instance.session; + if (session?.business?.id != null) { + return session!.business!.id; + } + + // 2. Check Cache + if (_cachedBusinessId != null) return _cachedBusinessId!; + + // 3. Check Auth Status + final firebase_auth.User? user = _auth.currentUser; + if (user == null) { + throw const NotAuthenticatedException( + technicalMessage: 'User is not authenticated', + ); + } + + // 4. Fallback (should ideally not happen if DB is seeded and session is initialized) + // Ideally we'd have a getBusinessByUserId query here. + return user.uid; + } + /// Converts a Data Connect timestamp/string/json to a [DateTime]. DateTime? toDateTime(dynamic t) { if (t == null) return null; @@ -116,5 +142,6 @@ class DataConnectService with DataErrorHandler { /// Clears the internal cache (e.g., on logout). void clearCache() { _cachedStaffId = null; + _cachedBusinessId = null; } } diff --git a/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart b/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart index a8591d07..8c639cb3 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart @@ -1,6 +1,5 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; import 'data/repositories_impl/billing_repository_impl.dart'; import 'domain/repositories/billing_repository.dart'; @@ -19,11 +18,7 @@ class BillingModule extends Module { // Repositories - i.addSingleton( - () => BillingRepositoryImpl( - dataConnect: ExampleConnector.instance, - ), - ); + i.addSingleton(BillingRepositoryImpl.new); // Use Cases i.addSingleton(GetCurrentBillAmountUseCase.new); diff --git a/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart b/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart index 689ac4bf..d0441b26 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart @@ -6,88 +6,78 @@ import '../../domain/repositories/billing_repository.dart'; /// Implementation of [BillingRepository] in the Data layer. /// -/// This class is responsible for retrieving billing data from the [FinancialRepositoryMock] -/// (which represents the Data Connect layer) and mapping it to Domain entities. -/// -/// It strictly adheres to the Clean Architecture data layer responsibilities: -/// - No business logic (except necessary data transformation/filtering). -/// - Delegates to data sources. -class BillingRepositoryImpl - with data_connect.DataErrorHandler - implements BillingRepository { +/// This class is responsible for retrieving billing data from the +/// Data Connect layer and mapping it to Domain entities. +class BillingRepositoryImpl implements BillingRepository { /// Creates a [BillingRepositoryImpl]. - /// - /// Requires the [financialRepository] to fetch financial data. BillingRepositoryImpl({ - required data_connect.ExampleConnector dataConnect, - }) : _dataConnect = dataConnect; + data_connect.DataConnectService? service, + }) : _service = service ?? data_connect.DataConnectService.instance; - final data_connect.ExampleConnector _dataConnect; + final data_connect.DataConnectService _service; /// Fetches the current bill amount by aggregating open invoices. @override - @override Future getCurrentBillAmount() async { - final String? businessId = - data_connect.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - return 0.0; - } + return _service.run(() async { + final String businessId = await _service.getBusinessId(); - final fdc.QueryResult result = await executeProtected(() => _dataConnect - .listInvoicesByBusinessId(businessId: businessId) - .execute()); + final fdc.QueryResult result = + await _service.connector + .listInvoicesByBusinessId(businessId: businessId) + .execute(); - return result.data.invoices - .map(_mapInvoice) - .where((Invoice i) => i.status == InvoiceStatus.open) - .fold( - 0.0, - (double sum, Invoice item) => sum + item.totalAmount, - ); + return result.data.invoices + .map(_mapInvoice) + .where((Invoice i) => i.status == InvoiceStatus.open) + .fold( + 0.0, + (double sum, Invoice item) => sum + item.totalAmount, + ); + }); } /// Fetches the history of paid invoices. @override Future> getInvoiceHistory() async { - final String? businessId = - data_connect.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - return []; - } + return _service.run(() async { + final String businessId = await _service.getBusinessId(); - final fdc.QueryResult result = await executeProtected(() => _dataConnect - .listInvoicesByBusinessId( - businessId: businessId, - ) - .limit(10) - .execute()); + final fdc.QueryResult result = + await _service.connector + .listInvoicesByBusinessId( + businessId: businessId, + ) + .limit(10) + .execute(); - return result.data.invoices.map(_mapInvoice).toList(); + return result.data.invoices.map(_mapInvoice).toList(); + }); } /// Fetches pending invoices (Open or Disputed). @override - @override Future> getPendingInvoices() async { - final String? businessId = - data_connect.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - return []; - } + return _service.run(() async { + final String businessId = await _service.getBusinessId(); - final fdc.QueryResult result = await executeProtected(() => _dataConnect - .listInvoicesByBusinessId(businessId: businessId) - .execute()); + final fdc.QueryResult result = + await _service.connector + .listInvoicesByBusinessId(businessId: businessId) + .execute(); - return result.data.invoices - .map(_mapInvoice) - .where( - (Invoice i) => - i.status == InvoiceStatus.open || - i.status == InvoiceStatus.disputed, - ) - .toList(); + return result.data.invoices + .map(_mapInvoice) + .where( + (Invoice i) => + i.status == InvoiceStatus.open || + i.status == InvoiceStatus.disputed, + ) + .toList(); + }); } /// Fetches the estimated savings amount. @@ -101,86 +91,81 @@ class BillingRepositoryImpl /// Fetches the breakdown of spending. @override Future> getSpendingBreakdown(BillingPeriod period) async { - final String? businessId = - data_connect.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - return []; - } + return _service.run(() async { + final String businessId = await _service.getBusinessId(); - final DateTime now = DateTime.now(); - final DateTime start; - final DateTime end; - if (period == BillingPeriod.week) { - final int daysFromMonday = now.weekday - DateTime.monday; - final DateTime monday = DateTime( - now.year, - now.month, - now.day, - ).subtract(Duration(days: daysFromMonday)); - start = DateTime(monday.year, monday.month, monday.day); - end = DateTime(monday.year, monday.month, monday.day + 6, 23, 59, 59, 999); - } else { - start = DateTime(now.year, now.month, 1); - end = DateTime(now.year, now.month + 1, 0, 23, 59, 59, 999); - } - - final fdc.QueryResult result = await executeProtected(() => _dataConnect - .listShiftRolesByBusinessAndDatesSummary( - businessId: businessId, - start: _toTimestamp(start), - end: _toTimestamp(end), - ) - .execute()); - - final List - shiftRoles = result.data.shiftRoles; - if (shiftRoles.isEmpty) { - return []; - } - - final Map summary = {}; - for (final data_connect.ListShiftRolesByBusinessAndDatesSummaryShiftRoles role - in shiftRoles) { - final String roleId = role.roleId; - final String roleName = role.role.name; - final double hours = role.hours ?? 0.0; - final double totalValue = role.totalValue ?? 0.0; - final _RoleSummary? existing = summary[roleId]; - if (existing == null) { - summary[roleId] = _RoleSummary( - roleId: roleId, - roleName: roleName, - totalHours: hours, - totalValue: totalValue, - ); + final DateTime now = DateTime.now(); + final DateTime start; + final DateTime end; + if (period == BillingPeriod.week) { + final int daysFromMonday = now.weekday - DateTime.monday; + final DateTime monday = DateTime( + now.year, + now.month, + now.day, + ).subtract(Duration(days: daysFromMonday)); + start = DateTime(monday.year, monday.month, monday.day); + end = DateTime( + monday.year, monday.month, monday.day + 6, 23, 59, 59, 999); } else { - summary[roleId] = existing.copyWith( - totalHours: existing.totalHours + hours, - totalValue: existing.totalValue + totalValue, - ); + start = DateTime(now.year, now.month, 1); + end = DateTime(now.year, now.month + 1, 0, 23, 59, 59, 999); } - } - return summary.values - .map( - (_RoleSummary item) => InvoiceItem( - id: item.roleId, - invoiceId: item.roleId, - staffId: item.roleName, - workHours: item.totalHours, - rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0, - amount: item.totalValue, - ), - ) - .toList(); - } + final fdc.QueryResult< + data_connect.ListShiftRolesByBusinessAndDatesSummaryData, + data_connect.ListShiftRolesByBusinessAndDatesSummaryVariables> + result = await _service.connector + .listShiftRolesByBusinessAndDatesSummary( + businessId: businessId, + start: _service.toTimestamp(start), + end: _service.toTimestamp(end), + ) + .execute(); - fdc.Timestamp _toTimestamp(DateTime dateTime) { - final DateTime utc = dateTime.toUtc(); - final int seconds = utc.millisecondsSinceEpoch ~/ 1000; - final int nanoseconds = - (utc.millisecondsSinceEpoch % 1000) * 1000000; - return fdc.Timestamp(nanoseconds, seconds); + final List + shiftRoles = result.data.shiftRoles; + if (shiftRoles.isEmpty) { + return []; + } + + final Map summary = {}; + for (final data_connect + .ListShiftRolesByBusinessAndDatesSummaryShiftRoles role + in shiftRoles) { + final String roleId = role.roleId; + final String roleName = role.role.name; + final double hours = role.hours ?? 0.0; + final double totalValue = role.totalValue ?? 0.0; + final _RoleSummary? existing = summary[roleId]; + if (existing == null) { + summary[roleId] = _RoleSummary( + roleId: roleId, + roleName: roleName, + totalHours: hours, + totalValue: totalValue, + ); + } else { + summary[roleId] = existing.copyWith( + totalHours: existing.totalHours + hours, + totalValue: existing.totalValue + totalValue, + ); + } + } + + return summary.values + .map( + (_RoleSummary item) => InvoiceItem( + id: item.roleId, + invoiceId: item.roleId, + staffId: item.roleName, + workHours: item.totalHours, + rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0, + amount: item.totalValue, + ), + ) + .toList(); + }); } Invoice _mapInvoice(data_connect.ListInvoicesByBusinessIdInvoices invoice) { @@ -193,7 +178,7 @@ class BillingRepositoryImpl workAmount: invoice.amount, addonsAmount: invoice.otherCharges ?? 0, invoiceNumber: invoice.invoiceNumber, - issueDate: invoice.issueDate.toDateTime(), + issueDate: _service.toDateTime(invoice.issueDate)!, ); } From fc0bb5828c024a86641ebe992063af92409d07c9 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 17:21:03 -0500 Subject: [PATCH 34/53] feat(auth-repository): Refactor AuthRepositoryImpl to remove FirebaseAuth dependency and utilize DataConnectService --- .../lib/client_authentication.dart | 8 +- .../auth_repository_impl.dart | 155 ++++++++++-------- 2 files changed, 91 insertions(+), 72 deletions(-) diff --git a/apps/mobile/packages/features/client/authentication/lib/client_authentication.dart b/apps/mobile/packages/features/client/authentication/lib/client_authentication.dart index 12310fde..1ee73543 100644 --- a/apps/mobile/packages/features/client/authentication/lib/client_authentication.dart +++ b/apps/mobile/packages/features/client/authentication/lib/client_authentication.dart @@ -1,6 +1,5 @@ library; -import 'package:firebase_auth/firebase_auth.dart' as firebase; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; @@ -28,12 +27,7 @@ class ClientAuthenticationModule extends Module { @override void binds(Injector i) { // Repositories - i.addLazySingleton( - () => AuthRepositoryImpl( - firebaseAuth: firebase.FirebaseAuth.instance, - dataConnect: ExampleConnector.instance, - ), - ); + i.addLazySingleton(AuthRepositoryImpl.new); // UseCases i.addLazySingleton( diff --git a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index f9ea9264..467a7c07 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -12,7 +12,6 @@ import 'package:krow_domain/krow_domain.dart' AccountExistsException, UserNotFoundException, UnauthorizedAppException, - UnauthorizedAppException, PasswordMismatchException, NetworkException; import 'package:krow_domain/krow_domain.dart' as domain; @@ -23,18 +22,13 @@ import '../../domain/repositories/auth_repository_interface.dart'; /// /// This implementation integrates with Firebase Authentication for user /// identity management and Krow's Data Connect SDK for storing user profile data. -class AuthRepositoryImpl - with dc.DataErrorHandler - implements AuthRepositoryInterface { - +class AuthRepositoryImpl implements AuthRepositoryInterface { /// Creates an [AuthRepositoryImpl] with the real dependencies. AuthRepositoryImpl({ - required firebase.FirebaseAuth firebaseAuth, - required dc.ExampleConnector dataConnect, - }) : _firebaseAuth = firebaseAuth, - _dataConnect = dataConnect; - final firebase.FirebaseAuth _firebaseAuth; - final dc.ExampleConnector _dataConnect; + dc.DataConnectService? service, + }) : _service = service ?? dc.DataConnectService.instance; + + final dc.DataConnectService _service; @override Future signInWithEmail({ @@ -42,7 +36,8 @@ class AuthRepositoryImpl required String password, }) async { try { - final firebase.UserCredential credential = await _firebaseAuth.signInWithEmailAndPassword( + final firebase.UserCredential credential = + await _service.auth.signInWithEmailAndPassword( email: email, password: password, ); @@ -59,7 +54,6 @@ class AuthRepositoryImpl fallbackEmail: firebaseUser.email ?? email, requireBusinessRole: true, ); - } on firebase.FirebaseAuthException catch (e) { if (e.code == 'invalid-credential' || e.code == 'wrong-password') { throw InvalidCredentialsException( @@ -94,7 +88,8 @@ class AuthRepositoryImpl try { // Step 1: Try to create Firebase Auth user - final firebase.UserCredential credential = await _firebaseAuth.createUserWithEmailAndPassword( + final firebase.UserCredential credential = + await _service.auth.createUserWithEmailAndPassword( email: email, password: password, ); @@ -111,9 +106,9 @@ class AuthRepositoryImpl firebaseUser: firebaseUser, companyName: companyName, email: email, - onBusinessCreated: (String businessId) => createdBusinessId = businessId, + onBusinessCreated: (String businessId) => + createdBusinessId = businessId, ); - } on firebase.FirebaseAuthException catch (e) { if (e.code == 'weak-password') { throw WeakPasswordException( @@ -137,11 +132,13 @@ class AuthRepositoryImpl } } on domain.AppException { // Rollback for our known exceptions - await _rollbackSignUp(firebaseUser: firebaseUser, businessId: createdBusinessId); + await _rollbackSignUp( + firebaseUser: firebaseUser, businessId: createdBusinessId); rethrow; } catch (e) { // Rollback: Clean up any partially created resources - await _rollbackSignUp(firebaseUser: firebaseUser, businessId: createdBusinessId); + await _rollbackSignUp( + firebaseUser: firebaseUser, businessId: createdBusinessId); throw SignUpFailedException( technicalMessage: 'Unexpected error: $e', ); @@ -164,11 +161,13 @@ class AuthRepositoryImpl required String password, required String companyName, }) async { - developer.log('Email exists in Firebase, attempting sign-in: $email', name: 'AuthRepository'); + developer.log('Email exists in Firebase, attempting sign-in: $email', + name: 'AuthRepository'); try { // Try to sign in with the provided password - final firebase.UserCredential credential = await _firebaseAuth.signInWithEmailAndPassword( + final firebase.UserCredential credential = + await _service.auth.signInWithEmailAndPassword( email: email, password: password, ); @@ -181,28 +180,32 @@ class AuthRepositoryImpl } // Sign-in succeeded! Check if user already has a BUSINESS account in PostgreSQL - final bool hasBusinessAccount = await _checkBusinessUserExists(firebaseUser.uid); + final bool hasBusinessAccount = + await _checkBusinessUserExists(firebaseUser.uid); if (hasBusinessAccount) { // User already has a KROW Client account - developer.log('User already has BUSINESS account: ${firebaseUser.uid}', name: 'AuthRepository'); + developer.log('User already has BUSINESS account: ${firebaseUser.uid}', + name: 'AuthRepository'); throw AccountExistsException( technicalMessage: 'User ${firebaseUser.uid} already has BUSINESS role', ); } // User exists in Firebase but not in KROW PostgreSQL - create the entities - developer.log('Creating BUSINESS account for existing Firebase user: ${firebaseUser.uid}', name: 'AuthRepository'); + developer.log( + 'Creating BUSINESS account for existing Firebase user: ${firebaseUser.uid}', + name: 'AuthRepository'); return await _createBusinessAndUser( firebaseUser: firebaseUser, companyName: companyName, email: email, onBusinessCreated: (_) {}, // No rollback needed for existing Firebase user ); - } on firebase.FirebaseAuthException catch (e) { // Sign-in failed - check why - developer.log('Sign-in failed with code: ${e.code}', name: 'AuthRepository'); + developer.log('Sign-in failed with code: ${e.code}', + name: 'AuthRepository'); if (e.code == 'wrong-password' || e.code == 'invalid-credential') { // Password doesn't match - check what providers are available @@ -226,9 +229,11 @@ class AuthRepositoryImpl // We can't distinguish between "wrong password" and "no password provider" // due to Firebase deprecating fetchSignInMethodsForEmail. // The PasswordMismatchException message covers both scenarios. - developer.log('Password mismatch or different provider for: $email', name: 'AuthRepository'); + developer.log('Password mismatch or different provider for: $email', + name: 'AuthRepository'); throw PasswordMismatchException( - technicalMessage: 'Email $email: password mismatch or different auth provider', + technicalMessage: + 'Email $email: password mismatch or different auth provider', ); } @@ -236,9 +241,11 @@ class AuthRepositoryImpl Future _checkBusinessUserExists(String firebaseUserId) async { final QueryResult response = - await executeProtected(() => _dataConnect.getUserById(id: firebaseUserId).execute()); + await _service.run( + () => _service.connector.getUserById(id: firebaseUserId).execute()); final dc.GetUserByIdUser? user = response.data.user; - return user != null && (user.userRole == 'BUSINESS' || user.userRole == 'BOTH'); + return user != null && + (user.userRole == 'BUSINESS' || user.userRole == 'BOTH'); } /// Creates Business and User entities in PostgreSQL for a Firebase user. @@ -250,38 +257,44 @@ class AuthRepositoryImpl }) async { // Create Business entity in PostgreSQL - final OperationResult createBusinessResponse = - await executeProtected(() => _dataConnect.createBusiness( - businessName: companyName, - userId: firebaseUser.uid, - rateGroup: dc.BusinessRateGroup.STANDARD, - status: dc.BusinessStatus.PENDING, - ).execute()); + final OperationResult + createBusinessResponse = await _service.run(() => _service.connector + .createBusiness( + businessName: companyName, + userId: firebaseUser.uid, + rateGroup: dc.BusinessRateGroup.STANDARD, + status: dc.BusinessStatus.PENDING, + ) + .execute()); - final dc.CreateBusinessBusinessInsert businessData = createBusinessResponse.data.business_insert; + final dc.CreateBusinessBusinessInsert businessData = + createBusinessResponse.data.business_insert; onBusinessCreated(businessData.id); // Check if User entity already exists in PostgreSQL final QueryResult userResult = - await executeProtected(() => _dataConnect.getUserById(id: firebaseUser.uid).execute()); + await _service.run(() => + _service.connector.getUserById(id: firebaseUser.uid).execute()); final dc.GetUserByIdUser? existingUser = userResult.data.user; if (existingUser != null) { // User exists (likely in another app like STAFF). Update role to BOTH. - await executeProtected(() => _dataConnect.updateUser( - id: firebaseUser.uid, - ) - .userRole('BOTH') - .execute()); + await _service.run(() => _service.connector + .updateUser( + id: firebaseUser.uid, + ) + .userRole('BOTH') + .execute()); } else { // Create new User entity in PostgreSQL - await executeProtected(() => _dataConnect.createUser( - id: firebaseUser.uid, - role: dc.UserBaseRole.USER, - ) - .email(email) - .userRole('BUSINESS') - .execute()); + await _service.run(() => _service.connector + .createUser( + id: firebaseUser.uid, + role: dc.UserBaseRole.USER, + ) + .email(email) + .userRole('BUSINESS') + .execute()); } return _getUserProfile( @@ -298,7 +311,7 @@ class AuthRepositoryImpl // Delete business first (if created) if (businessId != null) { try { - await _dataConnect.deleteBusiness(id: businessId).execute(); + await _service.connector.deleteBusiness(id: businessId).execute(); } catch (_) { // Log but don't throw - we're already in error recovery } @@ -316,8 +329,9 @@ class AuthRepositoryImpl @override Future signOut() async { try { - await _firebaseAuth.signOut(); + await _service.auth.signOut(); dc.ClientSessionStore.instance.clear(); + _service.clearCache(); } catch (e) { throw Exception('Error signing out: ${e.toString()}'); } @@ -325,7 +339,8 @@ class AuthRepositoryImpl @override Future signInWithSocial({required String provider}) { - throw UnimplementedError('Social authentication with $provider is not yet implemented.'); + throw UnimplementedError( + 'Social authentication with $provider is not yet implemented.'); } Future _getUserProfile({ @@ -334,18 +349,24 @@ class AuthRepositoryImpl bool requireBusinessRole = false, }) async { final QueryResult response = - await executeProtected(() => _dataConnect.getUserById(id: firebaseUserId).execute()); + await _service.run(() => + _service.connector.getUserById(id: firebaseUserId).execute()); final dc.GetUserByIdUser? user = response.data.user; if (user == null) { throw UserNotFoundException( - technicalMessage: 'Firebase UID $firebaseUserId not found in users table', + technicalMessage: + 'Firebase UID $firebaseUserId not found in users table', ); } - if (requireBusinessRole && user.userRole != 'BUSINESS' && user.userRole != 'BOTH') { - await _firebaseAuth.signOut(); + if (requireBusinessRole && + user.userRole != 'BUSINESS' && + user.userRole != 'BOTH') { + await _service.auth.signOut(); dc.ClientSessionStore.instance.clear(); + _service.clearCache(); throw UnauthorizedAppException( - technicalMessage: 'User role is ${user.userRole}, expected BUSINESS or BOTH', + technicalMessage: + 'User role is ${user.userRole}, expected BUSINESS or BOTH', ); } @@ -362,13 +383,17 @@ class AuthRepositoryImpl role: user.role.stringValue, ); - final QueryResult businessResponse = - await executeProtected(() => _dataConnect.getBusinessesByUserId( - userId: firebaseUserId, - ).execute()); - final dc.GetBusinessesByUserIdBusinesses? business = businessResponse.data.businesses.isNotEmpty - ? businessResponse.data.businesses.first - : null; + final QueryResult businessResponse = + await _service.run(() => _service.connector + .getBusinessesByUserId( + userId: firebaseUserId, + ) + .execute()); + final dc.GetBusinessesByUserIdBusinesses? business = + businessResponse.data.businesses.isNotEmpty + ? businessResponse.data.businesses.first + : null; dc.ClientSessionStore.instance.setSession( dc.ClientSession( From 19eda09620946fce0fc9feb5a2d1d79b20e5a2c3 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 17:35:15 -0500 Subject: [PATCH 35/53] feat: Refactor ClientCreateOrderRepositoryImpl and OneTimeOrderBloc to utilize DataConnectService, removing FirebaseAuth dependency --- .../lib/src/create_order_module.dart | 15 +- .../client_create_order_repository_impl.dart | 200 ++++++++---------- .../blocs/one_time_order_bloc.dart | 16 +- 3 files changed, 99 insertions(+), 132 deletions(-) diff --git a/apps/mobile/packages/features/client/create_order/lib/src/create_order_module.dart b/apps/mobile/packages/features/client/create_order/lib/src/create_order_module.dart index db759e08..0e2624e2 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/create_order_module.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/create_order_module.dart @@ -2,7 +2,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; -import 'package:firebase_auth/firebase_auth.dart' as firebase; import 'data/repositories_impl/client_create_order_repository_impl.dart'; import 'domain/repositories/client_create_order_repository_interface.dart'; import 'domain/usecases/create_one_time_order_usecase.dart'; @@ -29,12 +28,7 @@ class ClientCreateOrderModule extends Module { @override void binds(Injector i) { // Repositories - i.addLazySingleton( - () => ClientCreateOrderRepositoryImpl( - firebaseAuth: firebase.FirebaseAuth.instance, - dataConnect: ExampleConnector.instance, - ), - ); + i.addLazySingleton(ClientCreateOrderRepositoryImpl.new); // UseCases i.addLazySingleton(GetOrderTypesUseCase.new); @@ -44,12 +38,7 @@ class ClientCreateOrderModule extends Module { // BLoCs i.add(ClientCreateOrderBloc.new); i.add(RapidOrderBloc.new); - i.add( - () => OneTimeOrderBloc( - i.get(), - ExampleConnector.instance, - ), - ); + i.add(OneTimeOrderBloc.new); } @override diff --git a/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart b/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart index eb905a2a..0ae65a1a 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart @@ -1,4 +1,3 @@ -import 'package:firebase_auth/firebase_auth.dart' as firebase; import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; import 'package:intl/intl.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; @@ -13,21 +12,16 @@ import '../../domain/repositories/client_create_order_repository_interface.dart' /// /// It follows the KROW Clean Architecture by keeping the data layer focused /// on delegation and data mapping, without business logic. -class ClientCreateOrderRepositoryImpl - with dc.DataErrorHandler - implements ClientCreateOrderRepositoryInterface { +class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInterface { ClientCreateOrderRepositoryImpl({ - required firebase.FirebaseAuth firebaseAuth, - required dc.ExampleConnector dataConnect, - }) : _firebaseAuth = firebaseAuth, - _dataConnect = dataConnect; + required dc.DataConnectService service, + }) : _service = service; - final firebase.FirebaseAuth _firebaseAuth; - final dc.ExampleConnector _dataConnect; + final dc.DataConnectService _service; @override Future> getOrderTypes() { - return Future.value(const [ + return Future>.value(const [ domain.OrderType( id: 'one-time', titleKey: 'client_create_order.types.one_time', @@ -55,100 +49,95 @@ class ClientCreateOrderRepositoryImpl @override Future createOneTimeOrder(domain.OneTimeOrder order) async { - final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - await _firebaseAuth.signOut(); - throw Exception('Business is missing. Please sign in again.'); - } - final String? vendorId = order.vendorId; - if (vendorId == null || vendorId.isEmpty) { - throw Exception('Vendor is missing.'); - } - final domain.OneTimeOrderHubDetails? hub = order.hub; - if (hub == null || hub.id.isEmpty) { - throw Exception('Hub is missing.'); - } + return _service.run(() async { + final String businessId = await _service.getBusinessId(); + final String? vendorId = order.vendorId; + if (vendorId == null || vendorId.isEmpty) { + throw Exception('Vendor is missing.'); + } + final domain.OneTimeOrderHubDetails? hub = order.hub; + if (hub == null || hub.id.isEmpty) { + throw Exception('Hub is missing.'); + } - final DateTime orderDateOnly = DateTime( - order.date.year, - order.date.month, - order.date.day, - ); - final fdc.Timestamp orderTimestamp = _toTimestamp(orderDateOnly); - final fdc.OperationResult - orderResult = await executeProtected(() => _dataConnect - .createOrder( - businessId: businessId, - orderType: dc.OrderType.ONE_TIME, - teamHubId: hub.id, + final DateTime orderDateOnly = DateTime( + order.date.year, + order.date.month, + order.date.day, + ); + final fdc.Timestamp orderTimestamp = _service.toTimestamp(orderDateOnly); + final fdc.OperationResult orderResult = + await _service.connector + .createOrder( + businessId: businessId, + orderType: dc.OrderType.ONE_TIME, + teamHubId: hub.id, + ) + .vendorId(vendorId) + .eventName(order.eventName) + .status(dc.OrderStatus.POSTED) + .date(orderTimestamp) + .execute(); + + final String orderId = orderResult.data.order_insert.id; + + final int workersNeeded = order.positions.fold( + 0, + (int sum, domain.OneTimeOrderPosition position) => sum + position.count, + ); + final String shiftTitle = 'Shift 1 ${_formatDate(order.date)}'; + final double shiftCost = _calculateShiftCost(order); + + final fdc.OperationResult shiftResult = + await _service.connector + .createShift(title: shiftTitle, orderId: orderId) + .date(orderTimestamp) + .location(hub.name) + .locationAddress(hub.address) + .latitude(hub.latitude) + .longitude(hub.longitude) + .placeId(hub.placeId) + .city(hub.city) + .state(hub.state) + .street(hub.street) + .country(hub.country) + .status(dc.ShiftStatus.PENDING) + .workersNeeded(workersNeeded) + .filled(0) + .durationDays(1) + .cost(shiftCost) + .execute(); + + final String shiftId = shiftResult.data.shift_insert.id; + + for (final domain.OneTimeOrderPosition position in order.positions) { + final DateTime start = _parseTime(order.date, position.startTime); + final DateTime end = _parseTime(order.date, position.endTime); + final DateTime normalizedEnd = end.isBefore(start) ? end.add(const Duration(days: 1)) : end; + final double hours = normalizedEnd.difference(start).inMinutes / 60.0; + final double rate = order.roleRates[position.role] ?? 0; + final double totalValue = rate * hours * position.count; + + await _service.connector + .createShiftRole( + shiftId: shiftId, + roleId: position.role, + count: position.count, ) - .vendorId(vendorId) - .eventName(order.eventName) - .status(dc.OrderStatus.POSTED) - .date(orderTimestamp) - .execute()); + .startTime(_service.toTimestamp(start)) + .endTime(_service.toTimestamp(normalizedEnd)) + .hours(hours) + .breakType(_breakDurationFromValue(position.lunchBreak)) + .isBreakPaid(_isBreakPaid(position.lunchBreak)) + .totalValue(totalValue) + .execute(); + } - final String orderId = orderResult.data.order_insert.id; - - final int workersNeeded = order.positions.fold( - 0, - (int sum, domain.OneTimeOrderPosition position) => sum + position.count, - ); - final String shiftTitle = 'Shift 1 ${_formatDate(order.date)}'; - final double shiftCost = _calculateShiftCost(order); - - final fdc.OperationResult - shiftResult = await executeProtected(() => _dataConnect - .createShift(title: shiftTitle, orderId: orderId) - .date(orderTimestamp) - .location(hub.name) - .locationAddress(hub.address) - .latitude(hub.latitude) - .longitude(hub.longitude) - .placeId(hub.placeId) - .city(hub.city) - .state(hub.state) - .street(hub.street) - .country(hub.country) - .status(dc.ShiftStatus.PENDING) - .workersNeeded(workersNeeded) - .filled(0) - .durationDays(1) - .cost(shiftCost) - .execute()); - - final String shiftId = shiftResult.data.shift_insert.id; - - for (final domain.OneTimeOrderPosition position in order.positions) { - final DateTime start = _parseTime(order.date, position.startTime); - final DateTime end = _parseTime(order.date, position.endTime); - final DateTime normalizedEnd = - end.isBefore(start) ? end.add(const Duration(days: 1)) : end; - final double hours = normalizedEnd.difference(start).inMinutes / 60.0; - final double rate = order.roleRates[position.role] ?? 0; - final double totalValue = rate * hours * position.count; - - - - await executeProtected(() => _dataConnect - .createShiftRole( - shiftId: shiftId, - roleId: position.role, - count: position.count, - ) - .startTime(_toTimestamp(start)) - .endTime(_toTimestamp(normalizedEnd)) - .hours(hours) - .breakType(_breakDurationFromValue(position.lunchBreak)) - .isBreakPaid(_isBreakPaid(position.lunchBreak)) - .totalValue(totalValue) - .execute()); - } - - await executeProtected(() => _dataConnect - .updateOrder(id: orderId, teamHubId: hub.id) - .shifts(fdc.AnyValue([shiftId])) - .execute()); + await _service.connector + .updateOrder(id: orderId, teamHubId: hub.id) + .shifts(fdc.AnyValue([shiftId])) + .execute(); + }); } @override @@ -213,13 +202,6 @@ class ClientCreateOrderRepositoryImpl ); } - fdc.Timestamp _toTimestamp(DateTime dateTime) { - final DateTime utc = dateTime.toUtc(); - final int seconds = utc.millisecondsSinceEpoch ~/ 1000; - final int nanoseconds = (utc.microsecondsSinceEpoch % 1000000) * 1000; - return fdc.Timestamp(nanoseconds, seconds); - } - String _formatDate(DateTime dateTime) { final String year = dateTime.year.toString().padLeft(4, '0'); final String month = dateTime.month.toString().padLeft(2, '0'); diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart index 30fe20e1..7e11f0eb 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart @@ -11,7 +11,7 @@ import 'one_time_order_state.dart'; /// BLoC for managing the multi-step one-time order creation form. class OneTimeOrderBloc extends Bloc with BlocErrorHandler, SafeBloc { - OneTimeOrderBloc(this._createOneTimeOrderUseCase, this._dataConnect) + OneTimeOrderBloc(this._createOneTimeOrderUseCase, this._service) : super(OneTimeOrderState.initial()) { on(_onVendorsLoaded); on(_onVendorChanged); @@ -28,13 +28,13 @@ class OneTimeOrderBloc extends Bloc _loadHubs(); } final CreateOneTimeOrderUseCase _createOneTimeOrderUseCase; - final dc.ExampleConnector _dataConnect; + final dc.DataConnectService _service; Future _loadVendors() async { final List? vendors = await handleErrorWithResult( action: () async { final QueryResult result = - await _dataConnect.listVendors().execute(); + await _service.connector.listVendors().execute(); return result.data.vendors .map( (dc.ListVendorsVendors vendor) => Vendor( @@ -57,7 +57,7 @@ class OneTimeOrderBloc extends Bloc final List? roles = await handleErrorWithResult( action: () async { final QueryResult - result = await _dataConnect.listRolesByVendorId(vendorId: vendorId).execute(); + result = await _service.connector.listRolesByVendorId(vendorId: vendorId).execute(); return result.data.roles .map( (dc.ListRolesByVendorIdRoles role) => OneTimeOrderRoleOption( @@ -79,13 +79,9 @@ class OneTimeOrderBloc extends Bloc Future _loadHubs() async { final List? hubs = await handleErrorWithResult( action: () async { - final String? businessId = - dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - return []; - } + final String businessId = await _service.getBusinessId(); final QueryResult - result = await _dataConnect + result = await _service.connector .listTeamHubsByOwnerId(ownerId: businessId) .execute(); return result.data.teamHubs From 789fe24f2b0e27f24d591461c1af3c365ca6a708 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 17:38:09 -0500 Subject: [PATCH 36/53] feat: Refactor CoverageModule and CoverageRepositoryImpl to utilize DataConnectService --- .../lib/src/coverage_module.dart | 14 ++--- .../coverage_repository_impl.dart | 59 +++++-------------- 2 files changed, 21 insertions(+), 52 deletions(-) diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/coverage_module.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/coverage_module.dart index c0cc1258..aa36826c 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/coverage_module.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/coverage_module.dart @@ -10,24 +10,20 @@ import 'presentation/pages/coverage_page.dart'; /// Modular module for the coverage feature. class CoverageModule extends Module { + @override + List get imports => [DataConnectModule()]; + @override void binds(Injector i) { // Repositories - i.addSingleton( - () => CoverageRepositoryImpl(dataConnect: ExampleConnector.instance), - ); + i.addSingleton(CoverageRepositoryImpl.new); // Use Cases i.addSingleton(GetShiftsForDateUseCase.new); i.addSingleton(GetCoverageStatsUseCase.new); // BLoCs - i.addSingleton( - () => CoverageBloc( - getShiftsForDate: i.get(), - getCoverageStats: i.get(), - ), - ); + i.addSingleton(CoverageBloc.new); } @override diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart index 47a6dbc6..8dec3263 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart @@ -15,44 +15,36 @@ import '../../domain/repositories/coverage_repository.dart'; /// - Returns domain entities from `domain/ui_entities`. class CoverageRepositoryImpl implements CoverageRepository { /// Creates a [CoverageRepositoryImpl]. - CoverageRepositoryImpl({required dc.ExampleConnector dataConnect}) - : _dataConnect = dataConnect; + CoverageRepositoryImpl({required dc.DataConnectService service}) : _service = service; - final dc.ExampleConnector _dataConnect; + final dc.DataConnectService _service; /// Fetches shifts for a specific date. @override Future> getShiftsForDate({required DateTime date}) async { - try { - final String? businessId = - dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - return []; - } + return _service.run(() async { + final String businessId = await _service.getBusinessId(); final DateTime start = DateTime(date.year, date.month, date.day); - final DateTime end = - DateTime(date.year, date.month, date.day, 23, 59, 59, 999); + final DateTime end = DateTime(date.year, date.month, date.day, 23, 59, 59, 999); - final fdc.QueryResult< - dc.ListShiftRolesByBusinessAndDateRangeData, - dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult = - await _dataConnect + final fdc.QueryResult shiftRolesResult = + await _service.connector .listShiftRolesByBusinessAndDateRange( businessId: businessId, - start: _toTimestamp(start), - end: _toTimestamp(end), + start: _service.toTimestamp(start), + end: _service.toTimestamp(end), ) .execute(); - final fdc.QueryResult< - dc.ListStaffsApplicationsByBusinessForDayData, - dc.ListStaffsApplicationsByBusinessForDayVariables> applicationsResult = - await _dataConnect + final fdc.QueryResult applicationsResult = + await _service.connector .listStaffsApplicationsByBusinessForDay( businessId: businessId, - dayStart: _toTimestamp(start), - dayEnd: _toTimestamp(end), + dayStart: _service.toTimestamp(start), + dayEnd: _service.toTimestamp(end), ) .execute(); @@ -61,18 +53,7 @@ class CoverageRepositoryImpl implements CoverageRepository { applicationsResult.data.applications, date, ); - } catch (e) { - final String error = e.toString().toLowerCase(); - if (error.contains('network') || - error.contains('connection') || - error.contains('unavailable') || - error.contains('offline') || - error.contains('socket') || - error.contains('failed host lookup')) { - throw NetworkException(technicalMessage: 'Coverage fetch failed: $e'); - } - throw ServerException(technicalMessage: 'Coverage fetch failed: $e'); - } + }); } /// Fetches coverage statistics for a specific date. @@ -110,14 +91,6 @@ class CoverageRepositoryImpl implements CoverageRepository { ); } - fdc.Timestamp _toTimestamp(DateTime dateTime) { - final DateTime utc = dateTime.toUtc(); - final int seconds = utc.millisecondsSinceEpoch ~/ 1000; - final int nanoseconds = - (utc.millisecondsSinceEpoch % 1000) * 1000000; - return fdc.Timestamp(nanoseconds, seconds); - } - List _mapCoverageShifts( List shiftRoles, List applications, From a7e8704e4f72717999888a2aa33372f9119039e3 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 17:43:20 -0500 Subject: [PATCH 37/53] feat: Simplify HomeRepositoryImpl and ClientHomeModule by using constructor shorthand --- .../features/client/home/lib/client_home.dart | 14 +- .../home_repository_impl.dart | 142 ++++++------------ 2 files changed, 49 insertions(+), 107 deletions(-) diff --git a/apps/mobile/packages/features/client/home/lib/client_home.dart b/apps/mobile/packages/features/client/home/lib/client_home.dart index 65415b46..b72d7b32 100644 --- a/apps/mobile/packages/features/client/home/lib/client_home.dart +++ b/apps/mobile/packages/features/client/home/lib/client_home.dart @@ -23,11 +23,7 @@ class ClientHomeModule extends Module { @override void binds(Injector i) { // Repositories - i.addLazySingleton( - () => HomeRepositoryImpl( - ExampleConnector.instance, - ), - ); + i.addLazySingleton(HomeRepositoryImpl.new); // UseCases i.addLazySingleton(GetDashboardDataUseCase.new); @@ -35,13 +31,7 @@ class ClientHomeModule extends Module { i.addLazySingleton(GetUserSessionDataUseCase.new); // BLoCs - i.add( - () => ClientHomeBloc( - getDashboardDataUseCase: i.get(), - getRecentReordersUseCase: i.get(), - getUserSessionDataUseCase: i.get(), - ), - ); + i.add(ClientHomeBloc.new); } @override diff --git a/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart b/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart index 547f9c65..cc92dbc8 100644 --- a/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart +++ b/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart @@ -8,25 +8,14 @@ import '../../domain/repositories/home_repository_interface.dart'; /// This implementation resides in the data layer and acts as a bridge between the /// domain layer and the data source (in this case, a mock from data_connect). class HomeRepositoryImpl implements HomeRepositoryInterface { - /// Creates a [HomeRepositoryImpl]. - HomeRepositoryImpl(this._dataConnect); - final dc.ExampleConnector _dataConnect; + HomeRepositoryImpl(this._service); + final dc.DataConnectService _service; @override Future getDashboardData() async { - try { - final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - return const HomeDashboardData( - weeklySpending: 0, - next7DaysSpending: 0, - weeklyShifts: 0, - next7DaysScheduled: 0, - totalNeeded: 0, - totalFilled: 0, - ); - } + return _service.run(() async { + final String businessId = await _service.getBusinessId(); final DateTime now = DateTime.now(); final int daysFromMonday = now.weekday - DateTime.monday; @@ -35,14 +24,13 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { final DateTime weekRangeStart = DateTime(monday.year, monday.month, monday.day); final DateTime weekRangeEnd = DateTime(monday.year, monday.month, monday.day + 13, 23, 59, 59, 999); - final fdc.QueryResult< - dc.GetCompletedShiftsByBusinessIdData, - dc.GetCompletedShiftsByBusinessIdVariables> completedResult = - await _dataConnect + final fdc.QueryResult completedResult = + await _service.connector .getCompletedShiftsByBusinessId( businessId: businessId, - dateFrom: _toTimestamp(weekRangeStart), - dateTo: _toTimestamp(weekRangeEnd), + dateFrom: _service.toTimestamp(weekRangeStart), + dateTo: _service.toTimestamp(weekRangeEnd), ) .execute(); @@ -50,8 +38,7 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { double next7DaysSpending = 0.0; int weeklyShifts = 0; int next7DaysScheduled = 0; - for (final dc.GetCompletedShiftsByBusinessIdShifts shift - in completedResult.data.shifts) { + for (final dc.GetCompletedShiftsByBusinessIdShifts shift in completedResult.data.shifts) { final DateTime? shiftDate = shift.date?.toDateTime(); if (shiftDate == null) { continue; @@ -73,14 +60,13 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { final DateTime start = DateTime(now.year, now.month, now.day); final DateTime end = DateTime(now.year, now.month, now.day, 23, 59, 59, 999); - final fdc.QueryResult< - dc.ListShiftRolesByBusinessAndDateRangeData, - dc.ListShiftRolesByBusinessAndDateRangeVariables> result = - await _dataConnect + final fdc.QueryResult result = + await _service.connector .listShiftRolesByBusinessAndDateRange( businessId: businessId, - start: _toTimestamp(start), - end: _toTimestamp(end), + start: _service.toTimestamp(start), + end: _service.toTimestamp(end), ) .execute(); @@ -100,18 +86,7 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { totalNeeded: totalNeeded, totalFilled: totalFilled, ); - } catch (e) { - final String error = e.toString().toLowerCase(); - if (error.contains('network') || - error.contains('connection') || - error.contains('unavailable') || - error.contains('offline') || - error.contains('socket') || - error.contains('failed host lookup')) { - throw NetworkException(technicalMessage: 'Home dashboard fetch failed: $e'); - } - throw ServerException(technicalMessage: 'Home dashboard fetch failed: $e'); - } + }); } @override @@ -125,64 +100,41 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { @override Future> getRecentReorders() async { - try { - final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - return const []; - } + return _service.run(() async { + final String businessId = await _service.getBusinessId(); final DateTime now = DateTime.now(); final DateTime start = now.subtract(const Duration(days: 30)); - final fdc.Timestamp startTimestamp = _toTimestamp(start); - final fdc.Timestamp endTimestamp = _toTimestamp(now); + final fdc.Timestamp startTimestamp = _service.toTimestamp(start); + final fdc.Timestamp endTimestamp = _service.toTimestamp(now); - final fdc.QueryResult< - dc.ListShiftRolesByBusinessDateRangeCompletedOrdersData, - dc.ListShiftRolesByBusinessDateRangeCompletedOrdersVariables> result = - await _dataConnect.listShiftRolesByBusinessDateRangeCompletedOrders( - businessId: businessId, - start: startTimestamp, - end: endTimestamp, - ).execute(); + final fdc.QueryResult result = + await _service.connector + .listShiftRolesByBusinessDateRangeCompletedOrders( + businessId: businessId, + start: startTimestamp, + end: endTimestamp, + ) + .execute(); - return result.data.shiftRoles.map(( - dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole, - ) { - - final String location = - shiftRole.shift.location ?? - shiftRole.shift.locationAddress ?? - ''; - final String type = shiftRole.shift.order.orderType.stringValue; - return ReorderItem( - orderId: shiftRole.shift.order.id, - title: '${shiftRole.role.name} - ${shiftRole.shift.title}', - location: location, - hourlyRate: shiftRole.role.costPerHour, - hours: shiftRole.hours ?? 0, - workers: shiftRole.count, - type: type, - ); - }).toList(); - } catch (e) { - final String error = e.toString().toLowerCase(); - if (error.contains('network') || - error.contains('connection') || - error.contains('unavailable') || - error.contains('offline') || - error.contains('socket') || - error.contains('failed host lookup')) { - throw NetworkException(technicalMessage: 'Home reorders fetch failed: $e'); - } - throw ServerException(technicalMessage: 'Home reorders fetch failed: $e'); - } - } - - fdc.Timestamp _toTimestamp(DateTime date) { - final DateTime utc = date.toUtc(); - final int millis = utc.millisecondsSinceEpoch; - final int seconds = millis ~/ 1000; - final int nanos = (millis % 1000) * 1000000; - return fdc.Timestamp(nanos, seconds); + return result.data.shiftRoles + .map(( + dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole, + ) { + final String location = shiftRole.shift.location ?? shiftRole.shift.locationAddress ?? ''; + final String type = shiftRole.shift.order.orderType.stringValue; + return ReorderItem( + orderId: shiftRole.shift.order.id, + title: '${shiftRole.role.name} - ${shiftRole.shift.title}', + location: location, + hourlyRate: shiftRole.role.costPerHour, + hours: shiftRole.hours ?? 0, + workers: shiftRole.count, + type: type, + ); + }) + .toList(); + }); } } From 21f0e2ee895a3153cfde25adb5760df8912c6073 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 17:43:50 -0500 Subject: [PATCH 38/53] feat: Refactor HubRepositoryImpl to remove FirebaseAuth dependency and utilize DataConnectService --- .../features/client/hubs/lib/client_hubs.dart | 17 +- .../hub_repository_impl.dart | 174 +++++++++--------- 2 files changed, 86 insertions(+), 105 deletions(-) diff --git a/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart b/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart index 088ec6d1..1f7c0eb9 100644 --- a/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart +++ b/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart @@ -3,7 +3,6 @@ library; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; -import 'package:firebase_auth/firebase_auth.dart' as firebase; import 'src/data/repositories_impl/hub_repository_impl.dart'; import 'src/domain/repositories/hub_repository_interface.dart'; import 'src/domain/usecases/assign_nfc_tag_usecase.dart'; @@ -23,12 +22,7 @@ class ClientHubsModule extends Module { @override void binds(Injector i) { // Repositories - i.addLazySingleton( - () => HubRepositoryImpl( - firebaseAuth: firebase.FirebaseAuth.instance, - dataConnect: ExampleConnector.instance, - ), - ); + i.addLazySingleton(HubRepositoryImpl.new); // UseCases i.addLazySingleton(GetHubsUseCase.new); @@ -37,14 +31,7 @@ class ClientHubsModule extends Module { i.addLazySingleton(AssignNfcTagUseCase.new); // BLoCs - i.add( - () => ClientHubsBloc( - getHubsUseCase: i.get(), - createHubUseCase: i.get(), - deleteHubUseCase: i.get(), - assignNfcTagUseCase: i.get(), - ), - ); + i.add(ClientHubsBloc.new); } @override diff --git a/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart b/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart index 2fbd8aac..d207b7d5 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart @@ -9,30 +9,26 @@ import 'package:krow_domain/krow_domain.dart' as domain; import 'package:krow_domain/krow_domain.dart' show HubHasOrdersException, - HubCreationFailedException, BusinessNotFoundException, NotAuthenticatedException; import '../../domain/repositories/hub_repository_interface.dart'; /// Implementation of [HubRepositoryInterface] backed by Data Connect. -class HubRepositoryImpl - with dc.DataErrorHandler - implements HubRepositoryInterface { +class HubRepositoryImpl implements HubRepositoryInterface { HubRepositoryImpl({ - required firebase.FirebaseAuth firebaseAuth, - required dc.ExampleConnector dataConnect, - }) : _firebaseAuth = firebaseAuth, - _dataConnect = dataConnect; + required dc.DataConnectService service, + }) : _service = service; - final firebase.FirebaseAuth _firebaseAuth; - final dc.ExampleConnector _dataConnect; + final dc.DataConnectService _service; @override Future> getHubs() async { - final dc.GetBusinessesByUserIdBusinesses business = await _getBusinessForCurrentUser(); - final String teamId = await _getOrCreateTeamId(business); - return _fetchHubsForTeam(teamId: teamId, businessId: business.id); + return _service.run(() async { + final dc.GetBusinessesByUserIdBusinesses business = await _getBusinessForCurrentUser(); + final String teamId = await _getOrCreateTeamId(business); + return _fetchHubsForTeam(teamId: teamId, businessId: business.id); + }); } @override @@ -48,82 +44,80 @@ class HubRepositoryImpl String? country, String? zipCode, }) async { - final dc.GetBusinessesByUserIdBusinesses business = await _getBusinessForCurrentUser(); - final String teamId = await _getOrCreateTeamId(business); - final _PlaceAddress? placeAddress = - placeId == null || placeId.isEmpty ? null : await _fetchPlaceAddress(placeId); - final String? cityValue = city ?? placeAddress?.city ?? business.city; - final String? stateValue = state ?? placeAddress?.state; - final String? streetValue = street ?? placeAddress?.street; - final String? countryValue = country ?? placeAddress?.country; - final String? zipCodeValue = zipCode ?? placeAddress?.zipCode; + return _service.run(() async { + final dc.GetBusinessesByUserIdBusinesses business = await _getBusinessForCurrentUser(); + final String teamId = await _getOrCreateTeamId(business); + final _PlaceAddress? placeAddress = + placeId == null || placeId.isEmpty ? null : await _fetchPlaceAddress(placeId); + final String? cityValue = city ?? placeAddress?.city ?? business.city; + final String? stateValue = state ?? placeAddress?.state; + final String? streetValue = street ?? placeAddress?.street; + final String? countryValue = country ?? placeAddress?.country; + final String? zipCodeValue = zipCode ?? placeAddress?.zipCode; - final OperationResult - result = await executeProtected(() => _dataConnect - .createTeamHub( - teamId: teamId, - hubName: name, - address: address, - ) - .placeId(placeId) - .latitude(latitude) - .longitude(longitude) - .city(cityValue?.isNotEmpty == true ? cityValue : '') - .state(stateValue) - .street(streetValue) - .country(countryValue) - .zipCode(zipCodeValue) - .execute()); - final String createdId = result.data.teamHub_insert.id; + final OperationResult + result = await _service.connector + .createTeamHub( + teamId: teamId, + hubName: name, + address: address, + ) + .placeId(placeId) + .latitude(latitude) + .longitude(longitude) + .city(cityValue?.isNotEmpty == true ? cityValue : '') + .state(stateValue) + .street(streetValue) + .country(countryValue) + .zipCode(zipCodeValue) + .execute(); + final String createdId = result.data.teamHub_insert.id; - final List hubs = await _fetchHubsForTeam( - teamId: teamId, - businessId: business.id, - ); - domain.Hub? createdHub; - for (final domain.Hub hub in hubs) { - if (hub.id == createdId) { - createdHub = hub; - break; + final List hubs = await _fetchHubsForTeam( + teamId: teamId, + businessId: business.id, + ); + domain.Hub? createdHub; + for (final domain.Hub hub in hubs) { + if (hub.id == createdId) { + createdHub = hub; + break; + } } - } - return createdHub ?? - domain.Hub( - id: createdId, - businessId: business.id, - name: name, - address: address, - nfcTagId: null, - status: domain.HubStatus.active, - ); + return createdHub ?? + domain.Hub( + id: createdId, + businessId: business.id, + name: name, + address: address, + nfcTagId: null, + status: domain.HubStatus.active, + ); + }); } @override Future deleteHub(String id) async { - final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - await _firebaseAuth.signOut(); - throw const BusinessNotFoundException( - technicalMessage: 'Business ID missing from session', - ); - } + return _service.run(() async { + final String businessId = await _service.getBusinessId(); - final QueryResult result = - await executeProtected(() => _dataConnect - .listOrdersByBusinessAndTeamHub( - businessId: businessId, - teamHubId: id, - ) - .execute()); + final QueryResult result = + await _service.connector + .listOrdersByBusinessAndTeamHub( + businessId: businessId, + teamHubId: id, + ) + .execute(); - if (result.data.orders.isNotEmpty) { - throw HubHasOrdersException( - technicalMessage: 'Hub $id has ${result.data.orders.length} orders', - ); - } + if (result.data.orders.isNotEmpty) { + throw HubHasOrdersException( + technicalMessage: 'Hub $id has ${result.data.orders.length} orders', + ); + } - await executeProtected(() => _dataConnect.deleteTeamHub(id: id).execute()); + await _service.connector.deleteTeamHub(id: id).execute(); + }); } @override @@ -141,7 +135,7 @@ class HubRepositoryImpl return dc.GetBusinessesByUserIdBusinesses( id: cachedBusiness.id, businessName: cachedBusiness.businessName, - userId: _firebaseAuth.currentUser?.uid ?? '', + userId: _service.auth.currentUser?.uid ?? '', rateGroup: const dc.Known(dc.BusinessRateGroup.STANDARD), status: const dc.Known(dc.BusinessStatus.ACTIVE), contactName: cachedBusiness.contactName, @@ -159,7 +153,7 @@ class HubRepositoryImpl ); } - final firebase.User? user = _firebaseAuth.currentUser; + final firebase.User? user = _service.auth.currentUser; if (user == null) { throw const NotAuthenticatedException( technicalMessage: 'No Firebase user in currentUser', @@ -168,11 +162,11 @@ class HubRepositoryImpl final QueryResult result = - await executeProtected(() => _dataConnect.getBusinessesByUserId( + await _service.connector.getBusinessesByUserId( userId: user.uid, - ).execute()); + ).execute(); if (result.data.businesses.isEmpty) { - await _firebaseAuth.signOut(); + await _service.auth.signOut(); throw BusinessNotFoundException( technicalMessage: 'No business found for user ${user.uid}', ); @@ -203,14 +197,14 @@ class HubRepositoryImpl dc.GetBusinessesByUserIdBusinesses business, ) async { final QueryResult - teamsResult = await executeProtected(() => _dataConnect.getTeamsByOwnerId( + teamsResult = await _service.connector.getTeamsByOwnerId( ownerId: business.id, - ).execute()); + ).execute(); if (teamsResult.data.teams.isNotEmpty) { return teamsResult.data.teams.first.id; } - final dc.CreateTeamVariablesBuilder createTeamBuilder = _dataConnect.createTeam( + final dc.CreateTeamVariablesBuilder createTeamBuilder = _service.connector.createTeam( teamName: '${business.businessName} Team', ownerId: business.id, ownerName: business.contactName ?? '', @@ -222,7 +216,7 @@ class HubRepositoryImpl final OperationResult createTeamResult = - await executeProtected(() => createTeamBuilder.execute()); + await createTeamBuilder.execute(); final String teamId = createTeamResult.data.team_insert.id; return teamId; @@ -234,9 +228,9 @@ class HubRepositoryImpl }) async { final QueryResult hubsResult = - await executeProtected(() => _dataConnect.getTeamHubsByTeamId( + await _service.connector.getTeamHubsByTeamId( teamId: teamId, - ).execute()); + ).execute(); return hubsResult.data.teamHubs .map( From 39bb17d981bfb2b2aa8d9096c7295e126f982262 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Mon, 16 Feb 2026 17:49:34 -0500 Subject: [PATCH 39/53] feat: Refactor repositories and modules to remove FirebaseAuth dependency and utilize DataConnectService --- .../pages/client_sign_up_page.dart | 2 +- .../src/presentation/blocs/billing_bloc.dart | 1 - .../client_create_order_repository_impl.dart | 3 +- .../widgets/coverage_dashboard.dart | 8 +- .../client/settings/lib/client_settings.dart | 13 +- .../settings_repository_impl.dart | 20 +- .../view_orders_repository_impl.dart | 214 ++++++++---------- .../presentation/widgets/view_order_card.dart | 22 +- .../lib/src/view_orders_module.dart | 15 +- 9 files changed, 121 insertions(+), 177 deletions(-) diff --git a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_sign_up_page.dart b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_sign_up_page.dart index c6b8425f..99975735 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_sign_up_page.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_sign_up_page.dart @@ -41,7 +41,7 @@ class ClientSignUpPage extends StatelessWidget { final TranslationsClientAuthenticationSignUpPageEn i18n = t.client_authentication.sign_up_page; final ClientAuthBloc authBloc = Modular.get(); - return BlocProvider.value( + return BlocProvider.value( value: authBloc, child: BlocConsumer( listener: (BuildContext context, ClientAuthState state) { diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart index 23b18b1f..ccddda07 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart @@ -54,7 +54,6 @@ class BillingBloc extends Bloc _getSpendingBreakdown.call(state.period), ]); - final double currentBill = results[0] as double; final double savings = results[1] as double; final List pendingInvoices = results[2] as List; final List invoiceHistory = results[3] as List; diff --git a/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart b/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart index 0ae65a1a..d5c90dea 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart @@ -6,8 +6,7 @@ import '../../domain/repositories/client_create_order_repository_interface.dart' /// Implementation of [ClientCreateOrderRepositoryInterface]. /// -/// This implementation coordinates data access for order creation by delegating -/// to the [OrderRepositoryMock] and [ExampleConnector] from the shared +/// This implementation coordinates data access for order creation by [DataConnectService] from the shared /// Data Connect package. /// /// It follows the KROW Clean Architecture by keeping the data layer focused diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/coverage_dashboard.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/coverage_dashboard.dart index ee6bf227..a85eca69 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/coverage_dashboard.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/coverage_dashboard.dart @@ -22,8 +22,8 @@ class CoverageDashboard extends StatelessWidget { int totalConfirmed = 0; double todayCost = 0; - for (final s in shifts) { - final int needed = s['workersNeeded'] as int? ?? 0; + for (final dynamic s in shifts) { + final int needed = (s as Map)['workersNeeded'] as int? ?? 0; final int confirmed = s['filled'] as int? ?? 0; final double rate = s['hourlyRate'] as double? ?? 0.0; final double hours = s['hours'] as double? ?? 0.0; @@ -39,10 +39,10 @@ class CoverageDashboard extends StatelessWidget { final int unfilledPositions = totalNeeded - totalConfirmed; final int checkedInCount = applications - .where((a) => (a as Map)['checkInTime'] != null) + .where((dynamic a) => (a as Map)['checkInTime'] != null) .length; final int lateWorkersCount = applications - .where((a) => (a as Map)['status'] == 'LATE') + .where((dynamic a) => (a as Map)['status'] == 'LATE') .length; final bool isCoverageGood = coveragePercent >= 90; diff --git a/apps/mobile/packages/features/client/settings/lib/client_settings.dart b/apps/mobile/packages/features/client/settings/lib/client_settings.dart index 1af20a06..90cb283e 100644 --- a/apps/mobile/packages/features/client/settings/lib/client_settings.dart +++ b/apps/mobile/packages/features/client/settings/lib/client_settings.dart @@ -1,6 +1,6 @@ -import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; import 'src/data/repositories_impl/settings_repository_impl.dart'; import 'src/domain/repositories/settings_repository_interface.dart'; import 'src/domain/usecases/sign_out_usecase.dart'; @@ -9,20 +9,19 @@ import 'src/presentation/pages/client_settings_page.dart'; /// A [Module] for the client settings feature. class ClientSettingsModule extends Module { + @override + List get imports => [DataConnectModule()]; + @override void binds(Injector i) { // Repositories - i.addLazySingleton( - () => SettingsRepositoryImpl(firebaseAuth: FirebaseAuth.instance), - ); + i.addLazySingleton(SettingsRepositoryImpl.new); // UseCases i.addLazySingleton(SignOutUseCase.new); // BLoCs - i.add( - () => ClientSettingsBloc(signOutUseCase: i.get()), - ); + i.add(ClientSettingsBloc.new); } @override diff --git a/apps/mobile/packages/features/client/settings/lib/src/data/repositories_impl/settings_repository_impl.dart b/apps/mobile/packages/features/client/settings/lib/src/data/repositories_impl/settings_repository_impl.dart index 948af68c..2da4bc85 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/data/repositories_impl/settings_repository_impl.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/data/repositories_impl/settings_repository_impl.dart @@ -1,23 +1,21 @@ -import 'package:firebase_auth/firebase_auth.dart'; +import 'package:krow_data_connect/krow_data_connect.dart' as dc; import '../../domain/repositories/settings_repository_interface.dart'; /// Implementation of [SettingsRepositoryInterface]. /// -/// This implementation delegates authentication operations to [FirebaseAuth]. +/// This implementation delegates authentication operations to [DataConnectService]. class SettingsRepositoryImpl implements SettingsRepositoryInterface { -/// Creates a [SettingsRepositoryImpl] with the required [_firebaseAuth]. - const SettingsRepositoryImpl({required this.firebaseAuth}); + /// Creates a [SettingsRepositoryImpl] with the required [_service]. + const SettingsRepositoryImpl({required dc.DataConnectService service}) : _service = service; - /// The Firebase Auth instance. - final FirebaseAuth firebaseAuth; + /// The Data Connect service. + final dc.DataConnectService _service; @override Future signOut() async { - try { - await firebaseAuth.signOut(); - } catch (e) { - throw Exception('Error signing out: ${e.toString()}'); - } + return _service.run(() async { + await _service.auth.signOut(); + }); } } diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart b/apps/mobile/packages/features/client/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart index 82e730fc..2886c335 100644 --- a/apps/mobile/packages/features/client/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart +++ b/apps/mobile/packages/features/client/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart @@ -1,4 +1,3 @@ -import 'package:firebase_auth/firebase_auth.dart' as firebase; import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; import 'package:intl/intl.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; @@ -6,151 +5,132 @@ import 'package:krow_domain/krow_domain.dart' as domain; import '../../domain/repositories/i_view_orders_repository.dart'; /// Implementation of [IViewOrdersRepository] using Data Connect. -class ViewOrdersRepositoryImpl - with dc.DataErrorHandler - implements IViewOrdersRepository { - final firebase.FirebaseAuth _firebaseAuth; - final dc.ExampleConnector _dataConnect; +class ViewOrdersRepositoryImpl implements IViewOrdersRepository { + final dc.DataConnectService _service; ViewOrdersRepositoryImpl({ - required firebase.FirebaseAuth firebaseAuth, - required dc.ExampleConnector dataConnect, - }) : _firebaseAuth = firebaseAuth, - _dataConnect = dataConnect; + required dc.DataConnectService service, + }) : _service = service; @override Future> getOrdersForRange({ required DateTime start, required DateTime end, }) async { - final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - await _firebaseAuth.signOut(); - throw Exception('Business is missing. Please sign in again.'); - } - - final fdc.Timestamp startTimestamp = _toTimestamp(_startOfDay(start)); - final fdc.Timestamp endTimestamp = _toTimestamp(_endOfDay(end)); - final fdc.QueryResult result = - await executeProtected(() => _dataConnect - .listShiftRolesByBusinessAndDateRange( - businessId: businessId, - start: startTimestamp, - end: endTimestamp, - ) - .execute()); - print( - 'ViewOrders range start=${start.toIso8601String()} end=${end.toIso8601String()} shiftRoles=${result.data.shiftRoles.length}', - ); - - final String businessName = - dc.ClientSessionStore.instance.session?.business?.businessName ?? - 'Your Company'; - - return result.data.shiftRoles.map((dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole) { - final DateTime? shiftDate = shiftRole.shift.date?.toDateTime().toLocal(); - final String dateStr = shiftDate == null - ? '' - : DateFormat('yyyy-MM-dd').format(shiftDate); - final String startTime = _formatTime(shiftRole.startTime); - final String endTime = _formatTime(shiftRole.endTime); - final int filled = shiftRole.assigned ?? 0; - final int workersNeeded = shiftRole.count; - final double hours = shiftRole.hours ?? 0; - final double totalValue = shiftRole.totalValue ?? 0; - final double hourlyRate = _hourlyRate(shiftRole.totalValue, shiftRole.hours); - // final String status = filled >= workersNeeded ? 'filled' : 'open'; - final String status = shiftRole.shift.status?.stringValue ?? 'OPEN'; + return _service.run(() async { + final String businessId = await _service.getBusinessId(); + final fdc.Timestamp startTimestamp = _service.toTimestamp(_startOfDay(start)); + final fdc.Timestamp endTimestamp = _service.toTimestamp(_endOfDay(end)); + final fdc.QueryResult result = + await _service.connector + .listShiftRolesByBusinessAndDateRange( + businessId: businessId, + start: startTimestamp, + end: endTimestamp, + ) + .execute(); print( - 'ViewOrders item: date=$dateStr status=$status shiftId=${shiftRole.shiftId} ' - 'roleId=${shiftRole.roleId} start=${shiftRole.startTime?.toJson()} ' - 'end=${shiftRole.endTime?.toJson()} hours=$hours totalValue=$totalValue', + 'ViewOrders range start=${start.toIso8601String()} end=${end.toIso8601String()} shiftRoles=${result.data.shiftRoles.length}', ); - final String eventName = - shiftRole.shift.order.eventName ?? shiftRole.shift.title; + final String businessName = + dc.ClientSessionStore.instance.session?.business?.businessName ?? 'Your Company'; - return domain.OrderItem( - id: _shiftRoleKey(shiftRole.shiftId, shiftRole.roleId), - orderId: shiftRole.shift.order.id, - title: '${shiftRole.role.name} - $eventName', - clientName: businessName, - status: status, - date: dateStr, - startTime: startTime, - endTime: endTime, - location: shiftRole.shift.location ?? '', - locationAddress: shiftRole.shift.locationAddress ?? '', - filled: filled, - workersNeeded: workersNeeded, - hourlyRate: hourlyRate, - hours: hours, - totalValue: totalValue, - confirmedApps: const >[], - ); - }).toList(); + return result.data.shiftRoles.map((dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole) { + final DateTime? shiftDate = shiftRole.shift.date?.toDateTime().toLocal(); + final String dateStr = shiftDate == null ? '' : DateFormat('yyyy-MM-dd').format(shiftDate); + final String startTime = _formatTime(shiftRole.startTime); + final String endTime = _formatTime(shiftRole.endTime); + final int filled = shiftRole.assigned ?? 0; + final int workersNeeded = shiftRole.count; + final double hours = shiftRole.hours ?? 0; + final double totalValue = shiftRole.totalValue ?? 0; + final double hourlyRate = _hourlyRate(shiftRole.totalValue, shiftRole.hours); + // final String status = filled >= workersNeeded ? 'filled' : 'open'; + final String status = shiftRole.shift.status?.stringValue ?? 'OPEN'; + + print( + 'ViewOrders item: date=$dateStr status=$status shiftId=${shiftRole.shiftId} ' + 'roleId=${shiftRole.roleId} start=${shiftRole.startTime?.toJson()} ' + 'end=${shiftRole.endTime?.toJson()} hours=$hours totalValue=$totalValue', + ); + + final String eventName = shiftRole.shift.order.eventName ?? shiftRole.shift.title; + + return domain.OrderItem( + id: _shiftRoleKey(shiftRole.shiftId, shiftRole.roleId), + orderId: shiftRole.shift.order.id, + title: '${shiftRole.role.name} - $eventName', + clientName: businessName, + status: status, + date: dateStr, + startTime: startTime, + endTime: endTime, + location: shiftRole.shift.location ?? '', + locationAddress: shiftRole.shift.locationAddress ?? '', + filled: filled, + workersNeeded: workersNeeded, + hourlyRate: hourlyRate, + hours: hours, + totalValue: totalValue, + confirmedApps: const >[], + ); + }).toList(); + }); } @override Future>>> getAcceptedApplicationsForDay( DateTime day, ) async { - final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - await _firebaseAuth.signOut(); - throw Exception('Business is missing. Please sign in again.'); - } + return _service.run(() async { + final String businessId = await _service.getBusinessId(); - final fdc.Timestamp dayStart = _toTimestamp(_startOfDay(day)); - final fdc.Timestamp dayEnd = _toTimestamp(_endOfDay(day)); - final fdc.QueryResult result = - await executeProtected(() => _dataConnect - .listAcceptedApplicationsByBusinessForDay( - businessId: businessId, - dayStart: dayStart, - dayEnd: dayEnd, - ) - .execute()); + final fdc.Timestamp dayStart = _service.toTimestamp(_startOfDay(day)); + final fdc.Timestamp dayEnd = _service.toTimestamp(_endOfDay(day)); + final fdc.QueryResult result = + await _service.connector + .listAcceptedApplicationsByBusinessForDay( + businessId: businessId, + dayStart: dayStart, + dayEnd: dayEnd, + ) + .execute(); - print( - 'ViewOrders day=${day.toIso8601String()} applications=${result.data.applications.length}', - ); - - final Map>> grouped = >>{}; - for (final dc.ListAcceptedApplicationsByBusinessForDayApplications application in result.data.applications) { print( - 'ViewOrders app: shiftId=${application.shiftId} roleId=${application.roleId} ' - 'checkIn=${application.checkInTime?.toJson()} checkOut=${application.checkOutTime?.toJson()}', + 'ViewOrders day=${day.toIso8601String()} applications=${result.data.applications.length}', ); - final String key = _shiftRoleKey(application.shiftId, application.roleId); - grouped.putIfAbsent(key, () => >[]); - grouped[key]!.add({ - 'id': application.id, - 'worker_id': application.staff.id, - 'worker_name': application.staff.fullName, - 'status': 'confirmed', - 'photo_url': application.staff.photoUrl, - 'phone': application.staff.phone, - 'rating': application.staff.averageRating, - }); - } - return grouped; + + final Map>> grouped = >>{}; + for (final dc.ListAcceptedApplicationsByBusinessForDayApplications application + in result.data.applications) { + print( + 'ViewOrders app: shiftId=${application.shiftId} roleId=${application.roleId} ' + 'checkIn=${application.checkInTime?.toJson()} checkOut=${application.checkOutTime?.toJson()}', + ); + final String key = _shiftRoleKey(application.shiftId, application.roleId); + grouped.putIfAbsent(key, () => >[]); + grouped[key]!.add({ + 'id': application.id, + 'worker_id': application.staff.id, + 'worker_name': application.staff.fullName, + 'status': 'confirmed', + 'photo_url': application.staff.photoUrl, + 'phone': application.staff.phone, + 'rating': application.staff.averageRating, + }); + } + return grouped; + }); } String _shiftRoleKey(String shiftId, String roleId) { return '$shiftId:$roleId'; } - fdc.Timestamp _toTimestamp(DateTime dateTime) { - final DateTime utc = dateTime.toUtc(); - final int seconds = utc.millisecondsSinceEpoch ~/ 1000; - final int nanoseconds = (utc.microsecondsSinceEpoch % 1000000) * 1000; - return fdc.Timestamp(nanoseconds, seconds); - } - DateTime _startOfDay(DateTime dateTime) { return DateTime(dateTime.year, dateTime.month, dateTime.day); } diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart index 480faece..0f875aff 100644 --- a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart +++ b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart @@ -80,23 +80,6 @@ class _ViewOrderCardState extends State { } } - /// Formats the date string for display. - String _formatDate({required String dateStr}) { - try { - final DateTime date = DateTime.parse(dateStr); - final DateTime now = DateTime.now(); - final DateTime today = DateTime(now.year, now.month, now.day); - final DateTime tomorrow = today.add(const Duration(days: 1)); - final DateTime checkDate = DateTime(date.year, date.month, date.day); - - if (checkDate == today) return t.client_view_orders.card.today; - if (checkDate == tomorrow) return t.client_view_orders.card.tomorrow; - return DateFormat('EEE, MMM d').format(date); - } catch (_) { - return dateStr; - } - } - /// Formats the time string for display. String _formatTime({required String timeStr}) { if (timeStr.isEmpty) return ''; @@ -829,10 +812,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { final String dateText = orderDate == null ? widget.order.date : DateFormat('yyyy-MM-dd').format(orderDate); - final String location = firstShift.order.teamHub?.hubName ?? - firstShift.locationAddress ?? - firstShift.location ?? - widget.order.locationAddress; + final String location = firstShift.order.teamHub.hubName; _dateController.text = dateText; _globalLocationController.text = location; diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/view_orders_module.dart b/apps/mobile/packages/features/client/view_orders/lib/src/view_orders_module.dart index ceac0b36..b23db650 100644 --- a/apps/mobile/packages/features/client/view_orders/lib/src/view_orders_module.dart +++ b/apps/mobile/packages/features/client/view_orders/lib/src/view_orders_module.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; -import 'package:firebase_auth/firebase_auth.dart' as firebase; import 'data/repositories/view_orders_repository_impl.dart'; import 'domain/repositories/i_view_orders_repository.dart'; @@ -21,24 +20,14 @@ class ViewOrdersModule extends Module { @override void binds(Injector i) { // Repositories - i.add( - () => ViewOrdersRepositoryImpl( - firebaseAuth: firebase.FirebaseAuth.instance, - dataConnect: ExampleConnector.instance, - ), - ); + i.add(ViewOrdersRepositoryImpl.new); // UseCases i.add(GetOrdersUseCase.new); i.add(GetAcceptedApplicationsForDayUseCase.new); // BLoCs - i.add( - () => ViewOrdersCubit( - getOrdersUseCase: i.get(), - getAcceptedAppsUseCase: i.get(), - ), - ); + i.add(ViewOrdersCubit.new); } @override From da8192418f8339056b8a2a9754bf7764f9c6ff8c Mon Sep 17 00:00:00 2001 From: Suriya Date: Tue, 17 Feb 2026 16:23:10 +0530 Subject: [PATCH 40/53] fix(mobile): resolve client crash and shift status inconsistency --- .../lib/src/routing/client/route_paths.dart | 5 ++ .../lib/src/routing/staff/route_paths.dart | 5 ++ .../lib/src/data_connect_module.dart | 4 +- .../lib/client_authentication.dart | 4 +- .../auth_repository_impl.dart | 22 ++++++ .../auth_repository_interface.dart | 3 + .../presentation/pages/client_intro_page.dart | 63 ++++++++++++++++ .../auth_repository_impl.dart | 73 +++++++++++++++++++ .../auth_repository_interface.dart | 4 +- .../src/presentation/pages/intro_page.dart | 65 +++++++++++++++++ .../lib/src/staff_authentication_module.dart | 4 +- .../shifts_repository_impl.dart | 10 +++ .../connector/shiftRole/queries.gql | 2 + backend/dataconnect/schema/ShiftRole.gql | 2 + 14 files changed, 261 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart create mode 100644 apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/intro_page.dart diff --git a/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart b/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart index f7172e11..900bb545 100644 --- a/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart +++ b/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart @@ -41,6 +41,11 @@ class ClientPaths { /// This serves as the entry point for unauthenticated users. static const String root = '/'; + /// Get Started page (relative path within auth module). + /// + /// The landing page for unauthenticated users, offering login/signup options. + static const String getStarted = '/get-started'; + /// Sign-in page where existing clients can log into their account. /// /// Supports email/password and social authentication. diff --git a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart index 54e63c23..1b49991c 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart @@ -41,6 +41,11 @@ class StaffPaths { /// This serves as the entry point for unauthenticated staff members. static const String root = '/'; + /// Get Started page (relative path within auth module). + /// + /// The landing page for unauthenticated users, offering login/signup options. + static const String getStarted = '/get-started'; + /// Phone verification page (relative path within auth module). /// /// Used for both login and signup flows to verify phone numbers via OTP. diff --git a/apps/mobile/packages/data_connect/lib/src/data_connect_module.dart b/apps/mobile/packages/data_connect/lib/src/data_connect_module.dart index 8f7aa678..5704afb6 100644 --- a/apps/mobile/packages/data_connect/lib/src/data_connect_module.dart +++ b/apps/mobile/packages/data_connect/lib/src/data_connect_module.dart @@ -1,10 +1,10 @@ import 'package:flutter_modular/flutter_modular.dart'; +import 'services/data_connect_service.dart'; /// A module that provides Data Connect dependencies. class DataConnectModule extends Module { @override void exportedBinds(Injector i) { - // No mock bindings anymore. - // Real repositories are instantiated in their feature modules. + i.addInstance(DataConnectService.instance); } } diff --git a/apps/mobile/packages/features/client/authentication/lib/client_authentication.dart b/apps/mobile/packages/features/client/authentication/lib/client_authentication.dart index 1ee73543..5e4eec82 100644 --- a/apps/mobile/packages/features/client/authentication/lib/client_authentication.dart +++ b/apps/mobile/packages/features/client/authentication/lib/client_authentication.dart @@ -10,6 +10,7 @@ import 'src/domain/usecases/sign_in_with_social_use_case.dart'; import 'src/domain/usecases/sign_out_use_case.dart'; import 'src/domain/usecases/sign_up_with_email_use_case.dart'; import 'src/presentation/blocs/client_auth_bloc.dart'; +import 'src/presentation/pages/client_intro_page.dart'; import 'src/presentation/pages/client_get_started_page.dart'; import 'src/presentation/pages/client_sign_in_page.dart'; import 'src/presentation/pages/client_sign_up_page.dart'; @@ -54,7 +55,8 @@ class ClientAuthenticationModule extends Module { @override void routes(RouteManager r) { - r.child(ClientPaths.root, child: (_) => const ClientGetStartedPage()); + r.child(ClientPaths.root, child: (_) => const ClientIntroPage()); + r.child(ClientPaths.getStarted, child: (_) => const ClientGetStartedPage()); r.child(ClientPaths.signIn, child: (_) => const ClientSignInPage()); r.child(ClientPaths.signUp, child: (_) => const ClientSignUpPage()); } diff --git a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index 467a7c07..b64d9f71 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -414,4 +414,26 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { return domainUser; } + @override + Future restoreSession() async { + final firebase.User? firebaseUser = _service.auth.currentUser; + if (firebaseUser == null) { + return null; + } + + try { + return await _getUserProfile( + firebaseUserId: firebaseUser.uid, + fallbackEmail: firebaseUser.email, + requireBusinessRole: true, + ); + } catch (e) { + // If the user is not found or other permanent errors, we should probably sign out + if (e is UserNotFoundException || e is UnauthorizedAppException) { + await _service.auth.signOut(); + return null; + } + rethrow; + } + } } diff --git a/apps/mobile/packages/features/client/authentication/lib/src/domain/repositories/auth_repository_interface.dart b/apps/mobile/packages/features/client/authentication/lib/src/domain/repositories/auth_repository_interface.dart index 21a1830c..3dbc053f 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/domain/repositories/auth_repository_interface.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/domain/repositories/auth_repository_interface.dart @@ -34,4 +34,7 @@ abstract class AuthRepositoryInterface { /// Terminates the current user session and clears authentication tokens. Future signOut(); + + /// Restores the session if a user is already logged in. + Future restoreSession(); } diff --git a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart new file mode 100644 index 00000000..f866b43c --- /dev/null +++ b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart @@ -0,0 +1,63 @@ +import 'dart:async'; +import 'package:client_authentication/src/domain/repositories/auth_repository_interface.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; + +class ClientIntroPage extends StatefulWidget { + const ClientIntroPage({super.key}); + + @override + State createState() => _ClientIntroPageState(); +} + +class _ClientIntroPageState extends State { + @override + void initState() { + super.initState(); + _checkSession(); + } + + Future _checkSession() async { + // Check session immediately without artificial delay + if (!mounted) return; + + try { + final AuthRepositoryInterface authRepo = Modular.get(); + // Add a timeout to prevent infinite loading + final user = await authRepo.restoreSession().timeout( + const Duration(seconds: 5), + onTimeout: () { + throw TimeoutException('Session restore timed out'); + }, + ); + + if (mounted) { + if (user != null) { + Modular.to.navigate(ClientPaths.home); + } else { + Modular.to.navigate(ClientPaths.getStarted); + } + } + } catch (e) { + debugPrint('ClientIntroPage: Session check error: $e'); + if (mounted) { + Modular.to.navigate(ClientPaths.getStarted); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surface, + body: Center( + child: Image.asset( + 'assets/logo-blue.png', + package: 'design_system', + width: 120, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index b247880e..863d815f 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -257,4 +257,77 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { ); return domainUser; } + @override + Future restoreSession() async { + final User? firebaseUser = _service.auth.currentUser; + if (firebaseUser == null) { + return null; + } + + try { + // 1. Fetch User + final QueryResult response = + await _service.run(() => _service.connector + .getUserById( + id: firebaseUser.uid, + ) + .execute()); + final GetUserByIdUser? user = response.data.user; + + if (user == null) { + return null; + } + + // 2. Check Role + if (user.userRole != 'STAFF' && user.userRole != 'BOTH') { + return null; + } + + // 3. Fetch Staff Profile + final QueryResult + staffResponse = await _service.run(() => _service.connector + .getStaffByUserId( + userId: firebaseUser.uid, + ) + .execute()); + + if (staffResponse.data.staffs.isEmpty) { + return null; + } + + final GetStaffByUserIdStaffs staffRecord = staffResponse.data.staffs.first; + + // 4. Populate Session + final domain.User domainUser = domain.User( + id: firebaseUser.uid, + email: user.email ?? '', + phone: firebaseUser.phoneNumber, + role: user.role.stringValue, + ); + + final domain.Staff domainStaff = domain.Staff( + id: staffRecord.id, + authProviderId: staffRecord.userId, + name: staffRecord.fullName, + email: staffRecord.email ?? '', + phone: staffRecord.phone, + status: domain.StaffStatus.completedProfile, + address: staffRecord.addres, + avatar: staffRecord.photoUrl, + ); + + StaffSessionStore.instance.setSession( + StaffSession( + user: domainUser, + staff: domainStaff, + ownerId: staffRecord.ownerId, + ), + ); + + return domainUser; + } catch (e) { + // If restoration fails (network, etc), we rethrow to let UI handle it. + rethrow; + } + } } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart index 12e05413..e73be91d 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart @@ -20,5 +20,7 @@ abstract interface class AuthRepositoryInterface { /// Signs out the current user. Future signOut(); - // Future getStaffProfile(String userId); // Could be moved to a separate repository if needed, but useful here for routing logic. + + /// Restores the session if a user is already logged in. + Future restoreSession(); } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/intro_page.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/intro_page.dart new file mode 100644 index 00000000..6d27ee1b --- /dev/null +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/intro_page.dart @@ -0,0 +1,65 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart'; + +class IntroPage extends StatefulWidget { + const IntroPage({super.key}); + + @override + State createState() => _IntroPageState(); +} + +class _IntroPageState extends State { + @override + void initState() { + super.initState(); + _checkSession(); + } + + Future _checkSession() async { + // Check session immediately without artificial delay + if (!mounted) return; + + try { + final AuthRepositoryInterface authRepo = Modular.get(); + // Add a timeout to prevent infinite loading + final user = await authRepo.restoreSession().timeout( + const Duration(seconds: 5), + onTimeout: () { + // If it takes too long, navigate to Get Started. + // This handles poor network conditions gracefully. + throw TimeoutException('Session restore timed out'); + }, + ); + + if (mounted) { + if (user != null) { + Modular.to.navigate(StaffPaths.home); + } else { + Modular.to.navigate(StaffPaths.getStarted); + } + } + } catch (e) { + debugPrint('IntroPage: Session check error: $e'); + if (mounted) { + Modular.to.navigate(StaffPaths.getStarted); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surface, + body: Center( + child: Image.asset( + 'assets/logo-yellow.png', + package: 'design_system', + width: 120, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart b/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart index c5380d68..e0426496 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart @@ -14,6 +14,7 @@ import 'package:staff_authentication/src/data/repositories_impl/place_repository import 'package:staff_authentication/src/domain/usecases/search_cities_usecase.dart'; import 'package:staff_authentication/src/presentation/blocs/auth_bloc.dart'; import 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_bloc.dart'; +import 'package:staff_authentication/src/presentation/pages/intro_page.dart'; import 'package:staff_authentication/src/presentation/pages/get_started_page.dart'; import 'package:staff_authentication/src/presentation/pages/phone_verification_page.dart'; import 'package:staff_authentication/src/presentation/pages/profile_setup_page.dart'; @@ -54,7 +55,8 @@ class StaffAuthenticationModule extends Module { @override void routes(RouteManager r) { - r.child(StaffPaths.root, child: (_) => const GetStartedPage()); + r.child(StaffPaths.root, child: (_) => const IntroPage()); + r.child(StaffPaths.getStarted, child: (_) => const GetStartedPage()); r.child( StaffPaths.phoneVerification, child: (BuildContext context) { diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart index 64a112ca..9d799fcb 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart @@ -186,10 +186,20 @@ class ShiftsRepositoryImpl final fdc.QueryResult result = await _service.executeProtected(() => _service.connector .listShiftRolesByVendorId(vendorId: vendorId) .execute()); + + + final allShiftRoles = result.data.shiftRoles; + // Fetch my applications to filter out already booked shifts + final List myShifts = await _fetchApplications(); + final Set myShiftIds = myShifts.map((s) => s.id).toSet(); + final List mappedShifts = []; for (final sr in allShiftRoles) { + // Skip if I have already applied/booked this shift + if (myShiftIds.contains(sr.shiftId)) continue; + final DateTime? shiftDate = _service.toDateTime(sr.shift.date); final startDt = _service.toDateTime(sr.startTime); diff --git a/backend/dataconnect/connector/shiftRole/queries.gql b/backend/dataconnect/connector/shiftRole/queries.gql index c1569213..ffba13ae 100644 --- a/backend/dataconnect/connector/shiftRole/queries.gql +++ b/backend/dataconnect/connector/shiftRole/queries.gql @@ -247,6 +247,7 @@ query listShiftRolesByShiftIdAndTimeRange( # ------------------------------------------------------------ query listShiftRolesByVendorId( $vendorId: UUID! + $offset: Int $limit: Int ) @auth(level: USER) { @@ -313,6 +314,7 @@ query listShiftRolesByVendorId( vendor { id companyName } } } + } } diff --git a/backend/dataconnect/schema/ShiftRole.gql b/backend/dataconnect/schema/ShiftRole.gql index 94470ebd..57c742b4 100644 --- a/backend/dataconnect/schema/ShiftRole.gql +++ b/backend/dataconnect/schema/ShiftRole.gql @@ -33,4 +33,6 @@ type ShiftRole @table(name: "shift_roles", key: ["shiftId", "roleId"]) { createdAt: Timestamp @default(expr: "request.time") updatedAt: Timestamp @default(expr: "request.time") + + } From e36cb09b73e94e8296045252e9b174a2c7fa2f2c Mon Sep 17 00:00:00 2001 From: Gokulraj Date: Tue, 17 Feb 2026 16:53:21 +0530 Subject: [PATCH 41/53] Inconsistent Shift Booking Status --- apps/mobile/pubspec.lock | 32 ++--- .../dataconnect/connector/shift/mutations.gql | 134 ------------------ 2 files changed, 12 insertions(+), 154 deletions(-) delete mode 100644 backend/dataconnect/connector/shift/mutations.gql diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 25c3fd23..f30d02fc 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -141,10 +141,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -741,14 +741,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - js: - dependency: transitive - description: - name: js - sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.dev" - source: hosted - version: "0.7.2" json_annotation: dependency: transitive description: @@ -817,18 +809,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" melos: dependency: "direct dev" description: @@ -1326,26 +1318,26 @@ packages: dependency: transitive description: name: test - sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a" url: "https://pub.dev" source: hosted - version: "1.26.3" + version: "1.29.0" test_api: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.9" test_core: dependency: transitive description: name: test_core - sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943" url: "https://pub.dev" source: hosted - version: "0.6.12" + version: "0.6.15" typed_data: dependency: transitive description: diff --git a/backend/dataconnect/connector/shift/mutations.gql b/backend/dataconnect/connector/shift/mutations.gql deleted file mode 100644 index 0a81f9bc..00000000 --- a/backend/dataconnect/connector/shift/mutations.gql +++ /dev/null @@ -1,134 +0,0 @@ - -mutation createShift( - $title: String! - $orderId: UUID! - - $date: Timestamp - $startTime: Timestamp - $endTime: Timestamp - $hours: Float - $cost: Float - - $location: String - $locationAddress: String - $latitude: Float - $longitude: Float - $placeId: String - $city: String - $state: String - $street: String - $country: String - $description: String - - $status: ShiftStatus - $workersNeeded: Int - $filled: Int - $filledAt: Timestamp - - $managers: [Any!] - $durationDays: Int - - $createdBy: String -) @auth(level: USER) { - shift_insert( - data: { - title: $title - orderId: $orderId - - date: $date - startTime: $startTime - endTime: $endTime - hours: $hours - cost: $cost - - location: $location - locationAddress: $locationAddress - latitude: $latitude - longitude: $longitude - placeId: $placeId - city: $city - state: $state - street: $street - country: $country - description: $description - - status: $status - workersNeeded: $workersNeeded - filled: $filled - filledAt: $filledAt - - managers: $managers - durationDays: $durationDays - } - ) -} - -mutation updateShift( - $id: UUID! - $title: String - $orderId: UUID - - $date: Timestamp - $startTime: Timestamp - $endTime: Timestamp - $hours: Float - $cost: Float - - $location: String - $locationAddress: String - $latitude: Float - $longitude: Float - $placeId: String - $city: String - $state: String - $street: String - $country: String - $description: String - - $status: ShiftStatus - $workersNeeded: Int - $filled: Int - $filledAt: Timestamp - - $managers: [Any!] - $durationDays: Int - -) @auth(level: USER) { - shift_update( - id: $id - data: { - title: $title - orderId: $orderId - - date: $date - startTime: $startTime - endTime: $endTime - hours: $hours - cost: $cost - - location: $location - locationAddress: $locationAddress - latitude: $latitude - longitude: $longitude - placeId: $placeId - city: $city - state: $state - street: $street - country: $country - description: $description - - status: $status - workersNeeded: $workersNeeded - filled: $filled - filledAt: $filledAt - - managers: $managers - durationDays: $durationDays - - } - ) -} - -mutation deleteShift($id: UUID!) @auth(level: USER) { - shift_delete(id: $id) -} From caec0d859b5f64cc048430c0685a9bcbe6b1ae1c Mon Sep 17 00:00:00 2001 From: Gokulraj Date: Tue, 17 Feb 2026 19:25:47 +0530 Subject: [PATCH 42/53] shift mutation file --- .../dataconnect/connector/shift/mutations.gql | 134 ++++++++++++++++++ internal/api-harness/src/api/krowSDK.js | 13 +- 2 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 backend/dataconnect/connector/shift/mutations.gql diff --git a/backend/dataconnect/connector/shift/mutations.gql b/backend/dataconnect/connector/shift/mutations.gql new file mode 100644 index 00000000..0a81f9bc --- /dev/null +++ b/backend/dataconnect/connector/shift/mutations.gql @@ -0,0 +1,134 @@ + +mutation createShift( + $title: String! + $orderId: UUID! + + $date: Timestamp + $startTime: Timestamp + $endTime: Timestamp + $hours: Float + $cost: Float + + $location: String + $locationAddress: String + $latitude: Float + $longitude: Float + $placeId: String + $city: String + $state: String + $street: String + $country: String + $description: String + + $status: ShiftStatus + $workersNeeded: Int + $filled: Int + $filledAt: Timestamp + + $managers: [Any!] + $durationDays: Int + + $createdBy: String +) @auth(level: USER) { + shift_insert( + data: { + title: $title + orderId: $orderId + + date: $date + startTime: $startTime + endTime: $endTime + hours: $hours + cost: $cost + + location: $location + locationAddress: $locationAddress + latitude: $latitude + longitude: $longitude + placeId: $placeId + city: $city + state: $state + street: $street + country: $country + description: $description + + status: $status + workersNeeded: $workersNeeded + filled: $filled + filledAt: $filledAt + + managers: $managers + durationDays: $durationDays + } + ) +} + +mutation updateShift( + $id: UUID! + $title: String + $orderId: UUID + + $date: Timestamp + $startTime: Timestamp + $endTime: Timestamp + $hours: Float + $cost: Float + + $location: String + $locationAddress: String + $latitude: Float + $longitude: Float + $placeId: String + $city: String + $state: String + $street: String + $country: String + $description: String + + $status: ShiftStatus + $workersNeeded: Int + $filled: Int + $filledAt: Timestamp + + $managers: [Any!] + $durationDays: Int + +) @auth(level: USER) { + shift_update( + id: $id + data: { + title: $title + orderId: $orderId + + date: $date + startTime: $startTime + endTime: $endTime + hours: $hours + cost: $cost + + location: $location + locationAddress: $locationAddress + latitude: $latitude + longitude: $longitude + placeId: $placeId + city: $city + state: $state + street: $street + country: $country + description: $description + + status: $status + workersNeeded: $workersNeeded + filled: $filled + filledAt: $filledAt + + managers: $managers + durationDays: $durationDays + + } + ) +} + +mutation deleteShift($id: UUID!) @auth(level: USER) { + shift_delete(id: $id) +} diff --git a/internal/api-harness/src/api/krowSDK.js b/internal/api-harness/src/api/krowSDK.js index 7c9ec177..c47392ed 100644 --- a/internal/api-harness/src/api/krowSDK.js +++ b/internal/api-harness/src/api/krowSDK.js @@ -303,14 +303,19 @@ const dataconnectEntityConfig = { Order:{ list: 'listOrder', get: 'getOrderById', - create: 'UpdateOrder', - update: 'updateEnterprise', - delete: 'deleteEnterprise', + create: 'createOrder', + update: 'updateOrder', + delete: 'deleteOrder', filter: 'filterOrder', }, Shift:{ - + list: 'listShifts', + get: 'getShiftById', + create: 'createShift', + update: 'updateShift', + delete: 'deleteShift', + filter: 'filterShifts', } }; From 9e1af17328e288dd6e5571b79fc5cb9ae1eae954 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Feb 2026 12:05:24 -0500 Subject: [PATCH 43/53] feat: Refactor bank account handling in billing and staff modules - Introduced new bank account entities: BusinessBankAccount and StaffBankAccount. - Updated bank account adapter to handle new entities. - Removed legacy BankAccount entity and its adapter. - Implemented use case for fetching bank accounts in billing repository. - Updated BillingBloc and BillingState to include bank accounts. - Refactored PaymentMethodCard to display bank account information. - Adjusted actions widget layout for better UI consistency. - Updated staff bank account repository and use cases to utilize new entity structure. - Ensured all references to bank accounts in the codebase are updated to the new structure. --- .../packages/domain/lib/krow_domain.dart | 5 +- .../bank_account/bank_account_adapter.dart | 21 ++ .../profile/bank_account_adapter.dart | 28 +- .../financial/bank_account/bank_account.dart | 27 ++ .../bank_account/business_bank_account.dart | 26 ++ .../bank_account/staff_bank_account.dart | 48 ++++ .../src/entities/profile/bank_account.dart | 53 ---- .../billing/lib/src/billing_module.dart | 3 + .../billing_repository_impl.dart | 29 +++ .../repositories/billing_repository.dart | 3 + .../domain/usecases/get_bank_accounts.dart | 14 + .../src/presentation/blocs/billing_bloc.dart | 10 +- .../src/presentation/blocs/billing_state.dart | 8 + .../src/presentation/pages/billing_page.dart | 58 +++-- .../widgets/payment_method_card.dart | 245 ++++++++---------- .../presentation/widgets/actions_widget.dart | 13 +- .../bank_account_repository_impl.dart | 7 +- .../arguments/add_bank_account_params.dart | 2 +- .../repositories/bank_account_repository.dart | 4 +- .../usecases/get_bank_accounts_usecase.dart | 4 +- .../blocs/bank_account_cubit.dart | 43 ++- .../blocs/bank_account_state.dart | 4 +- .../presentation/pages/bank_account_page.dart | 4 +- apps/mobile/pubspec.lock | 32 ++- makefiles/mobile.mk | 6 +- 25 files changed, 399 insertions(+), 298 deletions(-) create mode 100644 apps/mobile/packages/domain/lib/src/adapters/financial/bank_account/bank_account_adapter.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/financial/bank_account/bank_account.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/financial/bank_account/business_bank_account.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/financial/bank_account/staff_bank_account.dart delete mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/bank_account.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_bank_accounts.dart diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index bbe513ae..d3b2ac2a 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -53,6 +53,10 @@ export 'src/entities/financial/invoice_item.dart'; export 'src/entities/financial/invoice_decline.dart'; export 'src/entities/financial/staff_payment.dart'; export 'src/entities/financial/payment_summary.dart'; +export 'src/entities/financial/bank_account/bank_account.dart'; +export 'src/entities/financial/bank_account/business_bank_account.dart'; +export 'src/entities/financial/bank_account/staff_bank_account.dart'; +export 'src/adapters/financial/bank_account/bank_account_adapter.dart'; // Profile export 'src/entities/profile/staff_document.dart'; @@ -68,7 +72,6 @@ export 'src/entities/ratings/business_staff_preference.dart'; // Staff Profile export 'src/entities/profile/emergency_contact.dart'; -export 'src/entities/profile/bank_account.dart'; export 'src/entities/profile/accessibility.dart'; export 'src/entities/profile/schedule.dart'; diff --git a/apps/mobile/packages/domain/lib/src/adapters/financial/bank_account/bank_account_adapter.dart b/apps/mobile/packages/domain/lib/src/adapters/financial/bank_account/bank_account_adapter.dart new file mode 100644 index 00000000..167d1126 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/adapters/financial/bank_account/bank_account_adapter.dart @@ -0,0 +1,21 @@ +import '../../../entities/financial/bank_account/business_bank_account.dart'; + +/// Adapter for [BusinessBankAccount] to map data layer values to domain entity. +class BusinessBankAccountAdapter { + /// Maps primitive values to [BusinessBankAccount]. + static BusinessBankAccount fromPrimitives({ + required String id, + required String bank, + required String last4, + required bool isPrimary, + DateTime? expiryTime, + }) { + return BusinessBankAccount( + id: id, + bankName: bank, + last4: last4, + isPrimary: isPrimary, + expiryTime: expiryTime, + ); + } +} diff --git a/apps/mobile/packages/domain/lib/src/adapters/profile/bank_account_adapter.dart b/apps/mobile/packages/domain/lib/src/adapters/profile/bank_account_adapter.dart index 6b285b8a..133da163 100644 --- a/apps/mobile/packages/domain/lib/src/adapters/profile/bank_account_adapter.dart +++ b/apps/mobile/packages/domain/lib/src/adapters/profile/bank_account_adapter.dart @@ -1,9 +1,9 @@ -import '../../entities/profile/bank_account.dart'; +import '../../entities/financial/bank_account/staff_bank_account.dart'; -/// Adapter for [BankAccount] to map data layer values to domain entity. +/// Adapter for [StaffBankAccount] to map data layer values to domain entity. class BankAccountAdapter { - /// Maps primitive values to [BankAccount]. - static BankAccount fromPrimitives({ + /// Maps primitive values to [StaffBankAccount]. + static StaffBankAccount fromPrimitives({ required String id, required String userId, required String bankName, @@ -13,7 +13,7 @@ class BankAccountAdapter { String? sortCode, bool? isPrimary, }) { - return BankAccount( + return StaffBankAccount( id: id, userId: userId, bankName: bankName, @@ -26,25 +26,25 @@ class BankAccountAdapter { ); } - static BankAccountType _stringToType(String? value) { - if (value == null) return BankAccountType.checking; + static StaffBankAccountType _stringToType(String? value) { + if (value == null) return StaffBankAccountType.checking; try { // Assuming backend enum names match or are uppercase - return BankAccountType.values.firstWhere( - (e) => e.name.toLowerCase() == value.toLowerCase(), - orElse: () => BankAccountType.other, + return StaffBankAccountType.values.firstWhere( + (StaffBankAccountType e) => e.name.toLowerCase() == value.toLowerCase(), + orElse: () => StaffBankAccountType.other, ); } catch (_) { - return BankAccountType.other; + return StaffBankAccountType.other; } } /// Converts domain type to string for backend. - static String typeToString(BankAccountType type) { + static String typeToString(StaffBankAccountType type) { switch (type) { - case BankAccountType.checking: + case StaffBankAccountType.checking: return 'CHECKING'; - case BankAccountType.savings: + case StaffBankAccountType.savings: return 'SAVINGS'; default: return 'CHECKING'; diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/bank_account.dart b/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/bank_account.dart new file mode 100644 index 00000000..04af8402 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/bank_account.dart @@ -0,0 +1,27 @@ +import 'package:equatable/equatable.dart'; + +/// Abstract base class for all types of bank accounts. +abstract class BankAccount extends Equatable { + /// Creates a [BankAccount]. + const BankAccount({ + required this.id, + required this.bankName, + required this.isPrimary, + this.last4, + }); + + /// Unique identifier. + final String id; + + /// Name of the bank or provider. + final String bankName; + + /// Whether this is the primary payment method. + final bool isPrimary; + + /// Last 4 digits of the account/card. + final String? last4; + + @override + List get props => [id, bankName, isPrimary, last4]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/business_bank_account.dart b/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/business_bank_account.dart new file mode 100644 index 00000000..8ad3d48e --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/business_bank_account.dart @@ -0,0 +1,26 @@ +import 'bank_account.dart'; + +/// Domain model representing a business bank account or payment method. +class BusinessBankAccount extends BankAccount { + /// Creates a [BusinessBankAccount]. + const BusinessBankAccount({ + required super.id, + required super.bankName, + required String last4, + required super.isPrimary, + this.expiryTime, + }) : super(last4: last4); + + /// Expiration date if applicable. + final DateTime? expiryTime; + + @override + List get props => [ + ...super.props, + expiryTime, + ]; + + /// Getter for non-nullable last4 in Business context. + @override + String get last4 => super.last4!; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/staff_bank_account.dart b/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/staff_bank_account.dart new file mode 100644 index 00000000..3f2f034e --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/staff_bank_account.dart @@ -0,0 +1,48 @@ +import 'bank_account.dart'; + +/// Type of staff bank account. +enum StaffBankAccountType { + /// Checking account. + checking, + + /// Savings account. + savings, + + /// Other type. + other, +} + +/// Domain entity representing a staff's bank account. +class StaffBankAccount extends BankAccount { + /// Creates a [StaffBankAccount]. + const StaffBankAccount({ + required super.id, + required this.userId, + required super.bankName, + required this.accountNumber, + required this.accountName, + required super.isPrimary, + super.last4, + this.sortCode, + this.type = StaffBankAccountType.checking, + }); + + /// User identifier. + final String userId; + + /// Full account number. + final String accountNumber; + + /// Name of the account holder. + final String accountName; + + /// Sort code (optional). + final String? sortCode; + + /// Account type. + final StaffBankAccountType type; + + @override + List get props => + [...super.props, userId, accountNumber, accountName, sortCode, type]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/bank_account.dart b/apps/mobile/packages/domain/lib/src/entities/profile/bank_account.dart deleted file mode 100644 index deca9a28..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/profile/bank_account.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Account type (Checking, Savings, etc). -enum BankAccountType { - checking, - savings, - other, -} - -/// Represents bank account details for payroll. -class BankAccount extends Equatable { - - const BankAccount({ - required this.id, - required this.userId, - required this.bankName, - required this.accountNumber, - required this.accountName, - this.sortCode, - this.type = BankAccountType.checking, - this.isPrimary = false, - this.last4, - }); - /// Unique identifier. - final String id; - - /// The [User] owning the account. - final String userId; - - /// Name of the bank. - final String bankName; - - /// Account number. - final String accountNumber; - - /// Name on the account. - final String accountName; - - /// Sort code (if applicable). - final String? sortCode; - - /// Type of account. - final BankAccountType type; - - /// Whether this is the primary account. - final bool isPrimary; - - /// Last 4 digits. - final String? last4; - - @override - List get props => [id, userId, bankName, accountNumber, accountName, sortCode, type, isPrimary, last4]; -} \ No newline at end of file diff --git a/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart b/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart index 8c639cb3..1acdc69b 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart @@ -3,6 +3,7 @@ import 'package:krow_core/core.dart'; import 'data/repositories_impl/billing_repository_impl.dart'; import 'domain/repositories/billing_repository.dart'; +import 'domain/usecases/get_bank_accounts.dart'; import 'domain/usecases/get_current_bill_amount.dart'; import 'domain/usecases/get_invoice_history.dart'; import 'domain/usecases/get_pending_invoices.dart'; @@ -21,6 +22,7 @@ class BillingModule extends Module { i.addSingleton(BillingRepositoryImpl.new); // Use Cases + i.addSingleton(GetBankAccountsUseCase.new); i.addSingleton(GetCurrentBillAmountUseCase.new); i.addSingleton(GetSavingsAmountUseCase.new); i.addSingleton(GetPendingInvoicesUseCase.new); @@ -30,6 +32,7 @@ class BillingModule extends Module { // BLoCs i.addSingleton( () => BillingBloc( + getBankAccounts: i.get(), getCurrentBillAmount: i.get(), getSavingsAmount: i.get(), getPendingInvoices: i.get(), diff --git a/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart b/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart index d0441b26..95578127 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart @@ -16,6 +16,23 @@ class BillingRepositoryImpl implements BillingRepository { final data_connect.DataConnectService _service; + /// Fetches bank accounts associated with the business. + @override + Future> getBankAccounts() async { + return _service.run(() async { + final String businessId = await _service.getBusinessId(); + + final fdc.QueryResult< + data_connect.GetAccountsByOwnerIdData, + data_connect.GetAccountsByOwnerIdVariables> result = + await _service.connector + .getAccountsByOwnerId(ownerId: businessId) + .execute(); + + return result.data.accounts.map(_mapBankAccount).toList(); + }); + } + /// Fetches the current bill amount by aggregating open invoices. @override Future getCurrentBillAmount() async { @@ -182,6 +199,18 @@ class BillingRepositoryImpl implements BillingRepository { ); } + BusinessBankAccount _mapBankAccount( + data_connect.GetAccountsByOwnerIdAccounts account, + ) { + return BusinessBankAccountAdapter.fromPrimitives( + id: account.id, + bank: account.bank, + last4: account.last4, + isPrimary: account.isPrimary ?? false, + expiryTime: _service.toDateTime(account.expiryTime), + ); + } + InvoiceStatus _mapInvoiceStatus( data_connect.EnumValue status, ) { diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart index 4a9300d3..d631a40b 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart @@ -7,6 +7,9 @@ import '../models/billing_period.dart'; /// acting as a boundary between the Domain and Data layers. /// It allows the Domain layer to remain independent of specific data sources. abstract class BillingRepository { + /// Fetches bank accounts associated with the business. + Future> getBankAccounts(); + /// Fetches invoices that are pending approval or payment. Future> getPendingInvoices(); diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_bank_accounts.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_bank_accounts.dart new file mode 100644 index 00000000..23a52f38 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_bank_accounts.dart @@ -0,0 +1,14 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../repositories/billing_repository.dart'; + +/// Use case for fetching the bank accounts associated with the business. +class GetBankAccountsUseCase extends NoInputUseCase> { + /// Creates a [GetBankAccountsUseCase]. + GetBankAccountsUseCase(this._repository); + + final BillingRepository _repository; + + @override + Future> call() => _repository.getBankAccounts(); +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart index ccddda07..ee88ed63 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart @@ -1,6 +1,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +import '../../domain/usecases/get_bank_accounts.dart'; import '../../domain/usecases/get_current_bill_amount.dart'; import '../../domain/usecases/get_invoice_history.dart'; import '../../domain/usecases/get_pending_invoices.dart'; @@ -16,12 +17,14 @@ class BillingBloc extends Bloc with BlocErrorHandler { /// Creates a [BillingBloc] with the given use cases. BillingBloc({ + required GetBankAccountsUseCase getBankAccounts, required GetCurrentBillAmountUseCase getCurrentBillAmount, required GetSavingsAmountUseCase getSavingsAmount, required GetPendingInvoicesUseCase getPendingInvoices, required GetInvoiceHistoryUseCase getInvoiceHistory, required GetSpendingBreakdownUseCase getSpendingBreakdown, - }) : _getCurrentBillAmount = getCurrentBillAmount, + }) : _getBankAccounts = getBankAccounts, + _getCurrentBillAmount = getCurrentBillAmount, _getSavingsAmount = getSavingsAmount, _getPendingInvoices = getPendingInvoices, _getInvoiceHistory = getInvoiceHistory, @@ -31,6 +34,7 @@ class BillingBloc extends Bloc on(_onPeriodChanged); } + final GetBankAccountsUseCase _getBankAccounts; final GetCurrentBillAmountUseCase _getCurrentBillAmount; final GetSavingsAmountUseCase _getSavingsAmount; final GetPendingInvoicesUseCase _getPendingInvoices; @@ -52,12 +56,15 @@ class BillingBloc extends Bloc _getPendingInvoices.call(), _getInvoiceHistory.call(), _getSpendingBreakdown.call(state.period), + _getBankAccounts.call(), ]); final double savings = results[1] as double; final List pendingInvoices = results[2] as List; final List invoiceHistory = results[3] as List; final List spendingItems = results[4] as List; + final List bankAccounts = + results[5] as List; // Map Domain Entities to Presentation Models final List uiPendingInvoices = @@ -79,6 +86,7 @@ class BillingBloc extends Bloc pendingInvoices: uiPendingInvoices, invoiceHistory: uiInvoiceHistory, spendingBreakdown: uiSpendingBreakdown, + bankAccounts: bankAccounts, ), ); }, diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_state.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_state.dart index d983728d..ef3ba019 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_state.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_state.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; import '../../domain/models/billing_period.dart'; import '../models/billing_invoice_model.dart'; import '../models/spending_breakdown_model.dart'; @@ -28,6 +29,7 @@ class BillingState extends Equatable { this.pendingInvoices = const [], this.invoiceHistory = const [], this.spendingBreakdown = const [], + this.bankAccounts = const [], this.period = BillingPeriod.week, this.errorMessage, }); @@ -50,6 +52,9 @@ class BillingState extends Equatable { /// Breakdown of spending by category. final List spendingBreakdown; + /// Bank accounts associated with the business. + final List bankAccounts; + /// Selected period for the breakdown. final BillingPeriod period; @@ -64,6 +69,7 @@ class BillingState extends Equatable { List? pendingInvoices, List? invoiceHistory, List? spendingBreakdown, + List? bankAccounts, BillingPeriod? period, String? errorMessage, }) { @@ -74,6 +80,7 @@ class BillingState extends Equatable { pendingInvoices: pendingInvoices ?? this.pendingInvoices, invoiceHistory: invoiceHistory ?? this.invoiceHistory, spendingBreakdown: spendingBreakdown ?? this.spendingBreakdown, + bankAccounts: bankAccounts ?? this.bankAccounts, period: period ?? this.period, errorMessage: errorMessage ?? this.errorMessage, ); @@ -87,6 +94,7 @@ class BillingState extends Equatable { pendingInvoices, invoiceHistory, spendingBreakdown, + bankAccounts, period, errorMessage, ]; diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart index 6a1c2832..4771b744 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart @@ -71,19 +71,20 @@ class _BillingViewState extends State { @override Widget build(BuildContext context) { - return BlocConsumer( - listener: (BuildContext context, BillingState state) { - if (state.status == BillingStatus.failure && state.errorMessage != null) { - UiSnackbar.show( - context, - message: translateErrorKey(state.errorMessage!), - type: UiSnackbarType.error, - ); - } - }, - builder: (BuildContext context, BillingState state) { - return Scaffold( - body: CustomScrollView( + return Scaffold( + body: BlocConsumer( + listener: (BuildContext context, BillingState state) { + if (state.status == BillingStatus.failure && + state.errorMessage != null) { + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, + ); + } + }, + builder: (BuildContext context, BillingState state) { + return CustomScrollView( controller: _scrollController, slivers: [ SliverAppBar( @@ -97,7 +98,7 @@ class _BillingViewState extends State { leading: Center( child: UiIconButton.secondary( icon: UiIcons.arrowLeft, - onTap: () => Modular.to.toClientHome() + onTap: () => Modular.to.toClientHome(), ), ), title: AnimatedSwitcher( @@ -132,8 +133,9 @@ class _BillingViewState extends State { const SizedBox(height: UiConstants.space1), Text( '\$${state.currentBill.toStringAsFixed(2)}', - style: UiTypography.display1b - .copyWith(color: UiColors.white), + style: UiTypography.display1b.copyWith( + color: UiColors.white, + ), ), const SizedBox(height: UiConstants.space2), Container( @@ -171,16 +173,14 @@ class _BillingViewState extends State { ), ), SliverList( - delegate: SliverChildListDelegate( - [ - _buildContent(context, state), - ], - ), + delegate: SliverChildListDelegate([ + _buildContent(context, state), + ]), ), ], - ), - ); - }, + ); + }, + ), ); } @@ -211,7 +211,9 @@ class _BillingViewState extends State { const SizedBox(height: UiConstants.space4), UiButton.secondary( text: 'Retry', - onPressed: () => BlocProvider.of(context).add(const BillingLoadStarted()), + onPressed: () => BlocProvider.of( + context, + ).add(const BillingLoadStarted()), ), ], ), @@ -230,8 +232,10 @@ class _BillingViewState extends State { ], const PaymentMethodCard(), const SpendingBreakdownCard(), - if (state.invoiceHistory.isEmpty) _buildEmptyState(context) - else InvoiceHistorySection(invoices: state.invoiceHistory), + if (state.invoiceHistory.isEmpty) + _buildEmptyState(context) + else + InvoiceHistorySection(invoices: state.invoiceHistory), const SizedBox(height: UiConstants.space32), ], diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/payment_method_card.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/payment_method_card.dart index 4f1c569b..346380e7 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/payment_method_card.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/payment_method_card.dart @@ -1,166 +1,133 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; -import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; import 'package:flutter/material.dart'; -import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../blocs/billing_bloc.dart'; +import '../blocs/billing_state.dart'; /// Card showing the current payment method. -class PaymentMethodCard extends StatefulWidget { +class PaymentMethodCard extends StatelessWidget { /// Creates a [PaymentMethodCard]. const PaymentMethodCard({super.key}); - @override - State createState() => _PaymentMethodCardState(); -} - -class _PaymentMethodCardState extends State { - late final Future _accountsFuture = - _loadAccounts(); - - Future _loadAccounts() async { - final String? businessId = - dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - return null; - } - - final fdc.QueryResult< - dc.GetAccountsByOwnerIdData, - dc.GetAccountsByOwnerIdVariables - > - result = await dc.ExampleConnector.instance - .getAccountsByOwnerId(ownerId: businessId) - .execute(); - return result.data; - } - @override Widget build(BuildContext context) { - return FutureBuilder( - future: _accountsFuture, - builder: - ( - BuildContext context, - AsyncSnapshot snapshot, - ) { - final List accounts = - snapshot.data?.accounts ?? []; - final dc.GetAccountsByOwnerIdAccounts? account = accounts.isNotEmpty - ? accounts.first - : null; + return BlocBuilder( + builder: (BuildContext context, BillingState state) { + final List accounts = state.bankAccounts; + final BusinessBankAccount? account = + accounts.isNotEmpty ? accounts.first : null; - if (account == null) { - return const SizedBox.shrink(); - } + if (account == null) { + return const SizedBox.shrink(); + } - final String bankLabel = account.bank.isNotEmpty == true - ? account.bank - : '----'; - final String last4 = account.last4.isNotEmpty == true - ? account.last4 - : '----'; - final bool isPrimary = account.isPrimary ?? false; - final String expiryLabel = _formatExpiry(account.expiryTime); + final String bankLabel = + account.bankName.isNotEmpty == true ? account.bankName : '----'; + final String last4 = + account.last4.isNotEmpty == true ? account.last4 : '----'; + final bool isPrimary = account.isPrimary; + final String expiryLabel = _formatExpiry(account.expiryTime); - return Container( - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), - boxShadow: [ - BoxShadow( - color: UiColors.black.withValues(alpha: 0.04), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + boxShadow: [ + BoxShadow( + color: UiColors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), ), - child: Column( + ], + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - t.client_billing.payment_method, - style: UiTypography.title2b.textPrimary, - ), - const SizedBox.shrink(), - ], - ), - const SizedBox(height: UiConstants.space3), - Container( - padding: const EdgeInsets.all(UiConstants.space3), - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: UiConstants.radiusMd, - ), - child: Row( - children: [ - Container( - width: UiConstants.space10, - height: UiConstants.space6 + 4, - decoration: BoxDecoration( - color: UiColors.primary, - borderRadius: UiConstants.radiusSm, - ), - child: Center( - child: Text( - bankLabel, - style: UiTypography.footnote2b.white, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '•••• $last4', - style: UiTypography.body2b.textPrimary, - ), - Text( - t.client_billing.expires(date: expiryLabel), - style: UiTypography.footnote2r.textSecondary, - ), - ], - ), - ), - if (isPrimary) - Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: UiConstants.space1, - ), - decoration: BoxDecoration( - color: UiColors.accent, - borderRadius: UiConstants.radiusSm, - ), - child: Text( - t.client_billing.default_badge, - style: UiTypography.titleUppercase4b.textPrimary, - ), - ), - ], - ), + Text( + t.client_billing.payment_method, + style: UiTypography.title2b.textPrimary, ), + const SizedBox.shrink(), ], ), - ); - }, + const SizedBox(height: UiConstants.space3), + Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: UiConstants.radiusMd, + ), + child: Row( + children: [ + Container( + width: UiConstants.space10, + height: UiConstants.space6 + 4, + decoration: BoxDecoration( + color: UiColors.primary, + borderRadius: UiConstants.radiusSm, + ), + child: Center( + child: Text( + bankLabel, + style: UiTypography.footnote2b.white, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '•••• $last4', + style: UiTypography.body2b.textPrimary, + ), + Text( + t.client_billing.expires(date: expiryLabel), + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), + ), + if (isPrimary) + Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: UiConstants.space1, + ), + decoration: BoxDecoration( + color: UiColors.accent, + borderRadius: UiConstants.radiusSm, + ), + child: Text( + t.client_billing.default_badge, + style: UiTypography.titleUppercase4b.textPrimary, + ), + ), + ], + ), + ), + ], + ), + ); + }, ); } - String _formatExpiry(fdc.Timestamp? expiryTime) { + String _formatExpiry(DateTime? expiryTime) { if (expiryTime == null) { return 'N/A'; } - final DateTime date = expiryTime.toDateTime(); - final String month = date.month.toString().padLeft(2, '0'); - final String year = (date.year % 100).toString().padLeft(2, '0'); + final String month = expiryTime.month.toString().padLeft(2, '0'); + final String year = (expiryTime.year % 100).toString().padLeft(2, '0'); return '$month/$year'; } } diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/actions_widget.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/actions_widget.dart index 04a420b7..3af93fc3 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/actions_widget.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/actions_widget.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; /// A widget that displays quick actions for the client. class ActionsWidget extends StatelessWidget { - /// Creates an [ActionsWidget]. const ActionsWidget({ super.key, @@ -12,6 +11,7 @@ class ActionsWidget extends StatelessWidget { required this.onCreateOrderPressed, this.subtitle, }); + /// Callback when RAPID is pressed. final VoidCallback onRapidPressed; @@ -26,12 +26,9 @@ class ActionsWidget extends StatelessWidget { // Check if client_home exists in t final TranslationsClientHomeActionsEn i18n = t.client_home.actions; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Row( + spacing: UiConstants.space4, children: [ - Row( - children: [ - /// TODO: FEATURE_NOT_YET_IMPLEMENTED Expanded( child: _ActionCard( title: i18n.rapid, @@ -46,7 +43,6 @@ class ActionsWidget extends StatelessWidget { onTap: onRapidPressed, ), ), - // const SizedBox(width: UiConstants.space2), Expanded( child: _ActionCard( title: i18n.create_order, @@ -62,14 +58,11 @@ class ActionsWidget extends StatelessWidget { ), ), ], - ), - ], ); } } class _ActionCard extends StatelessWidget { - const _ActionCard({ required this.title, required this.subtitle, diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/data/repositories/bank_account_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/data/repositories/bank_account_repository_impl.dart index 14614b66..b029f4ed 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/data/repositories/bank_account_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/data/repositories/bank_account_repository_impl.dart @@ -14,13 +14,10 @@ class BankAccountRepositoryImpl implements BankAccountRepository { final DataConnectService _service; @override - Future> getAccounts() async { + Future> getAccounts() async { return _service.run(() async { final String staffId = await _service.getStaffId(); - var x = staffId; - - print(x); final QueryResult result = await _service.connector .getAccountsByOwnerId(ownerId: staffId) @@ -44,7 +41,7 @@ class BankAccountRepositoryImpl implements BankAccountRepository { } @override - Future addAccount(BankAccount account) async { + Future addAccount(StaffBankAccount account) async { return _service.run(() async { final String staffId = await _service.getStaffId(); diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/arguments/add_bank_account_params.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/arguments/add_bank_account_params.dart index ead4135d..4bce8605 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/arguments/add_bank_account_params.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/arguments/add_bank_account_params.dart @@ -4,7 +4,7 @@ import 'package:krow_domain/krow_domain.dart'; /// Arguments for adding a bank account. class AddBankAccountParams extends UseCaseArgument with EquatableMixin { - final BankAccount account; + final StaffBankAccount account; const AddBankAccountParams({required this.account}); diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/repositories/bank_account_repository.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/repositories/bank_account_repository.dart index 3e701aba..51d72774 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/repositories/bank_account_repository.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/repositories/bank_account_repository.dart @@ -3,8 +3,8 @@ import 'package:krow_domain/krow_domain.dart'; /// Repository interface for managing bank accounts. abstract class BankAccountRepository { /// Fetches the list of bank accounts for the current user. - Future> getAccounts(); + Future> getAccounts(); /// adds a new bank account. - Future addAccount(BankAccount account); + Future addAccount(StaffBankAccount account); } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/get_bank_accounts_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/get_bank_accounts_usecase.dart index 2ee64df3..2de67941 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/get_bank_accounts_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/get_bank_accounts_usecase.dart @@ -3,13 +3,13 @@ import 'package:krow_domain/krow_domain.dart'; import '../repositories/bank_account_repository.dart'; /// Use case to fetch bank accounts. -class GetBankAccountsUseCase implements NoInputUseCase> { +class GetBankAccountsUseCase implements NoInputUseCase> { final BankAccountRepository _repository; GetBankAccountsUseCase(this._repository); @override - Future> call() { + Future> call() { return _repository.getAccounts(); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_cubit.dart index f159781e..afa3c888 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_cubit.dart @@ -23,19 +23,15 @@ class BankAccountCubit extends Cubit await handleError( emit: emit, action: () async { - final List accounts = await _getBankAccountsUseCase(); + final List accounts = await _getBankAccountsUseCase(); emit( - state.copyWith( - status: BankAccountStatus.loaded, - accounts: accounts, - ), + state.copyWith(status: BankAccountStatus.loaded, accounts: accounts), ); }, - onError: - (String errorKey) => state.copyWith( - status: BankAccountStatus.error, - errorMessage: errorKey, - ), + onError: (String errorKey) => state.copyWith( + status: BankAccountStatus.error, + errorMessage: errorKey, + ), ); } @@ -52,21 +48,18 @@ class BankAccountCubit extends Cubit emit(state.copyWith(status: BankAccountStatus.loading)); // Create domain entity - final BankAccount newAccount = BankAccount( + final StaffBankAccount newAccount = StaffBankAccount( id: '', // Generated by server usually userId: '', // Handled by Repo/Auth bankName: bankName, - accountNumber: accountNumber, + accountNumber: accountNumber.length > 4 + ? accountNumber.substring(accountNumber.length - 4) + : accountNumber, accountName: '', sortCode: routingNumber, - type: - type == 'CHECKING' - ? BankAccountType.checking - : BankAccountType.savings, - last4: - accountNumber.length > 4 - ? accountNumber.substring(accountNumber.length - 4) - : accountNumber, + type: type == 'CHECKING' + ? StaffBankAccountType.checking + : StaffBankAccountType.savings, isPrimary: false, ); @@ -85,12 +78,10 @@ class BankAccountCubit extends Cubit ), ); }, - onError: - (String errorKey) => state.copyWith( - status: BankAccountStatus.error, - errorMessage: errorKey, - ), + onError: (String errorKey) => state.copyWith( + status: BankAccountStatus.error, + errorMessage: errorKey, + ), ); } } - diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_state.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_state.dart index 09038616..3073c78b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_state.dart @@ -5,7 +5,7 @@ enum BankAccountStatus { initial, loading, loaded, error, accountAdded } class BankAccountState extends Equatable { final BankAccountStatus status; - final List accounts; + final List accounts; final String? errorMessage; final bool showForm; @@ -18,7 +18,7 @@ class BankAccountState extends Equatable { BankAccountState copyWith({ BankAccountStatus? status, - List? accounts, + List? accounts, String? errorMessage, bool? showForm, }) { diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart index 53b92702..698cfb6b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart @@ -96,7 +96,7 @@ class BankAccountPage extends StatelessWidget { style: UiTypography.headline4m.copyWith(color: UiColors.textPrimary), ), const SizedBox(height: UiConstants.space3), - ...state.accounts.map((BankAccount a) => _buildAccountCard(a, strings)), // Added type + ...state.accounts.map((StaffBankAccount a) => _buildAccountCard(a, strings)), // Added type // Add extra padding at bottom const SizedBox(height: UiConstants.space20), @@ -183,7 +183,7 @@ class BankAccountPage extends StatelessWidget { ); } - Widget _buildAccountCard(BankAccount account, dynamic strings) { + Widget _buildAccountCard(StaffBankAccount account, dynamic strings) { final bool isPrimary = account.isPrimary; const Color primaryColor = UiColors.primary; diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index f30d02fc..25c3fd23 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -141,10 +141,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" charcode: dependency: transitive description: @@ -741,6 +741,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" json_annotation: dependency: transitive description: @@ -809,18 +817,18 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" melos: dependency: "direct dev" description: @@ -1318,26 +1326,26 @@ packages: dependency: transitive description: name: test - sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.29.0" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.15" + version: "0.6.12" typed_data: dependency: transitive description: diff --git a/makefiles/mobile.mk b/makefiles/mobile.mk index f4d62624..43c3d618 100644 --- a/makefiles/mobile.mk +++ b/makefiles/mobile.mk @@ -1,6 +1,6 @@ # --- Mobile App Development --- -.PHONY: mobile-install mobile-info mobile-client-dev-android mobile-staff-dev-android mobile-client-build mobile-staff-build mobile-hot-reload mobile-hot-restart +.PHONY: mobile-install mobile-info mobile-analyze mobile-client-dev-android mobile-staff-dev-android mobile-client-build mobile-staff-build mobile-hot-reload mobile-hot-restart MOBILE_DIR := apps/mobile @@ -19,6 +19,10 @@ mobile-info: @echo "--> Fetching mobile command info..." @cd $(MOBILE_DIR) && melos run info +mobile-analyze: + @echo "--> Analyzing mobile workspace for compile-time errors..." + @cd $(MOBILE_DIR) && flutter analyze + # --- Hot Reload & Restart --- mobile-hot-reload: @echo "--> Triggering hot reload for running Flutter app..." From 506da5e26f16c8fcca20f2653eaa7aa1b6dc042f Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Feb 2026 13:21:00 -0500 Subject: [PATCH 44/53] feat: Implement DataErrorHandler mixin and update imports for consistency --- apps/mobile/packages/data_connect/lib/krow_data_connect.dart | 3 +-- .../data_connect/lib/src/services/data_connect_service.dart | 2 +- .../lib/src/{ => services}/mixins/data_error_handler.dart | 0 .../lib/src/data/repositories_impl/auth_repository_impl.dart | 1 + 4 files changed, 3 insertions(+), 3 deletions(-) rename apps/mobile/packages/data_connect/lib/src/{ => services}/mixins/data_error_handler.dart (100%) diff --git a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart index d512a29c..833f7115 100644 --- a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart +++ b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart @@ -3,7 +3,6 @@ /// This package provides mock implementations of domain repository interfaces /// for development and testing purposes. /// -/// TODO: These mocks currently do not implement any specific interfaces. /// They will implement interfaces defined in feature packages once those are created. library; @@ -16,4 +15,4 @@ export 'src/dataconnect_generated/generated.dart'; export 'src/services/data_connect_service.dart'; export 'src/session/staff_session_store.dart'; -export 'src/mixins/data_error_handler.dart'; +export 'src/services/mixins/data_error_handler.dart'; diff --git a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart index bad4b174..95f712c6 100644 --- a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart +++ b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart @@ -6,7 +6,7 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import '../../krow_data_connect.dart' as dc; -import '../mixins/data_error_handler.dart'; +import 'mixins/data_error_handler.dart'; /// A centralized service for interacting with Firebase Data Connect. /// diff --git a/apps/mobile/packages/data_connect/lib/src/mixins/data_error_handler.dart b/apps/mobile/packages/data_connect/lib/src/services/mixins/data_error_handler.dart similarity index 100% rename from apps/mobile/packages/data_connect/lib/src/mixins/data_error_handler.dart rename to apps/mobile/packages/data_connect/lib/src/services/mixins/data_error_handler.dart diff --git a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index b64d9f71..e57c1df9 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -414,6 +414,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { return domainUser; } + @override Future restoreSession() async { final firebase.User? firebaseUser = _service.auth.currentUser; From be40614274653b9c281323a1c6166434e5139269 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Feb 2026 14:03:24 -0500 Subject: [PATCH 45/53] feat: Implement session management with SessionListener and SessionHandlerMixin --- apps/mobile/apps/staff/lib/main.dart | 26 ++- .../lib/src/widgets/session_listener.dart | 149 +++++++++++++ apps/mobile/apps/staff/pubspec.yaml | 2 + .../lib/src/routing/client/navigator.dart | 11 +- .../core/lib/src/routing/staff/navigator.dart | 7 + .../data_connect/lib/krow_data_connect.dart | 1 + .../src/services/data_connect_service.dart | 29 ++- .../mixins/session_handler_mixin.dart | 199 ++++++++++++++++++ 8 files changed, 410 insertions(+), 14 deletions(-) create mode 100644 apps/mobile/apps/staff/lib/src/widgets/session_listener.dart create mode 100644 apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart index eba7af00..73c8ad95 100644 --- a/apps/mobile/apps/staff/lib/main.dart +++ b/apps/mobile/apps/staff/lib/main.dart @@ -5,25 +5,34 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krowwithus_staff/firebase_options.dart'; import 'package:staff_authentication/staff_authentication.dart' as staff_authentication; import 'package:staff_main/staff_main.dart' as staff_main; import 'package:krow_core/core.dart'; +import 'src/widgets/session_listener.dart'; + void main() async { WidgetsFlutterBinding.ensureInitialized(); - await Firebase.initializeApp( - options: DefaultFirebaseOptions.currentPlatform, - ); - + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + // Register global BLoC observer for centralized error logging Bloc.observer = CoreBlocObserver( logEvents: true, logStateChanges: false, // Set to true for verbose debugging ); - - runApp(ModularApp(module: AppModule(), child: const AppWidget())); + + // Initialize session listener for Firebase Auth state changes + DataConnectService.instance.initializeAuthListener(); + + runApp( + ModularApp( + module: AppModule(), + child: const SessionListener(child: AppWidget()), + ), + ); } /// The main application module. @@ -34,7 +43,10 @@ class AppModule extends Module { @override void routes(RouteManager r) { // Set the initial route to the authentication module - r.module(StaffPaths.root, module: staff_authentication.StaffAuthenticationModule()); + r.module( + StaffPaths.root, + module: staff_authentication.StaffAuthenticationModule(), + ); r.module(StaffPaths.main, module: staff_main.StaffMainModule()); } diff --git a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart new file mode 100644 index 00000000..bc40deea --- /dev/null +++ b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart @@ -0,0 +1,149 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; + +/// A widget that listens to session state changes and handles global reactions. +/// +/// This widget wraps the entire app and provides centralized session management, +/// such as logging out when the session expires or handling session errors. +class SessionListener extends StatefulWidget { + /// Creates a [SessionListener]. + const SessionListener({required this.child, super.key}); + + /// The child widget to wrap. + final Widget child; + + @override + State createState() => _SessionListenerState(); +} + +class _SessionListenerState extends State { + late StreamSubscription _sessionSubscription; + bool _sessionExpiredDialogShown = false; + + @override + void initState() { + super.initState(); + _setupSessionListener(); + } + + void _setupSessionListener() { + _sessionSubscription = DataConnectService.instance.onSessionStateChanged + .listen((SessionState state) { + _handleSessionChange(state); + }); + } + + void _handleSessionChange(SessionState state) { + if (!mounted) return; + + switch (state.type) { + case SessionStateType.unauthenticated: + debugPrint( + '[SessionListener] Unauthenticated: Session expired or user logged out', + ); + // Show expiration dialog if not already shown + if (!_sessionExpiredDialogShown) { + _sessionExpiredDialogShown = true; + _showSessionExpiredDialog(); + } + break; + + case SessionStateType.authenticated: + // Session restored or user authenticated + _sessionExpiredDialogShown = false; + debugPrint('[SessionListener] Authenticated: ${state.userId}'); + + // Navigate to the main app + Modular.to.toStaffHome(); + break; + + case SessionStateType.error: + // Show error notification with option to retry or logout + debugPrint('[SessionListener] Session error: ${state.errorMessage}'); + _showSessionErrorDialog(state.errorMessage ?? 'Session error occurred'); + break; + + case SessionStateType.loading: + // Session is loading, optionally show a loading indicator + debugPrint('[SessionListener] Session loading...'); + break; + } + } + + /// Shows a dialog when the session expires. + void _showSessionExpiredDialog() { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Session Expired'), + content: const Text( + 'Your session has expired. Please log in again to continue.', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _proceedToLogin(); + }, + child: const Text('Log In'), + ), + ], + ); + }, + ); + } + + /// Shows a dialog when a session error occurs, with retry option. + void _showSessionErrorDialog(String errorMessage) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Session Error'), + content: Text(errorMessage), + actions: [ + TextButton( + onPressed: () { + // User can retry by dismissing and continuing + Modular.to.pop(); + }, + child: const Text('Continue'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _proceedToLogin(); + }, + child: const Text('Log Out'), + ), + ], + ); + }, + ); + } + + /// Navigate to login screen and clear navigation stack. + void _proceedToLogin() { + // Clear service caches on sign-out + DataConnectService.instance.handleSignOut(); + + // Navigate to authentication + Modular.to.toGetStarted(); + } + + @override + void dispose() { + _sessionSubscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/apps/mobile/apps/staff/pubspec.yaml b/apps/mobile/apps/staff/pubspec.yaml index 8bc77687..d3b270ef 100644 --- a/apps/mobile/apps/staff/pubspec.yaml +++ b/apps/mobile/apps/staff/pubspec.yaml @@ -28,6 +28,8 @@ dependencies: path: ../../packages/features/staff/staff_main krow_core: path: ../../packages/core + krow_data_connect: + path: ../../packages/data_connect cupertino_icons: ^1.0.8 flutter_modular: ^6.3.0 firebase_core: ^4.4.0 diff --git a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart index d51abda4..4c7bcd34 100644 --- a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart @@ -21,20 +21,27 @@ import 'route_paths.dart'; /// /// See also: /// * [ClientPaths] for route path constants -/// * [StaffNavigator] for Staff app navigation +/// * [ClientNavigator] for Client app navigation extension ClientNavigator on IModularNavigator { // ========================================================================== // AUTHENTICATION FLOWS // ========================================================================== /// Navigate to the root authentication screen. - /// + /// /// This effectively logs out the user by navigating to root. /// Used when signing out or session expires. void toClientRoot() { navigate(ClientPaths.root); } + /// Navigates to the get started page. + /// + /// This is the landing page for unauthenticated users, offering login/signup options. + void toClientGetStartedPage() { + navigate(ClientPaths.getStarted); + } + /// Navigates to the client sign-in page. /// /// This page allows existing clients to log in using email/password diff --git a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart index 2e22a0ce..bcfdbaa0 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart @@ -36,6 +36,13 @@ extension StaffNavigator on IModularNavigator { navigate(StaffPaths.root); } + /// Navigates to the get started page. + /// + /// This is the landing page for unauthenticated users, offering login/signup options. + void toGetStartedPage() { + navigate(StaffPaths.getStarted); + } + /// Navigates to the phone verification page. /// /// Used for both login and signup flows to verify phone numbers via OTP. diff --git a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart index 833f7115..7afa4c97 100644 --- a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart +++ b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart @@ -13,6 +13,7 @@ export 'src/session/client_session_store.dart'; // Export the generated Data Connect SDK export 'src/dataconnect_generated/generated.dart'; export 'src/services/data_connect_service.dart'; +export 'src/services/mixins/session_handler_mixin.dart'; export 'src/session/staff_session_store.dart'; export 'src/services/mixins/data_error_handler.dart'; diff --git a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart index 95f712c6..539face8 100644 --- a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart +++ b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart @@ -7,11 +7,12 @@ import 'package:krow_domain/krow_domain.dart'; import '../../krow_data_connect.dart' as dc; import 'mixins/data_error_handler.dart'; +import 'mixins/session_handler_mixin.dart'; /// A centralized service for interacting with Firebase Data Connect. /// /// This service provides common utilities and context management for all repositories. -class DataConnectService with DataErrorHandler { +class DataConnectService with DataErrorHandler, SessionHandlerMixin { DataConnectService._(); /// The singleton instance of the [DataConnectService]. @@ -50,8 +51,11 @@ class DataConnectService with DataErrorHandler { } try { - final fdc.QueryResult - response = await executeProtected( + final fdc.QueryResult< + dc.GetStaffByUserIdData, + dc.GetStaffByUserIdVariables + > + response = await executeProtected( () => connector.getStaffByUserId(userId: user.uid).execute(), ); @@ -130,13 +134,18 @@ class DataConnectService with DataErrorHandler { Future run( Future Function() action, { bool requiresAuthentication = true, - }) { + }) async { if (requiresAuthentication && auth.currentUser == null) { throw const NotAuthenticatedException( technicalMessage: 'User must be authenticated to perform this action', ); } - return executeProtected(action); + + return executeProtected(() async { + // Ensure session token is valid and refresh if needed + await ensureSessionValid(); + return action(); + }); } /// Clears the internal cache (e.g., on logout). @@ -144,4 +153,14 @@ class DataConnectService with DataErrorHandler { _cachedStaffId = null; _cachedBusinessId = null; } + + /// Handle session sign-out by clearing caches. + void handleSignOut() { + clearCache(); + } + + /// Dispose all resources (call on app shutdown). + Future dispose() async { + await disposeSessionHandler(); + } } diff --git a/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart b/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart new file mode 100644 index 00000000..433b813b --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart @@ -0,0 +1,199 @@ +import 'dart:async'; + +import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; +import 'package:flutter/cupertino.dart'; + +/// Enum representing the current session state. +enum SessionStateType { loading, authenticated, unauthenticated, error } + +/// Data class for session state. +class SessionState { + /// Creates a [SessionState]. + SessionState({required this.type, this.userId, this.errorMessage}); + + /// Creates a loading state. + factory SessionState.loading() => + SessionState(type: SessionStateType.loading); + + /// Creates an authenticated state. + factory SessionState.authenticated({required String userId}) => + SessionState(type: SessionStateType.authenticated, userId: userId); + + /// Creates an unauthenticated state. + factory SessionState.unauthenticated() => + SessionState(type: SessionStateType.unauthenticated); + + /// Creates an error state. + factory SessionState.error(String message) => + SessionState(type: SessionStateType.error, errorMessage: message); + + /// The type of session state. + final SessionStateType type; + + /// The current user ID (if authenticated). + final String? userId; + + /// Error message (if error occurred). + final String? errorMessage; + + @override + String toString() => + 'SessionState(type: $type, userId: $userId, error: $errorMessage)'; +} + +/// Mixin for handling Firebase Auth session management, token refresh, and state emissions. +mixin SessionHandlerMixin { + /// Stream controller for session state changes. + final StreamController _sessionStateController = + StreamController.broadcast(); + + /// Public stream for listening to session state changes. + Stream get onSessionStateChanged => + _sessionStateController.stream; + + /// Last token refresh timestamp to avoid excessive checks. + DateTime? _lastTokenRefreshTime; + + /// Subscription to auth state changes. + StreamSubscription? _authStateSubscription; + + /// Minimum interval between token refresh checks. + static const Duration _minRefreshCheckInterval = Duration(seconds: 2); + + /// Time before token expiry to trigger a refresh. + static const Duration _refreshThreshold = Duration(minutes: 5); + + /// Firebase Auth instance (to be provided by implementing class). + firebase_auth.FirebaseAuth get auth; + + /// Initialize the auth state listener (call once on app startup). + void initializeAuthListener() { + // Cancel any existing subscription first + _authStateSubscription?.cancel(); + + // Listen to Firebase auth state changes + _authStateSubscription = auth.authStateChanges().listen( + (firebase_auth.User? user) async { + if (user == null) { + _handleSignOut(); + } else { + await _handleSignIn(user); + } + }, + onError: (Object error) { + _emitSessionState(SessionState.error(error.toString())); + }, + ); + } + + /// Ensures the Firebase auth token is valid and refreshes if needed. + /// Retries up to 3 times with exponential backoff before emitting error. + Future ensureSessionValid() async { + final firebase_auth.User? user = auth.currentUser; + + // No user = not authenticated, skip check + if (user == null) return; + + // Optimization: Skip if we just checked within the last 2 seconds + final DateTime now = DateTime.now(); + if (_lastTokenRefreshTime != null) { + final Duration timeSinceLastCheck = now.difference( + _lastTokenRefreshTime!, + ); + if (timeSinceLastCheck < _minRefreshCheckInterval) { + return; // Skip redundant check + } + } + + const int maxRetries = 3; + int retryCount = 0; + + while (retryCount < maxRetries) { + try { + // Get token result (doesn't fetch from network unless needed) + final firebase_auth.IdTokenResult idToken = + await user.getIdTokenResult(); + + // Extract expiration time + final DateTime? expiryTime = idToken.expirationTime; + + if (expiryTime == null) { + return; // Token info unavailable, proceed anyway + } + + // Calculate time until expiry + final Duration timeUntilExpiry = expiryTime.difference(now); + + // If token expires within 5 minutes, refresh it + if (timeUntilExpiry <= _refreshThreshold) { + await user.getIdTokenResult(); + } + + // Update last refresh check timestamp + _lastTokenRefreshTime = now; + return; // Success, exit retry loop + } catch (e) { + retryCount++; + debugPrint( + 'Token validation error (attempt $retryCount/$maxRetries): $e', + ); + + // If we've exhausted retries, emit error + if (retryCount >= maxRetries) { + _emitSessionState( + SessionState.error( + 'Token validation failed after $maxRetries attempts: $e', + ), + ); + return; + } + + // Exponential backoff: 1s, 2s, 4s + final Duration backoffDuration = Duration( + seconds: 1 << (retryCount - 1), // 2^(retryCount-1) + ); + debugPrint('Retrying token validation in ${backoffDuration.inSeconds}s'); + await Future.delayed(backoffDuration); + } + } + } + + /// Handle user sign-in event. + Future _handleSignIn(firebase_auth.User user) async { + try { + _emitSessionState(SessionState.loading()); + + // Get fresh token to validate session + final firebase_auth.IdTokenResult idToken = await user.getIdTokenResult(); + if (idToken.expirationTime != null && + DateTime.now().difference(idToken.expirationTime!) < + const Duration(minutes: 5)) { + // Token is expiring soon, refresh it + await user.getIdTokenResult(); + } + + // Emit authenticated state + _emitSessionState(SessionState.authenticated(userId: user.uid)); + } catch (e) { + _emitSessionState(SessionState.error(e.toString())); + } + } + + /// Handle user sign-out event. + void _handleSignOut() { + _emitSessionState(SessionState.unauthenticated()); + } + + /// Emit session state update. + void _emitSessionState(SessionState state) { + if (!_sessionStateController.isClosed) { + _sessionStateController.add(state); + } + } + + /// Dispose session handler resources. + Future disposeSessionHandler() async { + await _authStateSubscription?.cancel(); + await _sessionStateController.close(); + } +} From 8ce37d2306c6cec410a6b4a15b96cfa4b01c8c8e Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Feb 2026 15:10:10 -0500 Subject: [PATCH 46/53] feat: Implement role-based session management and refactor authentication flow --- apps/mobile/apps/staff/lib/main.dart | 4 +- .../lib/src/widgets/session_listener.dart | 24 ++++-- .../core/lib/src/routing/staff/navigator.dart | 2 +- .../src/services/data_connect_service.dart | 15 ++++ .../mixins/session_handler_mixin.dart | 68 +++++++++++++++-- .../auth_repository_impl.dart | 23 ------ .../auth_repository_interface.dart | 3 - .../presentation/pages/client_intro_page.dart | 18 ++--- .../auth_repository_impl.dart | 73 ------------------- .../auth_repository_interface.dart | 3 - .../src/presentation/pages/intro_page.dart | 59 +-------------- .../pages/phone_verification_page.dart | 43 ++++++----- .../pages/staff_profile_page.dart | 13 ++-- 13 files changed, 138 insertions(+), 210 deletions(-) diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart index 73c8ad95..1858e1bd 100644 --- a/apps/mobile/apps/staff/lib/main.dart +++ b/apps/mobile/apps/staff/lib/main.dart @@ -25,7 +25,9 @@ void main() async { ); // Initialize session listener for Firebase Auth state changes - DataConnectService.instance.initializeAuthListener(); + DataConnectService.instance.initializeAuthListener( + allowedRoles: ['STAFF', 'BOTH'], // Only allow users with STAFF or BOTH roles + ); runApp( ModularApp( diff --git a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart index bc40deea..160b5fd4 100644 --- a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart @@ -23,6 +23,7 @@ class SessionListener extends StatefulWidget { class _SessionListenerState extends State { late StreamSubscription _sessionSubscription; bool _sessionExpiredDialogShown = false; + bool _isInitialState = true; @override void initState() { @@ -35,6 +36,8 @@ class _SessionListenerState extends State { .listen((SessionState state) { _handleSessionChange(state); }); + + debugPrint('[SessionListener] Initialized session listener'); } void _handleSessionChange(SessionState state) { @@ -45,8 +48,12 @@ class _SessionListenerState extends State { debugPrint( '[SessionListener] Unauthenticated: Session expired or user logged out', ); - // Show expiration dialog if not already shown - if (!_sessionExpiredDialogShown) { + // On initial state (cold start), just proceed to login without dialog + // Only show dialog if user was previously authenticated (session expired) + if (_isInitialState) { + _isInitialState = false; + Modular.to.toGetStartedPage(); + } else if (!_sessionExpiredDialogShown) { _sessionExpiredDialogShown = true; _showSessionExpiredDialog(); } @@ -54,6 +61,7 @@ class _SessionListenerState extends State { case SessionStateType.authenticated: // Session restored or user authenticated + _isInitialState = false; _sessionExpiredDialogShown = false; debugPrint('[SessionListener] Authenticated: ${state.userId}'); @@ -63,8 +71,14 @@ class _SessionListenerState extends State { case SessionStateType.error: // Show error notification with option to retry or logout - debugPrint('[SessionListener] Session error: ${state.errorMessage}'); - _showSessionErrorDialog(state.errorMessage ?? 'Session error occurred'); + // Only show if not initial state (avoid showing on cold start) + if (!_isInitialState) { + debugPrint('[SessionListener] Session error: ${state.errorMessage}'); + _showSessionErrorDialog(state.errorMessage ?? 'Session error occurred'); + } else { + _isInitialState = false; + Modular.to.toInitialPage(); + } break; case SessionStateType.loading: @@ -135,7 +149,7 @@ class _SessionListenerState extends State { DataConnectService.instance.handleSignOut(); // Navigate to authentication - Modular.to.toGetStarted(); + Modular.to.toInitialPage(); } @override diff --git a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart index bcfdbaa0..1269484c 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart @@ -32,7 +32,7 @@ extension StaffNavigator on IModularNavigator { /// /// This effectively logs out the user by navigating to root. /// Used when signing out or session expires. - void toGetStarted() { + void toInitialPage() { navigate(StaffPaths.root); } diff --git a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart index 539face8..c70f4789 100644 --- a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart +++ b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; +import 'package:flutter/material.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -159,6 +160,20 @@ class DataConnectService with DataErrorHandler, SessionHandlerMixin { clearCache(); } + @override + Future fetchUserRole(String userId) async { + try { + final fdc.QueryResult + response = await executeProtected( + () => connector.getUserById(id: userId).execute(), + ); + return response.data.user?.userRole; + } catch (e) { + debugPrint('Failed to fetch user role: $e'); + return null; + } + } + /// Dispose all resources (call on app shutdown). Future dispose() async { await disposeSessionHandler(); diff --git a/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart b/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart index 433b813b..393f4b8a 100644 --- a/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart +++ b/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart @@ -47,9 +47,25 @@ mixin SessionHandlerMixin { final StreamController _sessionStateController = StreamController.broadcast(); + /// Last emitted session state (for late subscribers). + SessionState? _lastSessionState; + /// Public stream for listening to session state changes. - Stream get onSessionStateChanged => - _sessionStateController.stream; + /// Late subscribers will immediately receive the last emitted state. + Stream get onSessionStateChanged { + // Create a custom stream that emits the last state before forwarding new events + return _createStreamWithLastState(); + } + + /// Creates a stream that emits the last state before subscribing to new events. + Stream _createStreamWithLastState() async* { + // If we have a last state, emit it immediately to late subscribers + if (_lastSessionState != null) { + yield _lastSessionState!; + } + // Then forward all subsequent events + yield* _sessionStateController.stream; + } /// Last token refresh timestamp to avoid excessive checks. DateTime? _lastTokenRefreshTime; @@ -66,8 +82,13 @@ mixin SessionHandlerMixin { /// Firebase Auth instance (to be provided by implementing class). firebase_auth.FirebaseAuth get auth; + /// List of allowed roles for this app (to be set during initialization). + List _allowedRoles = []; + /// Initialize the auth state listener (call once on app startup). - void initializeAuthListener() { + void initializeAuthListener({List allowedRoles = const []}) { + _allowedRoles = allowedRoles; + // Cancel any existing subscription first _authStateSubscription?.cancel(); @@ -86,6 +107,25 @@ mixin SessionHandlerMixin { ); } + /// Validates if user has one of the allowed roles. + /// Returns true if user role is in allowed roles, false otherwise. + Future validateUserRole( + String userId, + List allowedRoles, + ) async { + try { + final String? userRole = await fetchUserRole(userId); + return userRole != null && allowedRoles.contains(userRole); + } catch (e) { + debugPrint('Failed to validate user role: $e'); + return false; + } + } + + /// Fetches user role from Data Connect. + /// To be implemented by concrete class. + Future fetchUserRole(String userId); + /// Ensures the Firebase auth token is valid and refreshes if needed. /// Retries up to 3 times with exponential backoff before emitting error. Future ensureSessionValid() async { @@ -111,8 +151,8 @@ mixin SessionHandlerMixin { while (retryCount < maxRetries) { try { // Get token result (doesn't fetch from network unless needed) - final firebase_auth.IdTokenResult idToken = - await user.getIdTokenResult(); + final firebase_auth.IdTokenResult idToken = await user + .getIdTokenResult(); // Extract expiration time final DateTime? expiryTime = idToken.expirationTime; @@ -152,7 +192,9 @@ mixin SessionHandlerMixin { final Duration backoffDuration = Duration( seconds: 1 << (retryCount - 1), // 2^(retryCount-1) ); - debugPrint('Retrying token validation in ${backoffDuration.inSeconds}s'); + debugPrint( + 'Retrying token validation in ${backoffDuration.inSeconds}s', + ); await Future.delayed(backoffDuration); } } @@ -163,6 +205,19 @@ mixin SessionHandlerMixin { try { _emitSessionState(SessionState.loading()); + // Validate role if allowed roles are specified + if (_allowedRoles.isNotEmpty) { + final bool isAuthorized = await validateUserRole( + user.uid, + _allowedRoles, + ); + if (!isAuthorized) { + await auth.signOut(); + _emitSessionState(SessionState.unauthenticated()); + return; + } + } + // Get fresh token to validate session final firebase_auth.IdTokenResult idToken = await user.getIdTokenResult(); if (idToken.expirationTime != null && @@ -186,6 +241,7 @@ mixin SessionHandlerMixin { /// Emit session state update. void _emitSessionState(SessionState state) { + _lastSessionState = state; if (!_sessionStateController.isClosed) { _sessionStateController.add(state); } diff --git a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index e57c1df9..467a7c07 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -414,27 +414,4 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { return domainUser; } - - @override - Future restoreSession() async { - final firebase.User? firebaseUser = _service.auth.currentUser; - if (firebaseUser == null) { - return null; - } - - try { - return await _getUserProfile( - firebaseUserId: firebaseUser.uid, - fallbackEmail: firebaseUser.email, - requireBusinessRole: true, - ); - } catch (e) { - // If the user is not found or other permanent errors, we should probably sign out - if (e is UserNotFoundException || e is UnauthorizedAppException) { - await _service.auth.signOut(); - return null; - } - rethrow; - } - } } diff --git a/apps/mobile/packages/features/client/authentication/lib/src/domain/repositories/auth_repository_interface.dart b/apps/mobile/packages/features/client/authentication/lib/src/domain/repositories/auth_repository_interface.dart index 3dbc053f..21a1830c 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/domain/repositories/auth_repository_interface.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/domain/repositories/auth_repository_interface.dart @@ -34,7 +34,4 @@ abstract class AuthRepositoryInterface { /// Terminates the current user session and clears authentication tokens. Future signOut(); - - /// Restores the session if a user is already logged in. - Future restoreSession(); } diff --git a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart index f866b43c..5420a013 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart @@ -23,26 +23,22 @@ class _ClientIntroPageState extends State { if (!mounted) return; try { - final AuthRepositoryInterface authRepo = Modular.get(); + final AuthRepositoryInterface authRepo = + Modular.get(); // Add a timeout to prevent infinite loading - final user = await authRepo.restoreSession().timeout( - const Duration(seconds: 5), - onTimeout: () { - throw TimeoutException('Session restore timed out'); - }, - ); - + final user = true; + if (mounted) { if (user != null) { - Modular.to.navigate(ClientPaths.home); + Modular.to.navigate(ClientPaths.home); } else { - Modular.to.navigate(ClientPaths.getStarted); + Modular.to.navigate(ClientPaths.getStarted); } } } catch (e) { debugPrint('ClientIntroPage: Session check error: $e'); if (mounted) { - Modular.to.navigate(ClientPaths.getStarted); + Modular.to.navigate(ClientPaths.getStarted); } } } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index 863d815f..b247880e 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -257,77 +257,4 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { ); return domainUser; } - @override - Future restoreSession() async { - final User? firebaseUser = _service.auth.currentUser; - if (firebaseUser == null) { - return null; - } - - try { - // 1. Fetch User - final QueryResult response = - await _service.run(() => _service.connector - .getUserById( - id: firebaseUser.uid, - ) - .execute()); - final GetUserByIdUser? user = response.data.user; - - if (user == null) { - return null; - } - - // 2. Check Role - if (user.userRole != 'STAFF' && user.userRole != 'BOTH') { - return null; - } - - // 3. Fetch Staff Profile - final QueryResult - staffResponse = await _service.run(() => _service.connector - .getStaffByUserId( - userId: firebaseUser.uid, - ) - .execute()); - - if (staffResponse.data.staffs.isEmpty) { - return null; - } - - final GetStaffByUserIdStaffs staffRecord = staffResponse.data.staffs.first; - - // 4. Populate Session - final domain.User domainUser = domain.User( - id: firebaseUser.uid, - email: user.email ?? '', - phone: firebaseUser.phoneNumber, - role: user.role.stringValue, - ); - - final domain.Staff domainStaff = domain.Staff( - id: staffRecord.id, - authProviderId: staffRecord.userId, - name: staffRecord.fullName, - email: staffRecord.email ?? '', - phone: staffRecord.phone, - status: domain.StaffStatus.completedProfile, - address: staffRecord.addres, - avatar: staffRecord.photoUrl, - ); - - StaffSessionStore.instance.setSession( - StaffSession( - user: domainUser, - staff: domainStaff, - ownerId: staffRecord.ownerId, - ), - ); - - return domainUser; - } catch (e) { - // If restoration fails (network, etc), we rethrow to let UI handle it. - rethrow; - } - } } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart index e73be91d..0ee6fc5a 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart @@ -20,7 +20,4 @@ abstract interface class AuthRepositoryInterface { /// Signs out the current user. Future signOut(); - - /// Restores the session if a user is already logged in. - Future restoreSession(); } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/intro_page.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/intro_page.dart index 6d27ee1b..0acc300b 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/intro_page.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/intro_page.dart @@ -1,65 +1,14 @@ -import 'dart:async'; +import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; -import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart'; -class IntroPage extends StatefulWidget { +/// A simple introductory page that displays the KROW logo. +class IntroPage extends StatelessWidget { const IntroPage({super.key}); - @override - State createState() => _IntroPageState(); -} - -class _IntroPageState extends State { - @override - void initState() { - super.initState(); - _checkSession(); - } - - Future _checkSession() async { - // Check session immediately without artificial delay - if (!mounted) return; - - try { - final AuthRepositoryInterface authRepo = Modular.get(); - // Add a timeout to prevent infinite loading - final user = await authRepo.restoreSession().timeout( - const Duration(seconds: 5), - onTimeout: () { - // If it takes too long, navigate to Get Started. - // This handles poor network conditions gracefully. - throw TimeoutException('Session restore timed out'); - }, - ); - - if (mounted) { - if (user != null) { - Modular.to.navigate(StaffPaths.home); - } else { - Modular.to.navigate(StaffPaths.getStarted); - } - } - } catch (e) { - debugPrint('IntroPage: Session check error: $e'); - if (mounted) { - Modular.to.navigate(StaffPaths.getStarted); - } - } - } - @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surface, - body: Center( - child: Image.asset( - 'assets/logo-yellow.png', - package: 'design_system', - width: 120, - ), - ), + body: Center(child: Image.asset(UiImageAssets.logoYellow, width: 120)), ); } } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart index 9cbf1455..109761aa 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart @@ -58,15 +58,14 @@ class _PhoneVerificationPageState extends State { } if (normalized.length == 10) { - BlocProvider.of( - context, - ).add( + BlocProvider.of(context).add( AuthSignInRequested(phoneNumber: '+1$normalized', mode: widget.mode), ); } else { UiSnackbar.show( context, - message: t.staff_authentication.phone_verification_page.validation_error, + message: + t.staff_authentication.phone_verification_page.validation_error, type: UiSnackbarType.error, margin: const EdgeInsets.only(bottom: 180, left: 16, right: 16), ); @@ -79,9 +78,7 @@ class _PhoneVerificationPageState extends State { required String otp, required String verificationId, }) { - BlocProvider.of( - context, - ).add( + BlocProvider.of(context).add( AuthOtpSubmitted( verificationId: verificationId, smsCode: otp, @@ -92,9 +89,9 @@ class _PhoneVerificationPageState extends State { /// Handles the request to resend the verification code using the phone number in the state. void _onResend({required BuildContext context}) { - BlocProvider.of(context).add( - AuthSignInRequested(mode: widget.mode), - ); + BlocProvider.of( + context, + ).add(AuthSignInRequested(mode: widget.mode)); } @override @@ -108,8 +105,6 @@ class _PhoneVerificationPageState extends State { if (state.status == AuthStatus.authenticated) { if (state.mode == AuthMode.signup) { Modular.to.toProfileSetup(); - } else { - Modular.to.toStaffHome(); } } else if (state.status == AuthStatus.error && state.mode == AuthMode.signup) { @@ -120,7 +115,11 @@ class _PhoneVerificationPageState extends State { context, message: translateErrorKey(messageKey), type: UiSnackbarType.error, - margin: const EdgeInsets.only(bottom: 180, left: 16, right: 16), + margin: const EdgeInsets.only( + bottom: 180, + left: 16, + right: 16, + ), ); Future.delayed(const Duration(seconds: 5), () { if (!mounted) return; @@ -153,9 +152,9 @@ class _PhoneVerificationPageState extends State { centerTitle: true, showBackButton: true, onLeadingPressed: () { - BlocProvider.of(context).add( - AuthResetRequested(mode: widget.mode), - ); + BlocProvider.of( + context, + ).add(AuthResetRequested(mode: widget.mode)); Navigator.of(context).pop(); }, ), @@ -175,13 +174,13 @@ class _PhoneVerificationPageState extends State { verificationId: state.verificationId ?? '', ), ) - : PhoneInput( - state: state, - onSendCode: (String phoneNumber) => _onSendCode( - context: context, - phoneNumber: phoneNumber, + : PhoneInput( + state: state, + onSendCode: (String phoneNumber) => _onSendCode( + context: context, + phoneNumber: phoneNumber, + ), ), - ), ), ), ); diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart index 344f15b9..f16beaec 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart @@ -1,22 +1,21 @@ +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' hide ReadContext; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import '../blocs/profile_cubit.dart'; import '../blocs/profile_state.dart'; -import 'package:krow_core/core.dart'; +import '../widgets/language_selector_bottom_sheet.dart'; import '../widgets/logout_button.dart'; +import '../widgets/profile_header.dart'; import '../widgets/profile_menu_grid.dart'; import '../widgets/profile_menu_item.dart'; -import '../widgets/profile_header.dart'; import '../widgets/reliability_score_bar.dart'; import '../widgets/reliability_stats_card.dart'; -import '../widgets/reliability_stats_card.dart'; import '../widgets/section_title.dart'; -import '../widgets/language_selector_bottom_sheet.dart'; /// The main Staff Profile page. /// @@ -63,7 +62,7 @@ class StaffProfilePage extends StatelessWidget { bloc: cubit, listener: (context, state) { if (state.status == ProfileStatus.signedOut) { - Modular.to.toGetStarted(); + Modular.to.toGetStartedPage(); } else if (state.status == ProfileStatus.error && state.errorMessage != null) { UiSnackbar.show( From 5b78f339a14fa5c95b1a8912d7c0c350b2ddd14f Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Feb 2026 15:19:08 -0500 Subject: [PATCH 47/53] feat: Implement session management with SessionListener and integrate krow_data_connect --- apps/mobile/apps/client/lib/main.dart | 14 +- .../lib/src/widgets/session_listener.dart | 163 ++++++++++++++++++ apps/mobile/apps/client/pubspec.yaml | 1 + .../presentation/pages/client_intro_page.dart | 46 +---- .../auth_repository_impl.dart | 69 ++++---- 5 files changed, 209 insertions(+), 84 deletions(-) create mode 100644 apps/mobile/apps/client/lib/src/widgets/session_listener.dart diff --git a/apps/mobile/apps/client/lib/main.dart b/apps/mobile/apps/client/lib/main.dart index 47d6a076..a0e67c19 100644 --- a/apps/mobile/apps/client/lib/main.dart +++ b/apps/mobile/apps/client/lib/main.dart @@ -14,8 +14,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; import 'firebase_options.dart'; +import 'src/widgets/session_listener.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -28,8 +30,18 @@ void main() async { logEvents: true, logStateChanges: false, // Set to true for verbose debugging ); + + // Initialize session listener for Firebase Auth state changes + DataConnectService.instance.initializeAuthListener( + allowedRoles: ['CLIENT', 'BUSINESS', 'BOTH'], // Only allow users with CLIENT, BUSINESS, or BOTH roles + ); - runApp(ModularApp(module: AppModule(), child: const AppWidget())); + runApp( + ModularApp( + module: AppModule(), + child: const SessionListener(child: AppWidget()), + ), + ); } /// The main application module for the Client app. diff --git a/apps/mobile/apps/client/lib/src/widgets/session_listener.dart b/apps/mobile/apps/client/lib/src/widgets/session_listener.dart new file mode 100644 index 00000000..abb7b559 --- /dev/null +++ b/apps/mobile/apps/client/lib/src/widgets/session_listener.dart @@ -0,0 +1,163 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; + +/// A widget that listens to session state changes and handles global reactions. +/// +/// This widget wraps the entire app and provides centralized session management, +/// such as logging out when the session expires or handling session errors. +class SessionListener extends StatefulWidget { + /// Creates a [SessionListener]. + const SessionListener({required this.child, super.key}); + + /// The child widget to wrap. + final Widget child; + + @override + State createState() => _SessionListenerState(); +} + +class _SessionListenerState extends State { + late StreamSubscription _sessionSubscription; + bool _sessionExpiredDialogShown = false; + bool _isInitialState = true; + + @override + void initState() { + super.initState(); + _setupSessionListener(); + } + + void _setupSessionListener() { + _sessionSubscription = DataConnectService.instance.onSessionStateChanged + .listen((SessionState state) { + _handleSessionChange(state); + }); + + debugPrint('[SessionListener] Initialized session listener'); + } + + void _handleSessionChange(SessionState state) { + if (!mounted) return; + + switch (state.type) { + case SessionStateType.unauthenticated: + debugPrint( + '[SessionListener] Unauthenticated: Session expired or user logged out', + ); + // On initial state (cold start), just proceed to login without dialog + // Only show dialog if user was previously authenticated (session expired) + if (_isInitialState) { + _isInitialState = false; + Modular.to.toGetStartedPage(); + } else if (!_sessionExpiredDialogShown) { + _sessionExpiredDialogShown = true; + _showSessionExpiredDialog(); + } + break; + + case SessionStateType.authenticated: + // Session restored or user authenticated + _isInitialState = false; + _sessionExpiredDialogShown = false; + debugPrint('[SessionListener] Authenticated: ${state.userId}'); + + // Navigate to the main app + Modular.to.toClientHome(); + break; + + case SessionStateType.error: + // Show error notification with option to retry or logout + // Only show if not initial state (avoid showing on cold start) + if (!_isInitialState) { + debugPrint('[SessionListener] Session error: ${state.errorMessage}'); + _showSessionErrorDialog(state.errorMessage ?? 'Session error occurred'); + } else { + _isInitialState = false; + Modular.to.toInitialPage(); + } + break; + + case SessionStateType.loading: + // Session is loading, optionally show a loading indicator + debugPrint('[SessionListener] Session loading...'); + break; + } + } + + /// Shows a dialog when the session expires. + void _showSessionExpiredDialog() { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Session Expired'), + content: const Text( + 'Your session has expired. Please log in again to continue.', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _proceedToLogin(); + }, + child: const Text('Log In'), + ), + ], + ); + }, + ); + } + + /// Shows a dialog when a session error occurs, with retry option. + void _showSessionErrorDialog(String errorMessage) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Session Error'), + content: Text(errorMessage), + actions: [ + TextButton( + onPressed: () { + // User can retry by dismissing and continuing + Modular.to.pop(); + }, + child: const Text('Continue'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _proceedToLogin(); + }, + child: const Text('Log Out'), + ), + ], + ); + }, + ); + } + + /// Navigate to login screen and clear navigation stack. + void _proceedToLogin() { + // Clear service caches on sign-out + DataConnectService.instance.handleSignOut(); + + // Navigate to authentication + Modular.to.toInitialPage(); + } + + @override + void dispose() { + _sessionSubscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/apps/mobile/apps/client/pubspec.yaml b/apps/mobile/apps/client/pubspec.yaml index 101a2b77..e947f7b5 100644 --- a/apps/mobile/apps/client/pubspec.yaml +++ b/apps/mobile/apps/client/pubspec.yaml @@ -41,6 +41,7 @@ dependencies: flutter_localizations: sdk: flutter firebase_core: ^4.4.0 + krow_data_connect: ^0.0.1 dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart index 5420a013..418533fd 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart @@ -1,56 +1,16 @@ -import 'dart:async'; -import 'package:client_authentication/src/domain/repositories/auth_repository_interface.dart'; +import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; -class ClientIntroPage extends StatefulWidget { +class ClientIntroPage extends StatelessWidget { const ClientIntroPage({super.key}); - @override - State createState() => _ClientIntroPageState(); -} - -class _ClientIntroPageState extends State { - @override - void initState() { - super.initState(); - _checkSession(); - } - - Future _checkSession() async { - // Check session immediately without artificial delay - if (!mounted) return; - - try { - final AuthRepositoryInterface authRepo = - Modular.get(); - // Add a timeout to prevent infinite loading - final user = true; - - if (mounted) { - if (user != null) { - Modular.to.navigate(ClientPaths.home); - } else { - Modular.to.navigate(ClientPaths.getStarted); - } - } - } catch (e) { - debugPrint('ClientIntroPage: Session check error: $e'); - if (mounted) { - Modular.to.navigate(ClientPaths.getStarted); - } - } - } - @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, body: Center( child: Image.asset( - 'assets/logo-blue.png', - package: 'design_system', + UiImageAssets.logoBlue, width: 120, ), ), diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index b247880e..e2dab61b 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -17,9 +17,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { Completer? _pendingVerification; @override - Stream get currentUser => _service.auth - .authStateChanges() - .map((User? firebaseUser) { + Stream get currentUser => + _service.auth.authStateChanges().map((User? firebaseUser) { if (firebaseUser == null) { return null; } @@ -49,20 +48,24 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // For real numbers, we can support auto-verification if desired. // But since this method returns a verificationId for manual OTP entry, // we might not handle direct sign-in here unless the architecture changes. - // Currently, we just ignore it for the completer flow, + // Currently, we just ignore it for the completer flow, // or we could sign in directly if the credential is provided. }, verificationFailed: (FirebaseAuthException e) { if (!completer.isCompleted) { // Map Firebase network errors to NetworkException - if (e.code == 'network-request-failed' || + if (e.code == 'network-request-failed' || e.message?.contains('Unable to resolve host') == true) { completer.completeError( - const domain.NetworkException(technicalMessage: 'Auth network failure'), + const domain.NetworkException( + technicalMessage: 'Auth network failure', + ), ); } else { completer.completeError( - domain.SignInFailedException(technicalMessage: 'Firebase ${e.code}: ${e.message}'), + domain.SignInFailedException( + technicalMessage: 'Firebase ${e.code}: ${e.message}', + ), ); } } @@ -110,21 +113,18 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { verificationId: verificationId, smsCode: smsCode, ); - final UserCredential userCredential = await _service.run( - () async { - try { - return await _service.auth.signInWithCredential(credential); - } on FirebaseAuthException catch (e) { - if (e.code == 'invalid-verification-code') { - throw const domain.InvalidCredentialsException( - technicalMessage: 'Invalid OTP code entered.', - ); - } - rethrow; + final UserCredential userCredential = await _service.run(() async { + try { + return await _service.auth.signInWithCredential(credential); + } on FirebaseAuthException catch (e) { + if (e.code == 'invalid-verification-code') { + throw const domain.InvalidCredentialsException( + technicalMessage: 'Invalid OTP code entered.', + ); } - }, - requiresAuthentication: false, - ); + rethrow; + } + }, requiresAuthentication: false); final User? firebaseUser = userCredential.user; if (firebaseUser == null) { throw const domain.SignInFailedException( @@ -135,13 +135,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { final QueryResult response = await _service.run( - () => _service.connector - .getUserById( - id: firebaseUser.uid, - ) - .execute(), - requiresAuthentication: false, - ); + () => _service.connector.getUserById(id: firebaseUser.uid).execute(), + requiresAuthentication: false, + ); final GetUserByIdUser? user = response.data.user; GetStaffByUserIdStaffs? staffRecord; @@ -150,10 +146,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { if (user == null) { await _service.run( () => _service.connector - .createUser( - id: firebaseUser.uid, - role: UserBaseRole.USER, - ) + .createUser(id: firebaseUser.uid, role: UserBaseRole.USER) .userRole('STAFF') .execute(), requiresAuthentication: false, @@ -161,11 +154,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { } else { // User exists in PostgreSQL. Check if they have a STAFF profile. final QueryResult - staffResponse = await _service.run( + staffResponse = await _service.run( () => _service.connector - .getStaffByUserId( - userId: firebaseUser.uid, - ) + .getStaffByUserId(userId: firebaseUser.uid) .execute(), requiresAuthentication: false, ); @@ -208,11 +199,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { } final QueryResult - staffResponse = await _service.run( + staffResponse = await _service.run( () => _service.connector - .getStaffByUserId( - userId: firebaseUser.uid, - ) + .getStaffByUserId(userId: firebaseUser.uid) .execute(), requiresAuthentication: false, ); From 631af65a2ff69e9019e6aae33d4c8ed4003901b9 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Feb 2026 15:46:52 -0500 Subject: [PATCH 48/53] feat: Update session navigation and enhance error handling in data services --- .../lib/src/widgets/session_listener.dart | 6 ++--- .../lib/src/widgets/session_listener.dart | 4 +-- .../src/services/data_connect_service.dart | 22 ++++++++++++--- .../services/mixins/data_error_handler.dart | 27 ++++++++++++++----- .../lib/src/session/staff_session_store.dart | 11 +++----- 5 files changed, 48 insertions(+), 22 deletions(-) diff --git a/apps/mobile/apps/client/lib/src/widgets/session_listener.dart b/apps/mobile/apps/client/lib/src/widgets/session_listener.dart index abb7b559..f481633b 100644 --- a/apps/mobile/apps/client/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/client/lib/src/widgets/session_listener.dart @@ -52,7 +52,7 @@ class _SessionListenerState extends State { // Only show dialog if user was previously authenticated (session expired) if (_isInitialState) { _isInitialState = false; - Modular.to.toGetStartedPage(); + Modular.to.toClientGetStartedPage(); } else if (!_sessionExpiredDialogShown) { _sessionExpiredDialogShown = true; _showSessionExpiredDialog(); @@ -77,7 +77,7 @@ class _SessionListenerState extends State { _showSessionErrorDialog(state.errorMessage ?? 'Session error occurred'); } else { _isInitialState = false; - Modular.to.toInitialPage(); + Modular.to.toClientGetStartedPage(); } break; @@ -149,7 +149,7 @@ class _SessionListenerState extends State { DataConnectService.instance.handleSignOut(); // Navigate to authentication - Modular.to.toInitialPage(); + Modular.to.toClientGetStartedPage(); } @override diff --git a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart index 160b5fd4..258bd901 100644 --- a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart @@ -77,7 +77,7 @@ class _SessionListenerState extends State { _showSessionErrorDialog(state.errorMessage ?? 'Session error occurred'); } else { _isInitialState = false; - Modular.to.toInitialPage(); + Modular.to.toGetStartedPage(); } break; @@ -149,7 +149,7 @@ class _SessionListenerState extends State { DataConnectService.instance.handleSignOut(); // Navigate to authentication - Modular.to.toInitialPage(); + Modular.to.toGetStartedPage(); } @override diff --git a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart index c70f4789..19799467 100644 --- a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart +++ b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart @@ -83,7 +83,7 @@ class DataConnectService with DataErrorHandler, SessionHandlerMixin { // 2. Check Cache if (_cachedBusinessId != null) return _cachedBusinessId!; - // 3. Check Auth Status + // 3. Fetch from Data Connect using Firebase UID final firebase_auth.User? user = _auth.currentUser; if (user == null) { throw const NotAuthenticatedException( @@ -91,8 +91,24 @@ class DataConnectService with DataErrorHandler, SessionHandlerMixin { ); } - // 4. Fallback (should ideally not happen if DB is seeded and session is initialized) - // Ideally we'd have a getBusinessByUserId query here. + try { + final fdc.QueryResult< + dc.GetBusinessesByUserIdData, + dc.GetBusinessesByUserIdVariables + > + response = await executeProtected( + () => connector.getBusinessesByUserId(userId: user.uid).execute(), + ); + + if (response.data.businesses.isNotEmpty) { + _cachedBusinessId = response.data.businesses.first.id; + return _cachedBusinessId!; + } + } catch (e) { + throw Exception('Failed to fetch business ID from Data Connect: $e'); + } + + // 4. Fallback (should ideally not happen if DB is seeded) return user.uid; } diff --git a/apps/mobile/packages/data_connect/lib/src/services/mixins/data_error_handler.dart b/apps/mobile/packages/data_connect/lib/src/services/mixins/data_error_handler.dart index aec89758..49a5cbea 100644 --- a/apps/mobile/packages/data_connect/lib/src/services/mixins/data_error_handler.dart +++ b/apps/mobile/packages/data_connect/lib/src/services/mixins/data_error_handler.dart @@ -20,8 +20,12 @@ mixin DataErrorHandler { try { return await action().timeout(timeout); } on TimeoutException { + debugPrint( + 'DataErrorHandler: Request timed out after ${timeout.inSeconds}s', + ); throw ServiceUnavailableException( - technicalMessage: 'Request timed out after ${timeout.inSeconds}s'); + technicalMessage: 'Request timed out after ${timeout.inSeconds}s', + ); } on SocketException catch (e) { throw NetworkException(technicalMessage: 'SocketException: ${e.message}'); } on FirebaseException catch (e) { @@ -32,16 +36,26 @@ mixin DataErrorHandler { msg.contains('offline') || msg.contains('network') || msg.contains('connection failed')) { + debugPrint( + 'DataErrorHandler: Firebase network error: ${e.code} - ${e.message}', + ); throw NetworkException( - technicalMessage: 'Firebase ${e.code}: ${e.message}'); + technicalMessage: 'Firebase ${e.code}: ${e.message}', + ); } if (code == 'deadline-exceeded') { + debugPrint( + 'DataErrorHandler: Firebase timeout error: ${e.code} - ${e.message}', + ); throw ServiceUnavailableException( - technicalMessage: 'Firebase ${e.code}: ${e.message}'); + technicalMessage: 'Firebase ${e.code}: ${e.message}', + ); } + debugPrint('DataErrorHandler: Firebase error: ${e.code} - ${e.message}'); // Fallback for other Firebase errors throw ServerException( - technicalMessage: 'Firebase ${e.code}: ${e.message}'); + technicalMessage: 'Firebase ${e.code}: ${e.message}', + ); } catch (e) { final String errorStr = e.toString().toLowerCase(); if (errorStr.contains('socketexception') || @@ -56,15 +70,16 @@ mixin DataErrorHandler { errorStr.contains('grpc error') || errorStr.contains('terminated') || errorStr.contains('connectexception')) { + debugPrint('DataErrorHandler: Network-related error: $e'); throw NetworkException(technicalMessage: e.toString()); } - + // If it's already an AppException, rethrow it if (e is AppException) rethrow; // Debugging: Log unexpected errors debugPrint('DataErrorHandler: Unhandled exception caught: $e'); - + throw UnknownException(technicalMessage: e.toString()); } } diff --git a/apps/mobile/packages/data_connect/lib/src/session/staff_session_store.dart b/apps/mobile/packages/data_connect/lib/src/session/staff_session_store.dart index 06be4aef..7c5229c9 100644 --- a/apps/mobile/packages/data_connect/lib/src/session/staff_session_store.dart +++ b/apps/mobile/packages/data_connect/lib/src/session/staff_session_store.dart @@ -1,18 +1,15 @@ import 'package:krow_domain/krow_domain.dart' as domain; class StaffSession { + const StaffSession({required this.user, this.staff, this.ownerId}); + final domain.User user; final domain.Staff? staff; final String? ownerId; - - const StaffSession({ - required this.user, - this.staff, - this.ownerId, - }); } class StaffSessionStore { + StaffSessionStore._(); StaffSession? _session; StaffSession? get session => _session; @@ -26,6 +23,4 @@ class StaffSessionStore { } static final StaffSessionStore instance = StaffSessionStore._(); - - StaffSessionStore._(); } From ddf270074be83cc36c9cf3d448111dd40e3b970e Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Feb 2026 16:05:45 -0500 Subject: [PATCH 49/53] feat: Refactor session management and improve user session data retrieval --- .../lib/src/session/client_session_store.dart | 10 +- .../auth_repository_impl.dart | 175 +++++++++--------- .../home_repository_impl.dart | 175 ++++++++++++------ .../home_repository_interface.dart | 2 +- .../get_user_session_data_usecase.dart | 2 +- .../presentation/blocs/client_home_bloc.dart | 2 +- .../hub_repository_impl.dart | 134 +++++++------- .../settings_profile_header.dart | 4 +- 8 files changed, 281 insertions(+), 223 deletions(-) diff --git a/apps/mobile/packages/data_connect/lib/src/session/client_session_store.dart b/apps/mobile/packages/data_connect/lib/src/session/client_session_store.dart index e17f22a4..529277ea 100644 --- a/apps/mobile/packages/data_connect/lib/src/session/client_session_store.dart +++ b/apps/mobile/packages/data_connect/lib/src/session/client_session_store.dart @@ -1,5 +1,3 @@ -import 'package:krow_domain/krow_domain.dart' as domain; - class ClientBusinessSession { final String id; final String businessName; @@ -19,15 +17,9 @@ class ClientBusinessSession { } class ClientSession { - final domain.User user; - final String? userPhotoUrl; final ClientBusinessSession? business; - const ClientSession({ - required this.user, - required this.userPhotoUrl, - required this.business, - }); + const ClientSession({required this.business}); } class ClientSessionStore { diff --git a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index 467a7c07..4ebdc924 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -24,9 +24,8 @@ import '../../domain/repositories/auth_repository_interface.dart'; /// identity management and Krow's Data Connect SDK for storing user profile data. class AuthRepositoryImpl implements AuthRepositoryInterface { /// Creates an [AuthRepositoryImpl] with the real dependencies. - AuthRepositoryImpl({ - dc.DataConnectService? service, - }) : _service = service ?? dc.DataConnectService.instance; + AuthRepositoryImpl({dc.DataConnectService? service}) + : _service = service ?? dc.DataConnectService.instance; final dc.DataConnectService _service; @@ -36,11 +35,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { required String password, }) async { try { - final firebase.UserCredential credential = - await _service.auth.signInWithEmailAndPassword( - email: email, - password: password, - ); + final firebase.UserCredential credential = await _service.auth + .signInWithEmailAndPassword(email: email, password: password); final firebase.User? firebaseUser = credential.user; if (firebaseUser == null) { @@ -60,9 +56,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { technicalMessage: 'Firebase error code: ${e.code}', ); } else if (e.code == 'network-request-failed') { - throw NetworkException( - technicalMessage: 'Firebase: ${e.message}', - ); + throw NetworkException(technicalMessage: 'Firebase: ${e.message}'); } else { throw SignInFailedException( technicalMessage: 'Firebase auth error: ${e.message}', @@ -71,9 +65,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { } on domain.AppException { rethrow; } catch (e) { - throw SignInFailedException( - technicalMessage: 'Unexpected error: $e', - ); + throw SignInFailedException(technicalMessage: 'Unexpected error: $e'); } } @@ -88,11 +80,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { try { // Step 1: Try to create Firebase Auth user - final firebase.UserCredential credential = - await _service.auth.createUserWithEmailAndPassword( - email: email, - password: password, - ); + final firebase.UserCredential credential = await _service.auth + .createUserWithEmailAndPassword(email: email, password: password); firebaseUser = credential.user; if (firebaseUser == null) { @@ -111,9 +100,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { ); } on firebase.FirebaseAuthException catch (e) { if (e.code == 'weak-password') { - throw WeakPasswordException( - technicalMessage: 'Firebase: ${e.message}', - ); + throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}'); } else if (e.code == 'email-already-in-use') { // Email exists in Firebase Auth - try to sign in and complete registration return await _handleExistingFirebaseAccount( @@ -122,9 +109,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { companyName: companyName, ); } else if (e.code == 'network-request-failed') { - throw NetworkException( - technicalMessage: 'Firebase: ${e.message}', - ); + throw NetworkException(technicalMessage: 'Firebase: ${e.message}'); } else { throw SignUpFailedException( technicalMessage: 'Firebase auth error: ${e.message}', @@ -133,15 +118,17 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { } on domain.AppException { // Rollback for our known exceptions await _rollbackSignUp( - firebaseUser: firebaseUser, businessId: createdBusinessId); + firebaseUser: firebaseUser, + businessId: createdBusinessId, + ); rethrow; } catch (e) { // Rollback: Clean up any partially created resources await _rollbackSignUp( - firebaseUser: firebaseUser, businessId: createdBusinessId); - throw SignUpFailedException( - technicalMessage: 'Unexpected error: $e', + firebaseUser: firebaseUser, + businessId: createdBusinessId, ); + throw SignUpFailedException(technicalMessage: 'Unexpected error: $e'); } } @@ -161,16 +148,15 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { required String password, required String companyName, }) async { - developer.log('Email exists in Firebase, attempting sign-in: $email', - name: 'AuthRepository'); + developer.log( + 'Email exists in Firebase, attempting sign-in: $email', + name: 'AuthRepository', + ); try { // Try to sign in with the provided password - final firebase.UserCredential credential = - await _service.auth.signInWithEmailAndPassword( - email: email, - password: password, - ); + final firebase.UserCredential credential = await _service.auth + .signInWithEmailAndPassword(email: email, password: password); final firebase.User? firebaseUser = credential.user; if (firebaseUser == null) { @@ -180,32 +166,40 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { } // Sign-in succeeded! Check if user already has a BUSINESS account in PostgreSQL - final bool hasBusinessAccount = - await _checkBusinessUserExists(firebaseUser.uid); + final bool hasBusinessAccount = await _checkBusinessUserExists( + firebaseUser.uid, + ); if (hasBusinessAccount) { // User already has a KROW Client account - developer.log('User already has BUSINESS account: ${firebaseUser.uid}', - name: 'AuthRepository'); + developer.log( + 'User already has BUSINESS account: ${firebaseUser.uid}', + name: 'AuthRepository', + ); throw AccountExistsException( - technicalMessage: 'User ${firebaseUser.uid} already has BUSINESS role', + technicalMessage: + 'User ${firebaseUser.uid} already has BUSINESS role', ); } // User exists in Firebase but not in KROW PostgreSQL - create the entities developer.log( - 'Creating BUSINESS account for existing Firebase user: ${firebaseUser.uid}', - name: 'AuthRepository'); + 'Creating BUSINESS account for existing Firebase user: ${firebaseUser.uid}', + name: 'AuthRepository', + ); return await _createBusinessAndUser( firebaseUser: firebaseUser, companyName: companyName, email: email, - onBusinessCreated: (_) {}, // No rollback needed for existing Firebase user + onBusinessCreated: + (_) {}, // No rollback needed for existing Firebase user ); } on firebase.FirebaseAuthException catch (e) { // Sign-in failed - check why - developer.log('Sign-in failed with code: ${e.code}', - name: 'AuthRepository'); + developer.log( + 'Sign-in failed with code: ${e.code}', + name: 'AuthRepository', + ); if (e.code == 'wrong-password' || e.code == 'invalid-credential') { // Password doesn't match - check what providers are available @@ -229,8 +223,10 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // We can't distinguish between "wrong password" and "no password provider" // due to Firebase deprecating fetchSignInMethodsForEmail. // The PasswordMismatchException message covers both scenarios. - developer.log('Password mismatch or different provider for: $email', - name: 'AuthRepository'); + developer.log( + 'Password mismatch or different provider for: $email', + name: 'AuthRepository', + ); throw PasswordMismatchException( technicalMessage: 'Email $email: password mismatch or different auth provider', @@ -242,7 +238,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { Future _checkBusinessUserExists(String firebaseUserId) async { final QueryResult response = await _service.run( - () => _service.connector.getUserById(id: firebaseUserId).execute()); + () => _service.connector.getUserById(id: firebaseUserId).execute(), + ); final dc.GetUserByIdUser? user = response.data.user; return user != null && (user.userRole == 'BUSINESS' || user.userRole == 'BOTH'); @@ -258,14 +255,16 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // Create Business entity in PostgreSQL final OperationResult - createBusinessResponse = await _service.run(() => _service.connector - .createBusiness( - businessName: companyName, - userId: firebaseUser.uid, - rateGroup: dc.BusinessRateGroup.STANDARD, - status: dc.BusinessStatus.PENDING, - ) - .execute()); + createBusinessResponse = await _service.run( + () => _service.connector + .createBusiness( + businessName: companyName, + userId: firebaseUser.uid, + rateGroup: dc.BusinessRateGroup.STANDARD, + status: dc.BusinessStatus.PENDING, + ) + .execute(), + ); final dc.CreateBusinessBusinessInsert businessData = createBusinessResponse.data.business_insert; @@ -273,28 +272,28 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // Check if User entity already exists in PostgreSQL final QueryResult userResult = - await _service.run(() => - _service.connector.getUserById(id: firebaseUser.uid).execute()); + await _service.run( + () => _service.connector.getUserById(id: firebaseUser.uid).execute(), + ); final dc.GetUserByIdUser? existingUser = userResult.data.user; if (existingUser != null) { // User exists (likely in another app like STAFF). Update role to BOTH. - await _service.run(() => _service.connector - .updateUser( - id: firebaseUser.uid, - ) - .userRole('BOTH') - .execute()); + await _service.run( + () => _service.connector + .updateUser(id: firebaseUser.uid) + .userRole('BOTH') + .execute(), + ); } else { // Create new User entity in PostgreSQL - await _service.run(() => _service.connector - .createUser( - id: firebaseUser.uid, - role: dc.UserBaseRole.USER, - ) - .email(email) - .userRole('BUSINESS') - .execute()); + await _service.run( + () => _service.connector + .createUser(id: firebaseUser.uid, role: dc.UserBaseRole.USER) + .email(email) + .userRole('BUSINESS') + .execute(), + ); } return _getUserProfile( @@ -340,7 +339,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { @override Future signInWithSocial({required String provider}) { throw UnimplementedError( - 'Social authentication with $provider is not yet implemented.'); + 'Social authentication with $provider is not yet implemented.', + ); } Future _getUserProfile({ @@ -349,8 +349,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { bool requireBusinessRole = false, }) async { final QueryResult response = - await _service.run(() => - _service.connector.getUserById(id: firebaseUserId).execute()); + await _service.run( + () => _service.connector.getUserById(id: firebaseUserId).execute(), + ); final dc.GetUserByIdUser? user = response.data.user; if (user == null) { throw UserNotFoundException( @@ -383,22 +384,22 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { role: user.role.stringValue, ); - final QueryResult businessResponse = - await _service.run(() => _service.connector - .getBusinessesByUserId( - userId: firebaseUserId, - ) - .execute()); + final QueryResult< + dc.GetBusinessesByUserIdData, + dc.GetBusinessesByUserIdVariables + > + businessResponse = await _service.run( + () => _service.connector + .getBusinessesByUserId(userId: firebaseUserId) + .execute(), + ); final dc.GetBusinessesByUserIdBusinesses? business = businessResponse.data.businesses.isNotEmpty - ? businessResponse.data.businesses.first - : null; + ? businessResponse.data.businesses.first + : null; dc.ClientSessionStore.instance.setSession( dc.ClientSession( - user: domainUser, - userPhotoUrl: user.photoUrl, business: business == null ? null : dc.ClientBusinessSession( diff --git a/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart b/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart index cc92dbc8..7d89f676 100644 --- a/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart +++ b/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart @@ -19,26 +19,43 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { final DateTime now = DateTime.now(); final int daysFromMonday = now.weekday - DateTime.monday; - final DateTime monday = - DateTime(now.year, now.month, now.day).subtract(Duration(days: daysFromMonday)); - final DateTime weekRangeStart = DateTime(monday.year, monday.month, monday.day); - final DateTime weekRangeEnd = - DateTime(monday.year, monday.month, monday.day + 13, 23, 59, 59, 999); - final fdc.QueryResult completedResult = - await _service.connector - .getCompletedShiftsByBusinessId( - businessId: businessId, - dateFrom: _service.toTimestamp(weekRangeStart), - dateTo: _service.toTimestamp(weekRangeEnd), - ) - .execute(); + final DateTime monday = DateTime( + now.year, + now.month, + now.day, + ).subtract(Duration(days: daysFromMonday)); + final DateTime weekRangeStart = DateTime( + monday.year, + monday.month, + monday.day, + ); + final DateTime weekRangeEnd = DateTime( + monday.year, + monday.month, + monday.day + 13, + 23, + 59, + 59, + 999, + ); + final fdc.QueryResult< + dc.GetCompletedShiftsByBusinessIdData, + dc.GetCompletedShiftsByBusinessIdVariables + > + completedResult = await _service.connector + .getCompletedShiftsByBusinessId( + businessId: businessId, + dateFrom: _service.toTimestamp(weekRangeStart), + dateTo: _service.toTimestamp(weekRangeEnd), + ) + .execute(); double weeklySpending = 0.0; double next7DaysSpending = 0.0; int weeklyShifts = 0; int next7DaysScheduled = 0; - for (final dc.GetCompletedShiftsByBusinessIdShifts shift in completedResult.data.shifts) { + for (final dc.GetCompletedShiftsByBusinessIdShifts shift + in completedResult.data.shifts) { final DateTime? shiftDate = shift.date?.toDateTime(); if (shiftDate == null) { continue; @@ -58,17 +75,27 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { } final DateTime start = DateTime(now.year, now.month, now.day); - final DateTime end = DateTime(now.year, now.month, now.day, 23, 59, 59, 999); + final DateTime end = DateTime( + now.year, + now.month, + now.day, + 23, + 59, + 59, + 999, + ); - final fdc.QueryResult result = - await _service.connector - .listShiftRolesByBusinessAndDateRange( - businessId: businessId, - start: _service.toTimestamp(start), - end: _service.toTimestamp(end), - ) - .execute(); + final fdc.QueryResult< + dc.ListShiftRolesByBusinessAndDateRangeData, + dc.ListShiftRolesByBusinessAndDateRangeVariables + > + result = await _service.connector + .listShiftRolesByBusinessAndDateRange( + businessId: businessId, + start: _service.toTimestamp(start), + end: _service.toTimestamp(end), + ) + .execute(); int totalNeeded = 0; int totalFilled = 0; @@ -90,12 +117,47 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { } @override - UserSessionData getUserSessionData() { + Future getUserSessionData() async { final dc.ClientSession? session = dc.ClientSessionStore.instance.session; - return UserSessionData( - businessName: session?.business?.businessName ?? '', - photoUrl: null, // Business photo isn't currently in session - ); + final dc.ClientBusinessSession? business = session?.business; + + // If session data is available, return it immediately + if (business != null) { + return UserSessionData( + businessName: business.businessName, + photoUrl: business.companyLogoUrl, + ); + } + + return await _service.run(() async { + // If session is not initialized, attempt to fetch business data to populate session + final String businessId = await _service.getBusinessId(); + final fdc.QueryResult + businessResult = await _service.connector + .getBusinessById(id: businessId) + .execute(); + + if (businessResult.data.business == null) { + throw Exception('Business data not found for ID: $businessId'); + } + + final dc.ClientSession updatedSession = dc.ClientSession( + business: dc.ClientBusinessSession( + id: businessResult.data.business!.id, + businessName: businessResult.data.business?.businessName ?? '', + email: businessResult.data.business?.email ?? '', + city: businessResult.data.business?.city ?? '', + contactName: businessResult.data.business?.contactName ?? '', + companyLogoUrl: businessResult.data.business?.companyLogoUrl, + ), + ); + dc.ClientSessionStore.instance.setSession(updatedSession); + + return UserSessionData( + businessName: businessResult.data.business!.businessName, + photoUrl: businessResult.data.business!.companyLogoUrl, + ); + }); } @override @@ -108,33 +170,34 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { final fdc.Timestamp startTimestamp = _service.toTimestamp(start); final fdc.Timestamp endTimestamp = _service.toTimestamp(now); - final fdc.QueryResult result = - await _service.connector - .listShiftRolesByBusinessDateRangeCompletedOrders( - businessId: businessId, - start: startTimestamp, - end: endTimestamp, - ) - .execute(); + final fdc.QueryResult< + dc.ListShiftRolesByBusinessDateRangeCompletedOrdersData, + dc.ListShiftRolesByBusinessDateRangeCompletedOrdersVariables + > + result = await _service.connector + .listShiftRolesByBusinessDateRangeCompletedOrders( + businessId: businessId, + start: startTimestamp, + end: endTimestamp, + ) + .execute(); - return result.data.shiftRoles - .map(( - dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole, - ) { - final String location = shiftRole.shift.location ?? shiftRole.shift.locationAddress ?? ''; - final String type = shiftRole.shift.order.orderType.stringValue; - return ReorderItem( - orderId: shiftRole.shift.order.id, - title: '${shiftRole.role.name} - ${shiftRole.shift.title}', - location: location, - hourlyRate: shiftRole.role.costPerHour, - hours: shiftRole.hours ?? 0, - workers: shiftRole.count, - type: type, - ); - }) - .toList(); + return result.data.shiftRoles.map(( + dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole, + ) { + final String location = + shiftRole.shift.location ?? shiftRole.shift.locationAddress ?? ''; + final String type = shiftRole.shift.order.orderType.stringValue; + return ReorderItem( + orderId: shiftRole.shift.order.id, + title: '${shiftRole.role.name} - ${shiftRole.shift.title}', + location: location, + hourlyRate: shiftRole.role.costPerHour, + hours: shiftRole.hours ?? 0, + workers: shiftRole.count, + type: type, + ); + }).toList(); }); } } diff --git a/apps/mobile/packages/features/client/home/lib/src/domain/repositories/home_repository_interface.dart b/apps/mobile/packages/features/client/home/lib/src/domain/repositories/home_repository_interface.dart index 22b5a7f4..e84df66a 100644 --- a/apps/mobile/packages/features/client/home/lib/src/domain/repositories/home_repository_interface.dart +++ b/apps/mobile/packages/features/client/home/lib/src/domain/repositories/home_repository_interface.dart @@ -24,7 +24,7 @@ abstract interface class HomeRepositoryInterface { Future getDashboardData(); /// Fetches the user's session data (business name and photo). - UserSessionData getUserSessionData(); + Future getUserSessionData(); /// Fetches recently completed shift roles for reorder suggestions. Future> getRecentReorders(); diff --git a/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_user_session_data_usecase.dart b/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_user_session_data_usecase.dart index 24d043c5..f246d856 100644 --- a/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_user_session_data_usecase.dart +++ b/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_user_session_data_usecase.dart @@ -10,7 +10,7 @@ class GetUserSessionDataUseCase { final HomeRepositoryInterface _repository; /// Executes the use case to get session data. - UserSessionData call() { + Future call() { return _repository.getUserSessionData(); } } diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart index a2cc7629..cba07bba 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart @@ -40,7 +40,7 @@ class ClientHomeBloc extends Bloc emit: emit, action: () async { // Get session data - final UserSessionData sessionData = _getUserSessionDataUseCase(); + final UserSessionData sessionData = await _getUserSessionDataUseCase(); // Get dashboard data final HomeDashboardData data = await _getDashboardDataUseCase(); diff --git a/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart b/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart index d207b7d5..91de3bdf 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart @@ -16,16 +16,16 @@ import '../../domain/repositories/hub_repository_interface.dart'; /// Implementation of [HubRepositoryInterface] backed by Data Connect. class HubRepositoryImpl implements HubRepositoryInterface { - HubRepositoryImpl({ - required dc.DataConnectService service, - }) : _service = service; + HubRepositoryImpl({required dc.DataConnectService service}) + : _service = service; final dc.DataConnectService _service; @override Future> getHubs() async { return _service.run(() async { - final dc.GetBusinessesByUserIdBusinesses business = await _getBusinessForCurrentUser(); + final dc.GetBusinessesByUserIdBusinesses business = + await _getBusinessForCurrentUser(); final String teamId = await _getOrCreateTeamId(business); return _fetchHubsForTeam(teamId: teamId, businessId: business.id); }); @@ -45,10 +45,12 @@ class HubRepositoryImpl implements HubRepositoryInterface { String? zipCode, }) async { return _service.run(() async { - final dc.GetBusinessesByUserIdBusinesses business = await _getBusinessForCurrentUser(); + final dc.GetBusinessesByUserIdBusinesses business = + await _getBusinessForCurrentUser(); final String teamId = await _getOrCreateTeamId(business); - final _PlaceAddress? placeAddress = - placeId == null || placeId.isEmpty ? null : await _fetchPlaceAddress(placeId); + final _PlaceAddress? placeAddress = placeId == null || placeId.isEmpty + ? null + : await _fetchPlaceAddress(placeId); final String? cityValue = city ?? placeAddress?.city ?? business.city; final String? stateValue = state ?? placeAddress?.state; final String? streetValue = street ?? placeAddress?.street; @@ -56,21 +58,17 @@ class HubRepositoryImpl implements HubRepositoryInterface { final String? zipCodeValue = zipCode ?? placeAddress?.zipCode; final OperationResult - result = await _service.connector - .createTeamHub( - teamId: teamId, - hubName: name, - address: address, - ) - .placeId(placeId) - .latitude(latitude) - .longitude(longitude) - .city(cityValue?.isNotEmpty == true ? cityValue : '') - .state(stateValue) - .street(streetValue) - .country(countryValue) - .zipCode(zipCodeValue) - .execute(); + result = await _service.connector + .createTeamHub(teamId: teamId, hubName: name, address: address) + .placeId(placeId) + .latitude(latitude) + .longitude(longitude) + .city(cityValue?.isNotEmpty == true ? cityValue : '') + .state(stateValue) + .street(streetValue) + .country(countryValue) + .zipCode(zipCodeValue) + .execute(); final String createdId = result.data.teamHub_insert.id; final List hubs = await _fetchHubsForTeam( @@ -101,14 +99,13 @@ class HubRepositoryImpl implements HubRepositoryInterface { return _service.run(() async { final String businessId = await _service.getBusinessId(); - final QueryResult result = - await _service.connector - .listOrdersByBusinessAndTeamHub( - businessId: businessId, - teamHubId: id, - ) - .execute(); + final QueryResult< + dc.ListOrdersByBusinessAndTeamHubData, + dc.ListOrdersByBusinessAndTeamHubVariables + > + result = await _service.connector + .listOrdersByBusinessAndTeamHub(businessId: businessId, teamHubId: id) + .execute(); if (result.data.orders.isNotEmpty) { throw HubHasOrdersException( @@ -121,14 +118,14 @@ class HubRepositoryImpl implements HubRepositoryInterface { } @override - Future assignNfcTag({ - required String hubId, - required String nfcTagId, - }) { - throw UnimplementedError('NFC tag assignment is not supported for team hubs.'); + Future assignNfcTag({required String hubId, required String nfcTagId}) { + throw UnimplementedError( + 'NFC tag assignment is not supported for team hubs.', + ); } - Future _getBusinessForCurrentUser() async { + Future + _getBusinessForCurrentUser() async { final dc.ClientSession? session = dc.ClientSessionStore.instance.session; final dc.ClientBusinessSession? cachedBusiness = session?.business; if (cachedBusiness != null) { @@ -136,7 +133,9 @@ class HubRepositoryImpl implements HubRepositoryInterface { id: cachedBusiness.id, businessName: cachedBusiness.businessName, userId: _service.auth.currentUser?.uid ?? '', - rateGroup: const dc.Known(dc.BusinessRateGroup.STANDARD), + rateGroup: const dc.Known( + dc.BusinessRateGroup.STANDARD, + ), status: const dc.Known(dc.BusinessStatus.ACTIVE), contactName: cachedBusiness.contactName, companyLogoUrl: cachedBusiness.companyLogoUrl, @@ -160,11 +159,13 @@ class HubRepositoryImpl implements HubRepositoryInterface { ); } - final QueryResult result = - await _service.connector.getBusinessesByUserId( - userId: user.uid, - ).execute(); + final QueryResult< + dc.GetBusinessesByUserIdData, + dc.GetBusinessesByUserIdVariables + > + result = await _service.connector + .getBusinessesByUserId(userId: user.uid) + .execute(); if (result.data.businesses.isEmpty) { await _service.auth.signOut(); throw BusinessNotFoundException( @@ -172,12 +173,11 @@ class HubRepositoryImpl implements HubRepositoryInterface { ); } - final dc.GetBusinessesByUserIdBusinesses business = result.data.businesses.first; + final dc.GetBusinessesByUserIdBusinesses business = + result.data.businesses.first; if (session != null) { dc.ClientSessionStore.instance.setSession( dc.ClientSession( - user: session.user, - userPhotoUrl: session.userPhotoUrl, business: dc.ClientBusinessSession( id: business.id, businessName: business.businessName, @@ -197,26 +197,26 @@ class HubRepositoryImpl implements HubRepositoryInterface { dc.GetBusinessesByUserIdBusinesses business, ) async { final QueryResult - teamsResult = await _service.connector.getTeamsByOwnerId( - ownerId: business.id, - ).execute(); + teamsResult = await _service.connector + .getTeamsByOwnerId(ownerId: business.id) + .execute(); if (teamsResult.data.teams.isNotEmpty) { return teamsResult.data.teams.first.id; } - final dc.CreateTeamVariablesBuilder createTeamBuilder = _service.connector.createTeam( - teamName: '${business.businessName} Team', - ownerId: business.id, - ownerName: business.contactName ?? '', - ownerRole: 'OWNER', - ); + final dc.CreateTeamVariablesBuilder createTeamBuilder = _service.connector + .createTeam( + teamName: '${business.businessName} Team', + ownerId: business.id, + ownerName: business.contactName ?? '', + ownerRole: 'OWNER', + ); if (business.email != null) { createTeamBuilder.email(business.email); } final OperationResult - createTeamResult = - await createTeamBuilder.execute(); + createTeamResult = await createTeamBuilder.execute(); final String teamId = createTeamResult.data.team_insert.id; return teamId; @@ -226,11 +226,13 @@ class HubRepositoryImpl implements HubRepositoryInterface { required String teamId, required String businessId, }) async { - final QueryResult hubsResult = - await _service.connector.getTeamHubsByTeamId( - teamId: teamId, - ).execute(); + final QueryResult< + dc.GetTeamHubsByTeamIdData, + dc.GetTeamHubsByTeamIdVariables + > + hubsResult = await _service.connector + .getTeamHubsByTeamId(teamId: teamId) + .execute(); return hubsResult.data.teamHubs .map( @@ -240,10 +242,9 @@ class HubRepositoryImpl implements HubRepositoryInterface { name: hub.hubName, address: hub.address, nfcTagId: null, - status: - hub.isActive - ? domain.HubStatus.active - : domain.HubStatus.inactive, + status: hub.isActive + ? domain.HubStatus.active + : domain.HubStatus.inactive, ), ) .toList(); @@ -288,7 +289,8 @@ class HubRepositoryImpl implements HubRepositoryInterface { for (final dynamic entry in components) { final Map component = entry as Map; - final List types = component['types'] as List? ?? []; + final List types = + component['types'] as List? ?? []; final String? longName = component['long_name'] as String?; final String? shortName = component['short_name'] as String?; diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart index 0d2db204..b9ddd93e 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart @@ -17,8 +17,8 @@ class SettingsProfileHeader extends StatelessWidget { final dc.ClientSession? session = dc.ClientSessionStore.instance.session; final String businessName = session?.business?.businessName ?? 'Your Company'; - final String email = session?.user.email ?? 'client@example.com'; - final String? photoUrl = session?.userPhotoUrl; + final String email = session?.business?.email ?? 'client@example.com'; + final String? photoUrl = session?.business?.companyLogoUrl; final String avatarLetter = businessName.trim().isNotEmpty ? businessName.trim()[0].toUpperCase() : 'C'; From c90b2c296b49622359035c932b554f087e9174fc Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Feb 2026 16:14:45 -0500 Subject: [PATCH 50/53] feat: Update navigation flow in ClientSettingsPage to redirect to Get Started page --- apps/mobile/analysis_options.yaml | 1 + .../lib/src/presentation/pages/client_settings_page.dart | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/mobile/analysis_options.yaml b/apps/mobile/analysis_options.yaml index cec4e925..2b4df59c 100644 --- a/apps/mobile/analysis_options.yaml +++ b/apps/mobile/analysis_options.yaml @@ -6,6 +6,7 @@ analyzer: - "**/*.g.dart" - "**/*.freezed.dart" - "**/*.config.dart" + - "apps/mobile/prototypes/**" errors: # Set the severity of the always_specify_types rule to warning as requested. always_specify_types: warning diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart index 44977e55..edf6b8e3 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart @@ -31,7 +31,7 @@ class ClientSettingsPage extends StatelessWidget { message: 'Signed out successfully', type: UiSnackbarType.success, ); - Modular.to.toClientRoot(); + Modular.to.toClientGetStartedPage(); } if (state is ClientSettingsError) { UiSnackbar.show( From db8a2f6e667c97c07272838305d51e04469b7684 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Feb 2026 16:18:33 -0500 Subject: [PATCH 51/53] Revise M4 milestone planning with new requirements Updated M4 milestone planning document to include new goals, acceptance criteria, and key rules for backend and frontend tasks. --- docs/MILESTONES/M4/planning/m4-planning.md | 914 +++++++++++++-------- 1 file changed, 571 insertions(+), 343 deletions(-) diff --git a/docs/MILESTONES/M4/planning/m4-planning.md b/docs/MILESTONES/M4/planning/m4-planning.md index 91c54af7..caa59eaf 100644 --- a/docs/MILESTONES/M4/planning/m4-planning.md +++ b/docs/MILESTONES/M4/planning/m4-planning.md @@ -1,417 +1,645 @@ -# **M4 Milestone Planning** - -## **BE** - -### Validate shift acceptance by a worker - -- **Goal**: Prevent workers from accepting shifts they are not eligible to accept. -- **Where**: Backend validation (server-side). -- **Key rules (M4)** - - Prevent accepting overlapping shifts. - - If a shift is already accepted in that time window, reject the accept action. -- **Design requirement** - - Make the algorithm scalable so future shift-acceptance rules can be added without rewriting core logic. -- **Acceptance criteria** - - Backend rejects overlapping acceptance with a clear error reason. - - Validation is enforced even if the client app is bypassed. - -### Validate shift creation by a client - -- **Goal**: Ensure shifts/orders created by clients meet required criteria. -- **Where**: Backend validation (server-side). -- **Key rules (M4)** - - Add a *soft check* for minimum shift hours when creating an order. - - When a client creates an order, check if shift hours are below the vendor minimum. - - Current minimum hours: **5 hours**. -- **FE dependency** - - Also add a FE validation (user feedback) in addition to BE enforcement. -- **Design requirement** - - Make the algorithm scalable so future creation rules can be added. -- **Acceptance criteria** - - Backend returns a consistent validation response when the minimum-hours check fails. - - FE shows a clear validation message before submission (soft check). - -### Enforce cancellation policy (no cancellations within 24 hours) - -- **Goal**: Prevent cancellations within 24 hours of shift start time. -- **Where**: Backend enforcement. -- **Open decision** - - Finalize the penalty for cancellations within this window. -- **Acceptance criteria** - - Backend blocks cancellation attempts inside the 24-hour window. - - API response communicates the policy reason and references the penalty (once finalized). - -### Implement worker documentation upload process - -- **Goal**: Allow workers to upload required documents (e.g., certifications, tax forms) securely. -- **Where**: Backend (storage + linking). -- **Scope** - - Upload flow. - - Store documents. - - Link documents to the worker profile. -- **Acceptance criteria** - - Uploaded documents are stored securely and retrievable by authorized parties. - - Each upload is reliably associated with the correct worker profile. - -### Parse uploaded documentation for verification - -- **Goal**: Extract relevant info from uploaded documents to assist verification. -- **Where**: Backend. -- **Policy requirement** - - Manual verification by the client must still exist even if AI verification passes. -- **Acceptance criteria** - - Parsed fields are stored in a structured format for review. - - Client can manually verify/override AI results. - -### Support attire upload for workers - -- **Goal**: Allow workers to upload attire images for verification. -- **Where**: Backend. -- **Acceptance criteria** - - Attire images can be uploaded and linked to the worker profile. - -### Verify attire images against shift dress code - -- **Goal**: Verify uploaded attire meets dress code requirements. -- **Where**: Backend. -- **Policy requirement** - - Manual verification by the client must still exist even if AI verification passes. -- **Acceptance criteria** - - Verification results are stored and visible to the client for manual review. - -### Support shifts requiring "awaiting confirmation" status - -- **Goal**: Support shifts where the worker must manually confirm before the shift becomes active. -- **Where**: Backend. -- **Acceptance criteria** - - Shift can enter an "awaiting confirmation" state. - - Worker confirmation transitions the shift to the next expected active state. - -### Enable NFC-based clock-in and clock-out - -- **Goal**: Allow attendance via NFC. -- **Where**: Backend tasks (APIs/events/storage to support NFC clock-in/out). -- **Acceptance criteria** - - Backend can record NFC clock-in/out events with appropriate validation and auditing. - -### Implement worker profile visibility settings - -- **Goal**: Allow workers to hide their profile from clients if they choose. -- **Where**: Backend tasks (visibility settings + enforcement). -- **Acceptance criteria** - - Worker can change visibility setting. - - Backend enforces visibility in client-facing queries. - -### Support rapid order parsing (voice and text) using AI - -- **Goal**: Let clients create orders by describing needs in voice/text. -- **Where**: Backend tasks (parsing + mapping). -- **Notes** - - Always map the output similar to **one-time order creation**. -- **Acceptance criteria** - - Backend returns a structured draft order that matches the one-time order model. - -### Personalize shifts shown to workers (auto match) - -- **Goal**: Show worker-personalized shifts. -- **Where**: Backend matching/filtering logic. -- **Inputs (M4)** - - Preferred locations. - - Experience. -- **Acceptance criteria** - - Worker shift feed is personalized based on these inputs. - -### What backend work is needed to support recurring order and reorder? - -- **Goal**: Enable recurring orders and reorder. -- **Where**: Backend. -- **Acceptance criteria** - - Backend can create and manage recurring orders and support reorder actions. +# M4 Milestone Planning -### What backend work is needed to support permanent order and reorder? +--- -- **Goal**: Enable permanent orders and reorder. -- **Where**: Backend. -- **Acceptance criteria** - - Backend can create and manage permanent orders and support reorder actions. - -### Backend support for reports page (client mobile) - -- **Goal**: Provide APIs/data needed to render the main reports entry and summary experience. -- **Where**: Backend. -- **Acceptance criteria** - - Reports API contracts are defined and documented. - - Client can load the reports page without placeholder data. +## Backend (BE) -### Backend support for "Daily ops" report (client mobile) +### Make Order creation flow a transaction using cloud functions +**Goal:** Make Order creation flow a transaction using cloud functions +**Based on:** https://github.com/Oloodi/krow-workforce/issues/420 +--- -- **Goal**: Provide the data required for the Daily ops report. -- **Where**: Backend. -- **Acceptance criteria** - - Daily ops report API returns all fields required by the UI. - - Response is documented (including filters/date ranges, if applicable). +### Validate Shift Acceptance by a Worker -### Backend support for "Spend report" (client mobile) +**Goal:** Prevent workers from accepting shifts they are not eligible to accept. -- **Goal**: Provide the data required for the Spend report. -- **Where**: Backend. -- **Acceptance criteria** - - Spend report API returns all fields required by the UI. - - Response is documented (including filters/date ranges, if applicable). +**Where:** Backend validation (server-side). -### Backend support for "Coverage report" (client mobile) +#### Key Rules (M4) +- Prevent accepting overlapping shifts. If a shift is already accepted in that time window, reject the accept action. -- **Goal**: Provide the data required for the Coverage report. -- **Where**: Backend. -- **Acceptance criteria** - - Coverage report API returns all fields required by the UI. - - Response is documented (including filters/date ranges, if applicable). +#### Design Requirement +Make the algorithm scalable so future shift-acceptance rules can be added without rewriting core logic. -### Backend support for "No-show" report (client mobile) +#### Acceptance Criteria +- Backend rejects overlapping acceptance with a clear error reason. +- Validation is enforced even if the client app is bypassed. -- **Goal**: Provide the data required for the No-show report. -- **Where**: Backend. -- **Acceptance criteria** - - No-show report API returns all fields required by the UI. - - Response is documented (including filters/date ranges, if applicable). +--- -### Backend support for "Performance report" (client mobile) +### Validate Shift Creation by a Client -- **Goal**: Provide the data required for the Performance report. -- **Where**: Backend. -- **Acceptance criteria** - - Performance report API returns all fields required by the UI. - - Response is documented (including filters/date ranges, if applicable). +**Goal:** Ensure shifts/orders created by clients meet required criteria. -### Calculate the 3 AI insights for the client mobile reports page +**Where:** Backend validation (server-side). -- **Goal**: Produce the three AI insights shown on the reports page. -- **Where**: Backend. -- **Acceptance criteria** - - Insights are generated consistently and returned in the reports API response. +#### Key Rules (M4) +- Add a soft check for minimum shift hours when creating an order. +- When a client creates an order, check if shift hours are below the vendor minimum. +- Current minimum hours: **5 hours**. -## **FE** +#### FE Dependency +Also add a FE validation (user feedback) in addition to BE enforcement. -### **Staff mobile application** +#### Design Requirement +Make the algorithm scalable so future creation rules can be added. -### Show Google Maps location in worker shift details +#### Acceptance Criteria +- Backend returns a consistent validation response when the minimum-hours check fails. +- FE shows a clear validation message before submission (soft check). -- **Goal**: Display shift location on a map. -- **Acceptance criteria** - - Shift details page shows the correct location using Google Maps. +--- -### Show shift requirements in worker shift details +### Enforce Cancellation Policy (No Cancellations Within 24 Hours) -- **Goal**: Make requirements visible before acceptance. -- **Acceptance criteria** - - Shift details page includes a requirements section for that shift. -- **Details** - - The main goal is to have list the the required attires in the requirement section but if there are any other requirements we can also list them there. +**Goal:** Prevent cancellations within 24 hours of shift start time. -### Implement attire screen in worker app +**Where:** Backend enforcement. -- **Goal**: Let workers understand attire requirements and submit attire for review. -- **Scope (M4)** - - Show the list of **MUST HAVE** attire items. - - Show the list of **NICE TO HAVE** attire items. - - Allow workers to upload images of their attire for verification. - - Show uploaded attire images in the worker profile. -- **Acceptance criteria** - - Attire screen renders required lists and supports image upload flow. - - Uploaded images appear in the worker profile UI. +#### Open Decision +Finalize the penalty for cancellations within this window. -### Implement FAQ screen in worker app +#### Acceptance Criteria +- Backend blocks cancellation attempts inside the 24-hour window. +- API response communicates the policy reason and references the penalty (once finalized). -- **Goal**: Provide common answers in-app. -- **Acceptance criteria** - - Worker app includes an FAQ screen accessible from appropriate navigation. +--- -### Implement Privacy and Security screen in worker app +### Implement Worker Documentation Upload Process -- **Goal**: Provide key privacy/security controls and documents. -- **Scope (M4)** - - Profile visibility setting. - - Terms of service (use a generated placeholder for now; replace with final for launch). - - Privacy policy (use a generated placeholder for now; replace with final for launch). -- **Acceptance criteria** - - Screen is accessible and displays all items above. +**Goal:** Allow workers to upload required documents (e.g., certifications, tax forms) securely. -### Restrict navigation when worker profile is incomplete +**Where:** Backend (storage + linking). -- **Goal**: Reduce access until onboarding/profile completion. -- **Acceptance criteria** - - If the user has not completed their profile, only show **Profile** and **Home**. +#### Scope +- Upload flow. +- Store documents. +- Link documents to the worker profile. -### **Client mobile application** +#### Acceptance Criteria +- Uploaded documents are stored securely and retrievable by authorized parties. +- Each upload is reliably associated with the correct worker profile. -### Implement rapid order creation (voice + text) in client mobile app +--- -- **Goal**: Allow clients to quickly create same-day orders by describing needs via voice/text. -- **Scope (M4)** - - Capture voice/text input. - - Send input for parsing. - - Populate a screen equivalent to the one-time order creation screen so the client can adjust before finalizing. - - Always map similar to one-time order creation (handles same-day orders). -- **Acceptance criteria** - - Parsed output populates the one-time order creation UI correctly. - - Client can edit and successfully submit the order. +### Parse Uploaded Documentation for Verification -### Implement recurring order in client mobile app +**Goal:** Extract relevant info from uploaded documents to assist verification. -- **Goal**: Allow clients to create recurring orders. -- **Scope (M4)** - - Create a recurring order UI flow. -- **Acceptance criteria** - - Client can create and submit a recurring order successfully. +**Where:** Backend. -### Implement permanent order in client mobile app +#### Policy Requirement +Manual verification by the client must still exist even if AI verification passes. -- **Goal**: Allow clients to create permanent orders. -- **Scope (M4)** - - Create a permanent order UI flow. -- **Acceptance criteria** - - Client can create and submit a permanent order successfully. +#### Acceptance Criteria +- Parsed fields are stored in a structured format for review. +- Client can manually verify/override AI results. -### Update reorder modal to support order types +--- -- **Goal**: Ensure reorder works across supported order types. -- **Acceptance criteria** - - Reorder modal reflects the correct order type and fields. +### Support Attire Upload for Workers -### Complete reports interface with AI insights (client mobile) +**Goal:** Allow workers to upload attire images for verification. -- **Goal**: Implement the main reports UI and display AI insights. -- **Dependencies** - - Requires backend reports APIs and AI insights availability. -- **Acceptance criteria** - - Reports interface is complete and shows AI insights (no placeholder UI). +**Where:** Backend. -### Complete "Daily ops" report UI (client mobile) +#### Acceptance Criteria +- Attire images can be uploaded and linked to the worker profile. -- **Goal**: Implement the Daily ops report screen. -- **Dependencies** - - Requires backend Daily ops report support. -- **Acceptance criteria** - - Daily ops report UI is complete and renders real data. +--- -### Complete "Spend report" UI (client mobile) +### Verify Attire Images Against Shift Dress Code -- **Goal**: Implement the Spend report screen. -- **Dependencies** - - Requires backend Spend report support. -- **Acceptance criteria** - - Spend report UI is complete and renders real data. +**Goal:** Verify uploaded attire meets dress code requirements. -### Complete "Coverage report" UI (client mobile) +**Where:** Backend. -- **Goal**: Implement the Coverage report screen. -- **Dependencies** - - Requires backend Coverage report support. -- **Acceptance criteria** - - Coverage report UI is complete and renders real data. +#### Policy Requirement +Manual verification by the client must still exist even if AI verification passes. -### Complete "No-show" report UI (client mobile) +#### Acceptance Criteria +- Verification results are stored and visible to the client for manual review. -- **Goal**: Implement the No-show report screen. -- **Dependencies** - - Requires backend No-show report support. -- **Acceptance criteria** - - No-show report UI is complete and renders real data. +--- -### Complete "Performance report" UI (client mobile) +### Support Shifts Requiring "Awaiting Confirmation" Status -- **Goal**: Implement the Performance report screen. -- **Dependencies** - - Requires backend Performance report support. -- **Acceptance criteria** - - Performance report UI is complete and renders real data. +**Goal:** Support shifts where the worker must manually confirm before the shift becomes active. -### Build dedicated interface to display hub details +**Where:** Backend. -- **Goal**: Show hub details in a dedicated UI. -- **Acceptance criteria** - - Hub details page exists and displays hub information. +#### Acceptance Criteria +- Shift can enter an "awaiting confirmation" state. +- Worker confirmation transitions the shift to the next expected active state. -### Enable hub editing (separate page) +--- -- **Goal**: Allow hub editing via a dedicated screen. -- **Acceptance criteria** - - Separate edit hub page exists and updates the hub data. +### Enable NFC-Based Clock-In and Clock-Out -# **Research Tasks** +**Goal:** Allow attendance via NFC. -### Validate worker SSN number in the US +**Where:** Backend tasks (APIs/events/storage to support NFC clock-in/out). -- **Goal**: Identify a viable SSN validation approach. -- **Scope (research)** - - Research third-party services/APIs that provide SSN validation. - - Evaluate cost, reliability, and integration effort. - - Outline an integration plan and highlight risks. +#### Acceptance Criteria +- Backend can record NFC clock-in/out events with appropriate validation and auditing. -### Validate worker bank account details in the US +--- -- **Goal**: Identify a viable bank account validation approach. -- **Scope (research)** - - Research third-party services/APIs for bank account validation. - - Evaluate cost, reliability, and integration effort. - - Outline an integration plan and highlight risks. - - Note: legacy app only uses soft FE checks; M4 aims for a proper validation process. +### Implement Worker Profile Visibility Settings ✅ -### What payment platforms do we want to integrate for processing payments to workers? +**Goal:** Allow workers to hide their profile from clients if they choose. -- **Goal**: Select a payout/payment platform. -- **Scope (research)** - - Research platforms (e.g., Stripe, PayPal, Square) that support payouts. - - Evaluate cost, reliability, and integration effort. - - Outline an integration plan and highlight risks. +**Where:** Backend tasks (visibility settings + enforcement). -### Implement test cases for 2 web dashboard features using agent-browser +#### Acceptance Criteria +- Worker can change visibility setting. +- Backend enforces visibility in client-facing queries. -- **Goal**: Add automated test coverage runnable via . -- **Scope (research + spike)** - - Research how to implement test cases for web apps using the agent browser. +--- -# **Business Tasks** +### Support Rapid Order Parsing (Voice and Text) Using AI -### Create template model for PDF reports in client and web apps +**Goal:** Let clients create orders by describing needs in voice/text. -- **Goal**: Align report format across platforms. -- **Acceptance criteria** - - A shared template model is defined and ready to implement. +**Where:** Backend tasks (parsing + mapping). -### Handle operational risk scenarios that disadvantage clients +#### Notes +Always map the output similar to one-time order creation. -- **Goal**: Define policies for common operational issues. -- **Scenarios (M4)** - - Worker is a no-show. - - Shifts are not getting enough applicants. -- **Acceptance criteria** - - Written policy decisions exist and are ready to translate into product rules. +#### Acceptance Criteria +- Backend returns a structured draft order that matches the one-time order model. -### Finalize terms of service and privacy policy for mobile apps +--- -- **Goal**: Provide legal documents for launch readiness. -- **Acceptance criteria** - - Terms of service and privacy policy drafts exist and are approved for implementation. +### Personalize Shifts Shown to Workers (Auto Match) -### Handle worker data requests +**Goal:** Show worker-personalized shifts. -- **Goal**: Define a process for worker data requests. -- **Acceptance criteria** - - A documented workflow exists (intake, verification, fulfillment, timelines). +**Where:** Backend matching/filtering logic. -### How should we rephrase key terminology in the app (for clarity and accuracy)? +#### Inputs (M4) +- Preferred locations. +- Experience. -- **Goal**: Use consistent product language. -- **Topics (M4)** - - Worker registration: is it "signup" or "onboarding"? - - Context: workers are expected to be attached to a vendor; the flow is closer to onboarding (phone number + OTP) than self-service signup. - - Worker profile visibility: is it "profile visibility" or "availability status"? - - Context: workers are not fully invisible; they are marking themselves unavailable. - - What do we mean by "auto match"? - - Should this exist by default anyway? - - Is this only a marketing item? - - These are just examples; the discussion may surface additional terminology decisions. -- **Acceptance criteria** - - A terminology decision list exists with approved wording. +#### Acceptance Criteria +- Worker shift feed is personalized based on these inputs. +--- + +### Recurring Order and Reorder — Backend Support + +**Goal:** Enable recurring orders and reorder. + +**Where:** Backend. + +#### Acceptance Criteria +- Backend can create and manage recurring orders and support reorder actions. + +--- + +### Permanent Order and Reorder — Backend Support + +**Goal:** Enable permanent orders and reorder. + +**Where:** Backend. + +#### Acceptance Criteria +- Backend can create and manage permanent orders and support reorder actions. + +--- + +### Backend Support for Reports Page (Client Mobile) + +**Goal:** Provide APIs/data needed to render the main reports entry and summary experience. + +**Where:** Backend. + +#### Acceptance Criteria +- Reports API contracts are defined and documented. +- Client can load the reports page without placeholder data. + +--- + +### Backend Support for "Daily Ops" Report (Client Mobile) + +**Goal:** Provide the data required for the Daily Ops report. + +**Where:** Backend. + +#### Acceptance Criteria +- Daily Ops report API returns all fields required by the UI. +- Response is documented (including filters/date ranges, if applicable). + +--- + +### Backend Support for "Spend Report" (Client Mobile) + +**Goal:** Provide the data required for the Spend report. + +**Where:** Backend. + +#### Acceptance Criteria +- Spend report API returns all fields required by the UI. +- Response is documented (including filters/date ranges, if applicable). + +--- + +### Backend Support for "Coverage Report" (Client Mobile) + +**Goal:** Provide the data required for the Coverage report. + +**Where:** Backend. + +#### Acceptance Criteria +- Coverage report API returns all fields required by the UI. +- Response is documented (including filters/date ranges, if applicable). + +--- + +### Backend Support for "No-Show" Report (Client Mobile) + +**Goal:** Provide the data required for the No-show report. + +**Where:** Backend. + +#### Acceptance Criteria +- No-show report API returns all fields required by the UI. +- Response is documented (including filters/date ranges, if applicable). + +--- + +### Backend Support for "Performance Report" (Client Mobile) + +**Goal:** Provide the data required for the Performance report. + +**Where:** Backend. + +#### Acceptance Criteria +- Performance report API returns all fields required by the UI. +- Response is documented (including filters/date ranges, if applicable). + +--- + +### Calculate the 3 AI Insights for Client Mobile Reports Page + +**Goal:** Produce the three AI insights shown on the reports page. + +**Where:** Backend. + +#### Acceptance Criteria +- Insights are generated consistently and returned in the reports API response. + +--- + +## Frontend (FE) + +### Maintain Auth Session (Client and Staff Mobile) + +**Goal:** Maintain the authentication session of the client and staff mobile application. + +#### Details +Currently when the user restarts the application they are prompted to login. This UX barrier should be removed — if a valid authentication session is available, use it instead of prompting the user to re-login. + +#### Acceptance Criteria +- The authentication session of the client and staff mobile applications is maintained across restarts. + +--- + +### Enable iOS Deployment + +**Goal:** Enable iOS deployment of both applications. + +--- + +## Staff Mobile Application + +### Show Google Maps Location in Worker Shift Details ✅ + +**Goal:** Display shift location on a map. + +#### Acceptance Criteria +- Shift details page shows the correct location using Google Maps. + +--- + +### Show Shift Requirements in Worker Shift Details + +**Goal:** Make requirements visible before acceptance. + +#### Details +The main goal is to list the required attires in the requirements section, but any other requirements can also be listed there. + +#### Acceptance Criteria +- Shift details page includes a requirements section for that shift. + +--- + +### Implement Attire Screen in Worker App + +**Goal:** Let workers understand attire requirements and submit attire for review. + +#### Scope (M4) +- Show the list of MUST HAVE attire items. +- Show the list of NICE TO HAVE attire items. +- Allow workers to upload images of their attire for verification. +- Show uploaded attire images in the worker profile. + +#### Acceptance Criteria +- Attire screen renders required lists and supports image upload flow. +- Uploaded images appear in the worker profile UI. + +--- + +### Implement FAQ Screen in Worker App + +**Goal:** Provide common answers in-app. + +#### Acceptance Criteria +- Worker app includes an FAQ screen accessible from appropriate navigation. + +--- + +### Implement Privacy and Security Screen in Worker App + +**Goal:** Provide key privacy/security controls and documents. + +#### Scope (M4) +- Profile visibility setting. +- Terms of service (use a generated placeholder for now; replace with final for launch). +- Privacy policy (use a generated placeholder for now; replace with final for launch). + +#### Acceptance Criteria +- Screen is accessible and displays all items above. + +--- + +### Restrict Navigation When Worker Profile Is Incomplete + +**Goal:** Reduce access until onboarding/profile completion. + +#### Acceptance Criteria +- If the user has not completed their profile, only show **Profile** and **Home**. + +--- + +### Preferred Location Edit + +**Goal:** Create a separate page to edit the preferred locations of the staff. + +--- + +## Client Mobile Application + +### Hide Edit Icon for Past or Completed Orders + +**Goal:** In the client application view order screen, hide the edit icon for orders that are in the past or completed. + +--- + +### Implement Rapid Order Creation (Voice + Text) + +**Goal:** Allow clients to quickly create same-day orders by describing needs via voice/text. + +#### Scope (M4) +- Capture voice/text input. +- Send input for parsing. +- Populate a screen equivalent to the one-time order creation screen so the client can adjust before finalizing. +- Always map similar to one-time order creation (handles same-day orders). + +#### Acceptance Criteria +- Parsed output populates the one-time order creation UI correctly. +- Client can edit and successfully submit the order. + +--- + +### Implement Recurring Order + +**Goal:** Allow clients to create recurring orders. + +#### Scope (M4) +- Create a recurring order UI flow. + +#### Acceptance Criteria +- Client can create and submit a recurring order successfully. + +--- + +### Implement Permanent Order + +**Goal:** Allow clients to create permanent orders. + +#### Scope (M4) +- Create a permanent order UI flow. + +#### Acceptance Criteria +- Client can create and submit a permanent order successfully. + +--- + +### Update Reorder Modal to Support Order Types + +**Goal:** Ensure reorder works across supported order types. + +#### Acceptance Criteria +- Reorder modal reflects the correct order type and fields. + +--- + +### Complete Reports Interface with AI Insights + +**Goal:** Implement the main reports UI and display AI insights. + +#### Dependencies +Requires backend reports APIs and AI insights availability. + +#### Acceptance Criteria +- Reports interface is complete and shows AI insights (no placeholder UI). + +--- + +### Complete "Daily Ops" Report UI + +**Goal:** Implement the Daily Ops report screen. + +#### Dependencies +Requires backend Daily Ops report support. + +#### Acceptance Criteria +- Daily Ops report UI is complete and renders real data. + +--- + +### Complete "Spend Report" UI + +**Goal:** Implement the Spend report screen. + +#### Dependencies +Requires backend Spend report support. + +#### Acceptance Criteria +- Spend report UI is complete and renders real data. + +--- + +### Complete "Coverage Report" UI + +**Goal:** Implement the Coverage report screen. + +#### Dependencies +Requires backend Coverage report support. + +#### Acceptance Criteria +- Coverage report UI is complete and renders real data. + +--- + +### Complete "No-Show" Report UI + +**Goal:** Implement the No-show report screen. + +#### Dependencies +Requires backend No-show report support. + +#### Acceptance Criteria +- No-show report UI is complete and renders real data. + +--- + +### Complete "Performance Report" UI + +**Goal:** Implement the Performance report screen. + +#### Dependencies +Requires backend Performance report support. + +#### Acceptance Criteria +- Performance report UI is complete and renders real data. + +--- + +### Build Dedicated Interface to Display Hub Details + +**Goal:** Show hub details in a dedicated UI. + +#### Acceptance Criteria +- Hub details page exists and displays hub information. + +--- + +### Enable Hub Editing (Separate Page) + +**Goal:** Allow hub editing via a dedicated screen. + +#### Acceptance Criteria +- Separate edit hub page exists and updates the hub data. + +--- + +## Research Tasks + +### Validate Worker SSN Number in the US + +**Goal:** Identify a viable SSN validation approach. + +#### Scope (Research) +- Research third-party services/APIs that provide SSN validation. +- Evaluate cost, reliability, and integration effort. +- Outline an integration plan and highlight risks. + +--- + +### Validate Worker Bank Account Details in the US + +**Goal:** Identify a viable bank account validation approach. + +#### Scope (Research) +- Research third-party services/APIs for bank account validation. +- Evaluate cost, reliability, and integration effort. +- Outline an integration plan and highlight risks. + +> **Note:** Legacy app only uses soft FE checks; M4 aims for a proper validation process. + +--- + +### Payment Platform Research for Worker Payouts + +**Goal:** Select a payout/payment platform. + +#### Scope (Research) +- Research platforms (e.g., Stripe, PayPal, Square) that support payouts. +- Evaluate cost, reliability, and integration effort. +- Outline an integration plan and highlight risks. + +--- + +### Implement Test Cases for 2 Web Dashboard Features Using Agent Browser + +**Goal:** Add automated test coverage runnable via [agent-browser.dev](https://agent-browser.dev/). + +#### Scope (Research + Spike) +- Research how to implement test cases for web apps using the agent browser. + +--- + +## Business Tasks + +### Create Template Model for PDF Reports (Client and Web Apps) + +**Goal:** Align report format across platforms. + +#### Acceptance Criteria +- A shared template model is defined and ready to implement. + +--- + +### Handle Operational Risk Scenarios That Disadvantage Clients + +**Goal:** Define policies for common operational issues. + +#### Scenarios (M4) +- Worker is a no-show. +- Shifts are not getting enough applicants. + +#### Acceptance Criteria +- Written policy decisions exist and are ready to translate into product rules. + +--- + +### Finalize Terms of Service and Privacy Policy for Mobile Apps + +**Goal:** Provide legal documents for launch readiness. + +#### Acceptance Criteria +- Terms of service and privacy policy drafts exist and are approved for implementation. + +--- + +### Handle Worker Data Requests + +**Goal:** Define a process for worker data requests. + +#### Acceptance Criteria +- A documented workflow exists (intake, verification, fulfillment, timelines). + +--- + +### Rephrase Key Terminology in the App + +**Goal:** Use consistent product language. + +#### Topics (M4) + +- **Worker registration:** Is it "signup" or "onboarding"? + - Context: Workers are expected to be attached to a vendor; the flow is closer to onboarding (phone number + OTP) than self-service signup. + +- **Worker profile visibility:** Is it "profile visibility" or "availability status"? + - Context: Workers are not fully invisible; they are marking themselves unavailable. + +- **"Auto match":** What do we mean by this? + - Should this exist by default anyway? + - Is this only a marketing item? + +> These are just examples; the discussion may surface additional terminology decisions. + +#### Acceptance Criteria +- A terminology decision list exists with approved wording. From 50c6be46a0b9c3ff4fb94d8fc1c39e7ee8e40928 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Feb 2026 16:18:57 -0500 Subject: [PATCH 52/53] Add goal for order creation transaction in backend Added goal for order creation flow transaction using cloud functions. --- docs/MILESTONES/M4/planning/m4-planning.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/MILESTONES/M4/planning/m4-planning.md b/docs/MILESTONES/M4/planning/m4-planning.md index caa59eaf..47563990 100644 --- a/docs/MILESTONES/M4/planning/m4-planning.md +++ b/docs/MILESTONES/M4/planning/m4-planning.md @@ -5,7 +5,9 @@ ## Backend (BE) ### Make Order creation flow a transaction using cloud functions + **Goal:** Make Order creation flow a transaction using cloud functions + **Based on:** https://github.com/Oloodi/krow-workforce/issues/420 --- From 61be89860920e4413943f87222d9c80df09349b5 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Feb 2026 16:19:07 -0500 Subject: [PATCH 53/53] Add goal and basis for M4 planning document --- docs/MILESTONES/M4/planning/m4-planning.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/MILESTONES/M4/planning/m4-planning.md b/docs/MILESTONES/M4/planning/m4-planning.md index 47563990..596f8992 100644 --- a/docs/MILESTONES/M4/planning/m4-planning.md +++ b/docs/MILESTONES/M4/planning/m4-planning.md @@ -9,6 +9,7 @@ **Goal:** Make Order creation flow a transaction using cloud functions **Based on:** https://github.com/Oloodi/krow-workforce/issues/420 + --- ### Validate Shift Acceptance by a Worker