feat: Migrate staff profile features from Data Connect to V2 REST API

- Removed data_connect package from mobile pubspec.yaml.
- Added documentation for V2 profile migration status and QA findings.
- Implemented new session management with ClientSessionStore and StaffSessionStore.
- Created V2SessionService for handling user sessions via the V2 API.
- Developed use cases for cancelling late worker assignments and submitting worker reviews.
- Added arguments and use cases for payment chart retrieval and profile completion checks.
- Implemented repository interfaces and their implementations for staff main and profile features.
- Ensured proper error handling and validation in use cases.
This commit is contained in:
Achintha Isuru
2026-03-16 22:45:06 -04:00
parent 4834266986
commit b31a615092
478 changed files with 10512 additions and 19854 deletions

View File

@@ -20,11 +20,6 @@ public final class GeneratedPluginRegistrant {
} catch (Exception e) {
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin firebase_app_check, io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.auth.FlutterFirebaseAuthPlugin());
} catch (Exception e) {

View File

@@ -12,12 +12,6 @@
@import file_picker;
#endif
#if __has_include(<firebase_app_check/FLTFirebaseAppCheckPlugin.h>)
#import <firebase_app_check/FLTFirebaseAppCheckPlugin.h>
#else
@import firebase_app_check;
#endif
#if __has_include(<firebase_auth/FLTFirebaseAuthPlugin.h>)
#import <firebase_auth/FLTFirebaseAuthPlugin.h>
#else
@@ -82,7 +76,6 @@
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
[FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]];
[FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]];
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
[FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]];

View File

@@ -14,7 +14,6 @@ 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';
@@ -31,8 +30,9 @@ void main() async {
logStateChanges: false, // Set to true for verbose debugging
);
// Initialize session listener for Firebase Auth state changes
DataConnectService.instance.initializeAuthListener(
// Initialize V2 session listener for Firebase Auth state changes.
// Role validation calls GET /auth/session via the V2 API.
V2SessionService.instance.initializeAuthListener(
allowedRoles: <String>[
'CLIENT',
'BUSINESS',

View File

@@ -3,7 +3,6 @@ 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.
///
@@ -32,7 +31,7 @@ class _SessionListenerState extends State<SessionListener> {
}
void _setupSessionListener() {
_sessionSubscription = DataConnectService.instance.onSessionStateChanged
_sessionSubscription = V2SessionService.instance.onSessionStateChanged
.listen((SessionState state) {
_handleSessionChange(state);
});
@@ -134,7 +133,7 @@ class _SessionListenerState extends State<SessionListener> {
),
TextButton(
onPressed: () {
Modular.to.popSafe();;
Modular.to.popSafe();
_proceedToLogin();
},
child: const Text('Log Out'),
@@ -147,8 +146,9 @@ class _SessionListenerState extends State<SessionListener> {
/// Navigate to login screen and clear navigation stack.
void _proceedToLogin() {
// Clear service caches on sign-out
DataConnectService.instance.handleSignOut();
// Clear session stores on sign-out
V2SessionService.instance.handleSignOut();
ClientSessionStore.instance.clear();
// Navigate to authentication
Modular.to.toClientGetStartedPage();

View File

@@ -7,7 +7,6 @@ import Foundation
import file_picker
import file_selector_macos
import firebase_app_check
import firebase_auth
import firebase_core
import flutter_local_notifications
@@ -20,7 +19,6 @@ import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))

View File

@@ -41,7 +41,6 @@ dependencies:
flutter_localizations:
sdk: flutter
firebase_core: ^4.4.0
krow_data_connect: ^0.0.1
dev_dependencies:
flutter_test:

View File

@@ -20,11 +20,6 @@ public final class GeneratedPluginRegistrant {
} catch (Exception e) {
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin firebase_app_check, io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.auth.FlutterFirebaseAuthPlugin());
} catch (Exception e) {
@@ -50,11 +45,6 @@ public final class GeneratedPluginRegistrant {
} catch (Exception e) {
Log.e(TAG, "Error registering plugin geolocator_android, com.baseflow.geolocator.GeolocatorPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.googlemaps.GoogleMapsPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin google_maps_flutter_android, io.flutter.plugins.googlemaps.GoogleMapsPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin());
} catch (Exception e) {

View File

@@ -12,12 +12,6 @@
@import file_picker;
#endif
#if __has_include(<firebase_app_check/FLTFirebaseAppCheckPlugin.h>)
#import <firebase_app_check/FLTFirebaseAppCheckPlugin.h>
#else
@import firebase_app_check;
#endif
#if __has_include(<firebase_auth/FLTFirebaseAuthPlugin.h>)
#import <firebase_auth/FLTFirebaseAuthPlugin.h>
#else
@@ -42,12 +36,6 @@
@import geolocator_apple;
#endif
#if __has_include(<google_maps_flutter_ios/FLTGoogleMapsPlugin.h>)
#import <google_maps_flutter_ios/FLTGoogleMapsPlugin.h>
#else
@import google_maps_flutter_ios;
#endif
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
#import <image_picker_ios/FLTImagePickerPlugin.h>
#else
@@ -88,12 +76,10 @@
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
[FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]];
[FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]];
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
[FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]];
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
[FLTGoogleMapsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleMapsPlugin"]];
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
[FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
[RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]];

View File

@@ -6,7 +6,6 @@ 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 'package:krowwithus_staff/firebase_options.dart';
import 'package:staff_authentication/staff_authentication.dart'
as staff_authentication;
@@ -29,8 +28,9 @@ void main() async {
logStateChanges: false, // Set to true for verbose debugging
);
// Initialize session listener for Firebase Auth state changes
DataConnectService.instance.initializeAuthListener(
// Initialize V2 session listener for Firebase Auth state changes.
// Role validation calls GET /auth/session via the V2 API.
V2SessionService.instance.initializeAuthListener(
allowedRoles: <String>[
'STAFF',
'BOTH',

View File

@@ -3,7 +3,6 @@ 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.
///
@@ -32,7 +31,7 @@ class _SessionListenerState extends State<SessionListener> {
}
void _setupSessionListener() {
_sessionSubscription = DataConnectService.instance.onSessionStateChanged
_sessionSubscription = V2SessionService.instance.onSessionStateChanged
.listen((SessionState state) {
_handleSessionChange(state);
});
@@ -65,6 +64,19 @@ class _SessionListenerState extends State<SessionListener> {
_sessionExpiredDialogShown = false;
debugPrint('[SessionListener] Authenticated: ${state.userId}');
// Don't auto-navigate while the auth flow is active — the auth
// BLoC handles its own navigation (e.g. profile-setup for new users).
final String currentPath = Modular.to.path;
if (currentPath.contains('/phone-verification') ||
currentPath.contains('/profile-setup') ||
currentPath.contains('/get-started')) {
debugPrint(
'[SessionListener] Skipping home navigation — auth flow active '
'(path: $currentPath)',
);
break;
}
// Navigate to the main app
Modular.to.toStaffHome();
break;
@@ -104,7 +116,7 @@ class _SessionListenerState extends State<SessionListener> {
actions: <Widget>[
TextButton(
onPressed: () {
Modular.to.popSafe();;
Modular.to.popSafe();
_proceedToLogin();
},
child: const Text('Log In'),
@@ -134,7 +146,7 @@ class _SessionListenerState extends State<SessionListener> {
),
TextButton(
onPressed: () {
Modular.to.popSafe();;
Modular.to.popSafe();
_proceedToLogin();
},
child: const Text('Log Out'),
@@ -147,8 +159,9 @@ class _SessionListenerState extends State<SessionListener> {
/// Navigate to login screen and clear navigation stack.
void _proceedToLogin() {
// Clear service caches on sign-out
DataConnectService.instance.handleSignOut();
// Clear session stores on sign-out
V2SessionService.instance.handleSignOut();
StaffSessionStore.instance.clear();
// Navigate to authentication
Modular.to.toGetStartedPage();

View File

@@ -7,7 +7,6 @@ import Foundation
import file_picker
import file_selector_macos
import firebase_app_check
import firebase_auth
import firebase_core
import flutter_local_notifications
@@ -20,7 +19,6 @@ import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))

View File

@@ -28,8 +28,6 @@ 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

View File

@@ -34,6 +34,11 @@ export 'src/services/api_service/core_api_services/verification/verification_res
export 'src/services/api_service/core_api_services/rapid_order/rapid_order_service.dart';
export 'src/services/api_service/core_api_services/rapid_order/rapid_order_response.dart';
// Session Management
export 'src/services/session/client_session_store.dart';
export 'src/services/session/staff_session_store.dart';
export 'src/services/session/v2_session_service.dart';
// Device Services
export 'src/services/device/camera/camera_service.dart';
export 'src/services/device/gallery/gallery_service.dart';

View File

@@ -18,6 +18,15 @@ class CoreModule extends Module {
// 2. Register the base API service
i.addLazySingleton<BaseApiService>(() => ApiService(i.get<Dio>()));
// 2b. Wire the V2 session service with the API service.
// This uses a post-registration callback so the singleton gets
// its dependency as soon as the injector resolves BaseApiService.
i.addLazySingleton<V2SessionService>(() {
final V2SessionService service = V2SessionService.instance;
service.setApiService(i.get<BaseApiService>());
return service;
});
// 3. Register Core API Services (Orchestrators)
i.addLazySingleton<FileUploadService>(
() => FileUploadService(i.get<BaseApiService>()),

View File

@@ -98,6 +98,13 @@ extension StaffNavigator on IModularNavigator {
safeNavigate(StaffPaths.shiftDetails(shift.id), arguments: shift);
}
/// Navigates to shift details by ID only (no pre-fetched [Shift] object).
///
/// Used when only the shift ID is available (e.g. from dashboard list items).
void toShiftDetailsById(String shiftId) {
safeNavigate(StaffPaths.shiftDetails(shiftId));
}
void toPersonalInfo() {
safePush(StaffPaths.onboardingPersonalInfo);
}
@@ -118,7 +125,7 @@ extension StaffNavigator on IModularNavigator {
safeNavigate(StaffPaths.attire);
}
void toAttireCapture({required AttireItem item, String? initialPhotoUrl}) {
void toAttireCapture({required AttireChecklist item, String? initialPhotoUrl}) {
safeNavigate(
StaffPaths.attireCapture,
arguments: <String, dynamic>{
@@ -132,7 +139,7 @@ extension StaffNavigator on IModularNavigator {
safeNavigate(StaffPaths.documents);
}
void toDocumentUpload({required StaffDocument document, String? initialUrl}) {
void toDocumentUpload({required ProfileDocument document, String? initialUrl}) {
safeNavigate(
StaffPaths.documentUpload,
arguments: <String, dynamic>{

View File

@@ -158,7 +158,7 @@ mixin SessionHandlerMixin {
final Duration timeUntilExpiry = expiryTime.difference(now);
if (timeUntilExpiry <= _refreshThreshold) {
await user.getIdTokenResult();
await user.getIdTokenResult(true);
}
_lastTokenRefreshTime = now;
@@ -212,9 +212,9 @@ mixin SessionHandlerMixin {
final firebase_auth.IdTokenResult idToken =
await user.getIdTokenResult();
if (idToken.expirationTime != null &&
DateTime.now().difference(idToken.expirationTime!) <
idToken.expirationTime!.difference(DateTime.now()) <
const Duration(minutes: 5)) {
await user.getIdTokenResult();
await user.getIdTokenResult(true);
}
_emitSessionState(SessionState.authenticated(userId: user.uid));

View File

@@ -0,0 +1,28 @@
import 'package:krow_domain/krow_domain.dart' show ClientSession;
/// Singleton store for the authenticated client's session context.
///
/// Holds a [ClientSession] (V2 domain entity) populated after sign-in via the
/// V2 session API. Features read from this store to access business context
/// without re-fetching from the backend.
class ClientSessionStore {
ClientSessionStore._();
/// The global singleton instance.
static final ClientSessionStore instance = ClientSessionStore._();
ClientSession? _session;
/// The current client session, or `null` if not authenticated.
ClientSession? get session => _session;
/// Replaces the current session with [session].
void setSession(ClientSession session) {
_session = session;
}
/// Clears the stored session (e.g. on sign-out).
void clear() {
_session = null;
}
}

View File

@@ -0,0 +1,28 @@
import 'package:krow_domain/krow_domain.dart' show StaffSession;
/// Singleton store for the authenticated staff member's session context.
///
/// Holds a [StaffSession] (V2 domain entity) populated after sign-in via the
/// V2 session API. Features read from this store to access staff/tenant context
/// without re-fetching from the backend.
class StaffSessionStore {
StaffSessionStore._();
/// The global singleton instance.
static final StaffSessionStore instance = StaffSessionStore._();
StaffSession? _session;
/// The current staff session, or `null` if not authenticated.
StaffSession? get session => _session;
/// Replaces the current session with [session].
void setSession(StaffSession session) {
_session = session;
}
/// Clears the stored session (e.g. on sign-out).
void clear() {
_session = null;
}
}

View File

@@ -0,0 +1,101 @@
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
import 'package:flutter/foundation.dart';
import 'package:krow_domain/krow_domain.dart';
import '../api_service/api_service.dart';
import '../api_service/core_api_services/v2_api_endpoints.dart';
import '../api_service/mixins/session_handler_mixin.dart';
/// A singleton service that manages user session state via the V2 REST API.
///
/// Replaces `DataConnectService` for auth-state listening, role validation,
/// and session-state broadcasting. Uses [SessionHandlerMixin] for token
/// refresh and retry logic.
class V2SessionService with SessionHandlerMixin {
V2SessionService._();
/// The global singleton instance.
static final V2SessionService instance = V2SessionService._();
/// Optional [BaseApiService] reference set during DI initialisation.
///
/// When `null` the service falls back to a raw Dio call so that
/// `initializeAuthListener` can work before the Modular injector is ready.
BaseApiService? _apiService;
/// Injects the [BaseApiService] dependency.
///
/// Call once from `CoreModule.exportedBinds` after registering [ApiService].
void setApiService(BaseApiService apiService) {
_apiService = apiService;
}
@override
firebase_auth.FirebaseAuth get auth => firebase_auth.FirebaseAuth.instance;
/// Fetches the user role by calling `GET /auth/session`.
///
/// Returns the role string (e.g. `STAFF`, `BUSINESS`, `BOTH`) or `null` if
/// the call fails or the user has no role.
@override
Future<String?> fetchUserRole(String userId) async {
try {
// Wait for ApiService to be injected (happens after CoreModule.exportedBinds).
// On cold start, initializeAuthListener fires before DI is ready.
if (_apiService == null) {
debugPrint(
'[V2SessionService] ApiService not yet injected; '
'waiting for DI initialization...',
);
for (int i = 0; i < 10; i++) {
await Future<void>.delayed(const Duration(milliseconds: 200));
if (_apiService != null) break;
}
}
final BaseApiService? api = _apiService;
if (api == null) {
debugPrint(
'[V2SessionService] ApiService still null after waiting 2 s; '
'cannot fetch user role.',
);
return null;
}
final ApiResponse response = await api.get(V2ApiEndpoints.session);
if (response.data is Map<String, dynamic>) {
final Map<String, dynamic> data =
response.data as Map<String, dynamic>;
final String? role = data['role'] as String?;
return role;
}
return null;
} catch (e) {
debugPrint('[V2SessionService] Error fetching user role: $e');
return null;
}
}
/// Signs out the current user from Firebase Auth and clears local state.
Future<void> signOut() async {
try {
// Revoke server-side session token.
final BaseApiService? api = _apiService;
if (api != null) {
try {
await api.post(V2ApiEndpoints.signOut);
} catch (e) {
debugPrint('[V2SessionService] Server sign-out failed: $e');
}
}
await auth.signOut();
} catch (e) {
debugPrint('[V2SessionService] Error signing out: $e');
rethrow;
} finally {
handleSignOut();
}
}
}

View File

@@ -785,6 +785,9 @@
"personal_info": {
"title": "Personal Info",
"change_photo_hint": "Tap to change photo",
"choose_photo_source": "Choose Photo Source",
"photo_upload_success": "Profile photo updated",
"photo_upload_failed": "Failed to upload photo. Please try again.",
"full_name_label": "Full Name",
"email_label": "Email",
"phone_label": "Phone Number",

View File

@@ -780,6 +780,9 @@
"personal_info": {
"title": "Informaci\u00f3n Personal",
"change_photo_hint": "Toca para cambiar foto",
"choose_photo_source": "Elegir fuente de foto",
"photo_upload_success": "Foto de perfil actualizada",
"photo_upload_failed": "Error al subir la foto. Por favor, int\u00e9ntalo de nuevo.",
"full_name_label": "Nombre Completo",
"email_label": "Correo Electr\u00f3nico",
"phone_label": "N\u00famero de Tel\u00e9fono",

View File

@@ -1,52 +0,0 @@
/// The Data Connect layer.
///
/// This package provides mock implementations of domain repository interfaces
/// for development and testing purposes.
///
/// They will implement interfaces defined in feature packages once those are created.
library;
export 'src/data_connect_module.dart';
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';
// Export Staff Connector repositories and use cases
export 'src/connectors/staff/domain/repositories/staff_connector_repository.dart';
export 'src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart';
export 'src/connectors/staff/domain/usecases/get_personal_info_completion_usecase.dart';
export 'src/connectors/staff/domain/usecases/get_emergency_contacts_completion_usecase.dart';
export 'src/connectors/staff/domain/usecases/get_experience_completion_usecase.dart';
export 'src/connectors/staff/domain/usecases/get_tax_forms_completion_usecase.dart';
export 'src/connectors/staff/domain/usecases/get_attire_options_completion_usecase.dart';
export 'src/connectors/staff/domain/usecases/get_staff_documents_completion_usecase.dart';
export 'src/connectors/staff/domain/usecases/get_staff_certificates_completion_usecase.dart';
export 'src/connectors/staff/domain/usecases/get_staff_profile_usecase.dart';
export 'src/connectors/staff/domain/usecases/sign_out_staff_usecase.dart';
export 'src/connectors/staff/data/repositories/staff_connector_repository_impl.dart';
// Export Reports Connector
export 'src/connectors/reports/domain/repositories/reports_connector_repository.dart';
export 'src/connectors/reports/data/repositories/reports_connector_repository_impl.dart';
// Export Shifts Connector
export 'src/connectors/shifts/domain/repositories/shifts_connector_repository.dart';
export 'src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart';
// Export Hubs Connector
export 'src/connectors/hubs/domain/repositories/hubs_connector_repository.dart';
export 'src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart';
// Export Billing Connector
export 'src/connectors/billing/domain/repositories/billing_connector_repository.dart';
export 'src/connectors/billing/data/repositories/billing_connector_repository_impl.dart';
// Export Coverage Connector
export 'src/connectors/coverage/domain/repositories/coverage_connector_repository.dart';
export 'src/connectors/coverage/data/repositories/coverage_connector_repository_impl.dart';

View File

@@ -1,373 +0,0 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
import 'package:firebase_data_connect/src/core/ref.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/billing_connector_repository.dart';
/// Implementation of [BillingConnectorRepository].
class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
BillingConnectorRepositoryImpl({dc.DataConnectService? service})
: _service = service ?? dc.DataConnectService.instance;
final dc.DataConnectService _service;
@override
Future<List<BusinessBankAccount>> getBankAccounts({
required String businessId,
}) async {
return _service.run(() async {
final QueryResult<
dc.GetAccountsByOwnerIdData,
dc.GetAccountsByOwnerIdVariables
>
result = await _service.connector
.getAccountsByOwnerId(ownerId: businessId)
.execute();
return result.data.accounts.map(_mapBankAccount).toList();
});
}
@override
Future<double> getCurrentBillAmount({required String businessId}) async {
return _service.run(() async {
final QueryResult<
dc.ListInvoicesByBusinessIdData,
dc.ListInvoicesByBusinessIdVariables
>
result = await _service.connector
.listInvoicesByBusinessId(businessId: businessId)
.execute();
return result.data.invoices
.map(_mapInvoice)
.where((Invoice i) => i.status == InvoiceStatus.open)
.fold<double>(
0.0,
(double sum, Invoice item) => sum + item.totalAmount,
);
});
}
@override
Future<List<Invoice>> getInvoiceHistory({required String businessId}) async {
return _service.run(() async {
final QueryResult<
dc.ListInvoicesByBusinessIdData,
dc.ListInvoicesByBusinessIdVariables
>
result = await _service.connector
.listInvoicesByBusinessId(businessId: businessId)
.limit(20)
.execute();
return result.data.invoices
.map(_mapInvoice)
.where((Invoice i) => i.status == InvoiceStatus.paid)
.toList();
});
}
@override
Future<List<Invoice>> getPendingInvoices({required String businessId}) async {
return _service.run(() async {
final QueryResult<
dc.ListInvoicesByBusinessIdData,
dc.ListInvoicesByBusinessIdVariables
>
result = await _service.connector
.listInvoicesByBusinessId(businessId: businessId)
.execute();
return result.data.invoices
.map(_mapInvoice)
.where(
(Invoice i) =>
i.status != InvoiceStatus.paid &&
i.status != InvoiceStatus.disputed &&
i.status != InvoiceStatus.open,
)
.toList();
});
}
@override
Future<List<InvoiceItem>> getSpendingBreakdown({
required String businessId,
required BillingPeriod period,
}) async {
return _service.run(() async {
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 = monday;
end = monday.add(
const Duration(days: 6, hours: 23, minutes: 59, seconds: 59),
);
} else {
start = DateTime(now.year, now.month, 1);
end = DateTime(now.year, now.month + 1, 0, 23, 59, 59);
}
final QueryResult<
dc.ListShiftRolesByBusinessAndDatesSummaryData,
dc.ListShiftRolesByBusinessAndDatesSummaryVariables
>
result = await _service.connector
.listShiftRolesByBusinessAndDatesSummary(
businessId: businessId,
start: _service.toTimestamp(start),
end: _service.toTimestamp(end),
)
.execute();
final List<dc.ListShiftRolesByBusinessAndDatesSummaryShiftRoles>
shiftRoles = result.data.shiftRoles;
if (shiftRoles.isEmpty) return <InvoiceItem>[];
final Map<String, _RoleSummary> summary = <String, _RoleSummary>{};
for (final dc.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();
});
}
@override
Future<void> approveInvoice({required String id}) async {
return _service.run(() async {
await _service.connector
.updateInvoice(id: id)
.status(dc.InvoiceStatus.APPROVED)
.execute();
});
}
@override
Future<void> disputeInvoice({
required String id,
required String reason,
}) async {
return _service.run(() async {
await _service.connector
.updateInvoice(id: id)
.status(dc.InvoiceStatus.DISPUTED)
.disputeReason(reason)
.execute();
});
}
// --- MAPPERS ---
Invoice _mapInvoice(dynamic invoice) {
List<InvoiceWorker> workers = <InvoiceWorker>[];
// Try to get workers from denormalized 'roles' field first
final List<dynamic> rolesData = invoice.roles is List
? invoice.roles
: <dynamic>[];
if (rolesData.isNotEmpty) {
workers = rolesData.map((dynamic r) {
final Map<String, dynamic> role = r as Map<String, dynamic>;
// Handle various possible key naming conventions in the JSON data
final String name =
role['name'] ?? role['staffName'] ?? role['fullName'] ?? 'Unknown';
final String roleTitle =
role['role'] ?? role['roleName'] ?? role['title'] ?? 'Staff';
final double amount =
(role['amount'] as num?)?.toDouble() ??
(role['totalValue'] as num?)?.toDouble() ??
0.0;
final double hours =
(role['hours'] as num?)?.toDouble() ??
(role['workHours'] as num?)?.toDouble() ??
(role['totalHours'] as num?)?.toDouble() ??
0.0;
final double rate =
(role['rate'] as num?)?.toDouble() ??
(role['hourlyRate'] as num?)?.toDouble() ??
0.0;
final dynamic checkInVal =
role['checkInTime'] ?? role['startTime'] ?? role['check_in_time'];
final dynamic checkOutVal =
role['checkOutTime'] ?? role['endTime'] ?? role['check_out_time'];
return InvoiceWorker(
name: name,
role: roleTitle,
amount: amount,
hours: hours,
rate: rate,
checkIn: _service.toDateTime(checkInVal),
checkOut: _service.toDateTime(checkOutVal),
breakMinutes: role['breakMinutes'] ?? role['break_minutes'] ?? 0,
avatarUrl:
role['avatarUrl'] ?? role['photoUrl'] ?? role['staffPhoto'],
);
}).toList();
}
// Fallback: If roles is empty, try to get workers from shift applications
else if (invoice.shift != null &&
invoice.shift.applications_on_shift != null) {
final List<dynamic> apps = invoice.shift.applications_on_shift;
workers = apps.map((dynamic app) {
final String name = app.staff?.fullName ?? 'Unknown';
final String roleTitle = app.shiftRole?.role?.name ?? 'Staff';
final double amount =
(app.shiftRole?.totalValue as num?)?.toDouble() ?? 0.0;
final double hours = (app.shiftRole?.hours as num?)?.toDouble() ?? 0.0;
// Calculate rate if not explicitly provided
double rate = 0.0;
if (hours > 0) {
rate = amount / hours;
}
// Map break type to minutes
int breakMin = 0;
final String? breakType = app.shiftRole?.breakType?.toString();
if (breakType != null) {
if (breakType.contains('10'))
breakMin = 10;
else if (breakType.contains('15'))
breakMin = 15;
else if (breakType.contains('30'))
breakMin = 30;
else if (breakType.contains('45'))
breakMin = 45;
else if (breakType.contains('60'))
breakMin = 60;
}
return InvoiceWorker(
name: name,
role: roleTitle,
amount: amount,
hours: hours,
rate: rate,
checkIn: _service.toDateTime(app.checkInTime),
checkOut: _service.toDateTime(app.checkOutTime),
breakMinutes: breakMin,
avatarUrl: app.staff?.photoUrl,
);
}).toList();
}
return Invoice(
id: invoice.id,
eventId: invoice.orderId,
businessId: invoice.businessId,
status: _mapInvoiceStatus(invoice.status.stringValue),
totalAmount: invoice.amount,
workAmount: invoice.amount,
addonsAmount: invoice.otherCharges ?? 0,
invoiceNumber: invoice.invoiceNumber,
issueDate: _service.toDateTime(invoice.issueDate)!,
title: invoice.order?.eventName,
clientName: invoice.business?.businessName,
locationAddress:
invoice.order?.teamHub?.hubName ?? invoice.order?.teamHub?.address,
staffCount:
invoice.staffCount ?? (workers.isNotEmpty ? workers.length : 0),
totalHours: _calculateTotalHours(rolesData),
workers: workers,
);
}
double _calculateTotalHours(List<dynamic> roles) {
return roles.fold<double>(0.0, (sum, role) {
final hours = role['hours'] ?? role['workHours'] ?? role['totalHours'];
if (hours is num) return sum + hours.toDouble();
return sum;
});
}
BusinessBankAccount _mapBankAccount(dynamic account) {
return BusinessBankAccountAdapter.fromPrimitives(
id: account.id,
bank: account.bank,
last4: account.last4,
isPrimary: account.isPrimary ?? false,
expiryTime: _service.toDateTime(account.expiryTime),
);
}
InvoiceStatus _mapInvoiceStatus(String status) {
switch (status) {
case 'PAID':
return InvoiceStatus.paid;
case 'OVERDUE':
return InvoiceStatus.overdue;
case 'DISPUTED':
return InvoiceStatus.disputed;
case 'APPROVED':
return InvoiceStatus.verified;
default:
return InvoiceStatus.open;
}
}
}
class _RoleSummary {
const _RoleSummary({
required this.roleId,
required this.roleName,
required this.totalHours,
required this.totalValue,
});
final String roleId;
final String roleName;
final double totalHours;
final double totalValue;
_RoleSummary copyWith({double? totalHours, double? totalValue}) {
return _RoleSummary(
roleId: roleId,
roleName: roleName,
totalHours: totalHours ?? this.totalHours,
totalValue: totalValue ?? this.totalValue,
);
}
}

View File

@@ -1,30 +0,0 @@
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for billing connector operations.
///
/// This acts as a buffer layer between the domain repository and the Data Connect SDK.
abstract interface class BillingConnectorRepository {
/// Fetches bank accounts associated with the business.
Future<List<BusinessBankAccount>> getBankAccounts({required String businessId});
/// Fetches the current bill amount for the period.
Future<double> getCurrentBillAmount({required String businessId});
/// Fetches historically paid invoices.
Future<List<Invoice>> getInvoiceHistory({required String businessId});
/// Fetches pending invoices (Open or Disputed).
Future<List<Invoice>> getPendingInvoices({required String businessId});
/// Fetches the breakdown of spending.
Future<List<InvoiceItem>> getSpendingBreakdown({
required String businessId,
required BillingPeriod period,
});
/// Approves an invoice.
Future<void> approveInvoice({required String id});
/// Disputes an invoice.
Future<void> disputeInvoice({required String id, required String reason});
}

View File

@@ -1,158 +0,0 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
import 'package:firebase_data_connect/src/core/ref.dart';
import 'package:intl/intl.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/coverage_connector_repository.dart';
/// Implementation of [CoverageConnectorRepository].
class CoverageConnectorRepositoryImpl implements CoverageConnectorRepository {
CoverageConnectorRepositoryImpl({
dc.DataConnectService? service,
}) : _service = service ?? dc.DataConnectService.instance;
final dc.DataConnectService _service;
@override
Future<List<CoverageShift>> getShiftsForDate({
required String businessId,
required DateTime date,
}) async {
return _service.run(() async {
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 QueryResult<dc.ListShiftRolesByBusinessAndDateRangeData, dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult = await _service.connector
.listShiftRolesByBusinessAndDateRange(
businessId: businessId,
start: _service.toTimestamp(start),
end: _service.toTimestamp(end),
)
.execute();
final QueryResult<dc.ListStaffsApplicationsByBusinessForDayData, dc.ListStaffsApplicationsByBusinessForDayVariables> applicationsResult = await _service.connector
.listStaffsApplicationsByBusinessForDay(
businessId: businessId,
dayStart: _service.toTimestamp(start),
dayEnd: _service.toTimestamp(end),
)
.execute();
return _mapCoverageShifts(
shiftRolesResult.data.shiftRoles,
applicationsResult.data.applications,
date,
);
});
}
List<CoverageShift> _mapCoverageShifts(
List<dynamic> shiftRoles,
List<dynamic> applications,
DateTime date,
) {
if (shiftRoles.isEmpty && applications.isEmpty) return <CoverageShift>[];
final Map<String, _CoverageGroup> groups = <String, _CoverageGroup>{};
for (final sr in shiftRoles) {
final String key = '${sr.shiftId}:${sr.roleId}';
final DateTime? startTime = _service.toDateTime(sr.startTime);
groups[key] = _CoverageGroup(
shiftId: sr.shiftId,
roleId: sr.roleId,
title: sr.role.name,
location: sr.shift.location ?? sr.shift.locationAddress ?? '',
startTime: startTime != null ? DateFormat('HH:mm').format(startTime) : '00:00',
workersNeeded: sr.count,
date: _service.toDateTime(sr.shift.date) ?? date,
workers: <CoverageWorker>[],
);
}
for (final app in applications) {
final String key = '${app.shiftId}:${app.roleId}';
if (!groups.containsKey(key)) {
final DateTime? startTime = _service.toDateTime(app.shiftRole.startTime);
groups[key] = _CoverageGroup(
shiftId: app.shiftId,
roleId: app.roleId,
title: app.shiftRole.role.name,
location: app.shiftRole.shift.location ?? app.shiftRole.shift.locationAddress ?? '',
startTime: startTime != null ? DateFormat('HH:mm').format(startTime) : '00:00',
workersNeeded: app.shiftRole.count,
date: _service.toDateTime(app.shiftRole.shift.date) ?? date,
workers: <CoverageWorker>[],
);
}
final DateTime? checkIn = _service.toDateTime(app.checkInTime);
groups[key]!.workers.add(
CoverageWorker(
name: app.staff.fullName,
status: _mapWorkerStatus(app.status.stringValue),
checkInTime: checkIn != null ? DateFormat('HH:mm').format(checkIn) : null,
),
);
}
return groups.values
.map((_CoverageGroup g) => CoverageShift(
id: '${g.shiftId}:${g.roleId}',
title: g.title,
location: g.location,
startTime: g.startTime,
workersNeeded: g.workersNeeded,
date: g.date,
workers: g.workers,
))
.toList();
}
CoverageWorkerStatus _mapWorkerStatus(String status) {
switch (status) {
case 'PENDING':
return CoverageWorkerStatus.pending;
case 'REJECTED':
return CoverageWorkerStatus.rejected;
case 'CONFIRMED':
return CoverageWorkerStatus.confirmed;
case 'CHECKED_IN':
return CoverageWorkerStatus.checkedIn;
case 'CHECKED_OUT':
return CoverageWorkerStatus.checkedOut;
case 'LATE':
return CoverageWorkerStatus.late;
case 'NO_SHOW':
return CoverageWorkerStatus.noShow;
case 'COMPLETED':
return CoverageWorkerStatus.completed;
default:
return CoverageWorkerStatus.pending;
}
}
}
class _CoverageGroup {
_CoverageGroup({
required this.shiftId,
required this.roleId,
required this.title,
required this.location,
required this.startTime,
required this.workersNeeded,
required this.date,
required this.workers,
});
final String shiftId;
final String roleId;
final String title;
final String location;
final String startTime;
final int workersNeeded;
final DateTime date;
final List<CoverageWorker> workers;
}

View File

@@ -1,12 +0,0 @@
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for coverage connector operations.
///
/// This acts as a buffer layer between the domain repository and the Data Connect SDK.
abstract interface class CoverageConnectorRepository {
/// Fetches coverage data for a specific date and business.
Future<List<CoverageShift>> getShiftsForDate({
required String businessId,
required DateTime date,
});
}

View File

@@ -1,342 +0,0 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
import 'dart:convert';
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:http/http.dart' as http;
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/hubs_connector_repository.dart';
/// Implementation of [HubsConnectorRepository].
class HubsConnectorRepositoryImpl implements HubsConnectorRepository {
HubsConnectorRepositoryImpl({
dc.DataConnectService? service,
}) : _service = service ?? dc.DataConnectService.instance;
final dc.DataConnectService _service;
@override
Future<List<Hub>> getHubs({required String businessId}) async {
return _service.run(() async {
final String teamId = await _getOrCreateTeamId(businessId);
final QueryResult<dc.GetTeamHubsByTeamIdData, dc.GetTeamHubsByTeamIdVariables> response = await _service.connector
.getTeamHubsByTeamId(teamId: teamId)
.execute();
final QueryResult<
dc.ListTeamHudDepartmentsData,
dc.ListTeamHudDepartmentsVariables
>
deptsResult = await _service.connector.listTeamHudDepartments().execute();
final Map<String, dc.ListTeamHudDepartmentsTeamHudDepartments> hubToDept =
<String, dc.ListTeamHudDepartmentsTeamHudDepartments>{};
for (final dc.ListTeamHudDepartmentsTeamHudDepartments dep
in deptsResult.data.teamHudDepartments) {
if (dep.costCenter != null &&
dep.costCenter!.isNotEmpty &&
!hubToDept.containsKey(dep.teamHubId)) {
hubToDept[dep.teamHubId] = dep;
}
}
return response.data.teamHubs.map((dc.GetTeamHubsByTeamIdTeamHubs h) {
final dc.ListTeamHudDepartmentsTeamHudDepartments? dept =
hubToDept[h.id];
return Hub(
id: h.id,
businessId: businessId,
name: h.hubName,
address: h.address,
nfcTagId: null,
status: h.isActive ? HubStatus.active : HubStatus.inactive,
costCenter: dept != null
? CostCenter(
id: dept.id,
name: dept.name,
code: dept.costCenter ?? dept.name,
)
: null,
);
}).toList();
});
}
@override
Future<Hub> createHub({
required String businessId,
required String name,
required String address,
String? placeId,
double? latitude,
double? longitude,
String? city,
String? state,
String? street,
String? country,
String? zipCode,
String? costCenterId,
}) async {
return _service.run(() async {
final String teamId = await _getOrCreateTeamId(businessId);
final _PlaceAddress? placeAddress = (placeId != null && placeId.isNotEmpty)
? await _fetchPlaceAddress(placeId)
: null;
final OperationResult<dc.CreateTeamHubData, dc.CreateTeamHubVariables> result = await _service.connector
.createTeamHub(
teamId: teamId,
hubName: name,
address: address,
)
.placeId(placeId)
.latitude(latitude)
.longitude(longitude)
.city(city ?? placeAddress?.city ?? '')
.state(state ?? placeAddress?.state)
.street(street ?? placeAddress?.street)
.country(country ?? placeAddress?.country)
.zipCode(zipCode ?? placeAddress?.zipCode)
.execute();
final String hubId = result.data.teamHub_insert.id;
CostCenter? costCenter;
if (costCenterId != null && costCenterId.isNotEmpty) {
await _service.connector
.createTeamHudDepartment(
name: costCenterId,
teamHubId: hubId,
)
.costCenter(costCenterId)
.execute();
costCenter = CostCenter(id: costCenterId, name: costCenterId, code: costCenterId);
}
return Hub(
id: hubId,
businessId: businessId,
name: name,
address: address,
nfcTagId: null,
status: HubStatus.active,
costCenter: costCenter,
);
});
}
@override
Future<Hub> updateHub({
required String businessId,
required String id,
String? name,
String? address,
String? placeId,
double? latitude,
double? longitude,
String? city,
String? state,
String? street,
String? country,
String? zipCode,
String? costCenterId,
}) async {
return _service.run(() async {
final _PlaceAddress? placeAddress = (placeId != null && placeId.isNotEmpty)
? await _fetchPlaceAddress(placeId)
: null;
final dc.UpdateTeamHubVariablesBuilder builder = _service.connector.updateTeamHub(id: id);
if (name != null) builder.hubName(name);
if (address != null) builder.address(address);
if (placeId != null) builder.placeId(placeId);
if (latitude != null) builder.latitude(latitude);
if (longitude != null) builder.longitude(longitude);
if (city != null || placeAddress?.city != null) {
builder.city(city ?? placeAddress?.city);
}
if (state != null || placeAddress?.state != null) {
builder.state(state ?? placeAddress?.state);
}
if (street != null || placeAddress?.street != null) {
builder.street(street ?? placeAddress?.street);
}
if (country != null || placeAddress?.country != null) {
builder.country(country ?? placeAddress?.country);
}
if (zipCode != null || placeAddress?.zipCode != null) {
builder.zipCode(zipCode ?? placeAddress?.zipCode);
}
await builder.execute();
CostCenter? costCenter;
final QueryResult<
dc.ListTeamHudDepartmentsByTeamHubIdData,
dc.ListTeamHudDepartmentsByTeamHubIdVariables
>
deptsResult = await _service.connector
.listTeamHudDepartmentsByTeamHubId(teamHubId: id)
.execute();
final List<dc.ListTeamHudDepartmentsByTeamHubIdTeamHudDepartments> depts =
deptsResult.data.teamHudDepartments;
if (costCenterId == null || costCenterId.isEmpty) {
if (depts.isNotEmpty) {
await _service.connector
.updateTeamHudDepartment(id: depts.first.id)
.costCenter(null)
.execute();
}
} else {
if (depts.isNotEmpty) {
await _service.connector
.updateTeamHudDepartment(id: depts.first.id)
.costCenter(costCenterId)
.execute();
costCenter = CostCenter(id: costCenterId, name: costCenterId, code: costCenterId);
} else {
await _service.connector
.createTeamHudDepartment(
name: costCenterId,
teamHubId: id,
)
.costCenter(costCenterId)
.execute();
costCenter = CostCenter(id: costCenterId, name: costCenterId, code: costCenterId);
}
}
return Hub(
id: id,
businessId: businessId,
name: name ?? '',
address: address ?? '',
nfcTagId: null,
status: HubStatus.active,
costCenter: costCenter,
);
});
}
@override
Future<void> deleteHub({required String businessId, required String id}) async {
return _service.run(() async {
final QueryResult<dc.ListOrdersByBusinessAndTeamHubData, dc.ListOrdersByBusinessAndTeamHubVariables> ordersRes = await _service.connector
.listOrdersByBusinessAndTeamHub(businessId: businessId, teamHubId: id)
.execute();
if (ordersRes.data.orders.isNotEmpty) {
throw HubHasOrdersException(
technicalMessage: 'Hub $id has ${ordersRes.data.orders.length} orders',
);
}
await _service.connector.deleteTeamHub(id: id).execute();
});
}
// --- HELPERS ---
Future<String> _getOrCreateTeamId(String businessId) async {
final QueryResult<dc.GetTeamsByOwnerIdData, dc.GetTeamsByOwnerIdVariables> teamsRes = await _service.connector
.getTeamsByOwnerId(ownerId: businessId)
.execute();
if (teamsRes.data.teams.isNotEmpty) {
return teamsRes.data.teams.first.id;
}
// Logic to fetch business details to create a team name if missing
// For simplicity, we assume one exists or we create a generic one
final OperationResult<dc.CreateTeamData, dc.CreateTeamVariables> createRes = await _service.connector
.createTeam(
teamName: 'Business Team',
ownerId: businessId,
ownerName: '',
ownerRole: 'OWNER',
)
.execute();
return createRes.data.team_insert.id;
}
Future<_PlaceAddress?> _fetchPlaceAddress(String placeId) async {
final Uri uri = Uri.https(
'maps.googleapis.com',
'/maps/api/place/details/json',
<String, dynamic>{
'place_id': placeId,
'fields': 'address_component',
'key': AppConfig.googleMapsApiKey,
},
);
try {
final http.Response response = await http.get(uri);
if (response.statusCode != 200) return null;
final Map<String, dynamic> payload = json.decode(response.body) as Map<String, dynamic>;
if (payload['status'] != 'OK') return null;
final Map<String, dynamic>? result = payload['result'] as Map<String, dynamic>?;
final List<dynamic>? components = result?['address_components'] as List<dynamic>?;
if (components == null || components.isEmpty) return null;
String? streetNumber, route, city, state, country, zipCode;
for (var entry in components) {
final Map<String, dynamic> component = entry as Map<String, dynamic>;
final List<dynamic> types = component['types'] as List<dynamic>? ?? <dynamic>[];
final String? longName = component['long_name'] as String?;
final String? shortName = component['short_name'] as String?;
if (types.contains('street_number')) {
streetNumber = longName;
} else if (types.contains('route')) {
route = longName;
} else if (types.contains('locality')) {
city = longName;
} else if (types.contains('administrative_area_level_1')) {
state = shortName ?? longName;
} else if (types.contains('country')) {
country = shortName ?? longName;
} else if (types.contains('postal_code')) {
zipCode = longName;
}
}
final String street = <String?>[streetNumber, route]
.where((String? v) => v != null && v.isNotEmpty)
.join(' ')
.trim();
return _PlaceAddress(
street: street.isEmpty ? null : street,
city: city,
state: state,
country: country,
zipCode: zipCode,
);
} catch (_) {
return null;
}
}
}
class _PlaceAddress {
const _PlaceAddress({
this.street,
this.city,
this.state,
this.country,
this.zipCode,
});
final String? street;
final String? city;
final String? state;
final String? country;
final String? zipCode;
}

View File

@@ -1,45 +0,0 @@
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for hubs connector operations.
///
/// This acts as a buffer layer between the domain repository and the Data Connect SDK.
abstract interface class HubsConnectorRepository {
/// Fetches the list of hubs for a business.
Future<List<Hub>> getHubs({required String businessId});
/// Creates a new hub.
Future<Hub> createHub({
required String businessId,
required String name,
required String address,
String? placeId,
double? latitude,
double? longitude,
String? city,
String? state,
String? street,
String? country,
String? zipCode,
String? costCenterId,
});
/// Updates an existing hub.
Future<Hub> updateHub({
required String businessId,
required String id,
String? name,
String? address,
String? placeId,
double? latitude,
double? longitude,
String? city,
String? state,
String? street,
String? country,
String? zipCode,
String? costCenterId,
});
/// Deletes a hub.
Future<void> deleteHub({required String businessId, required String id});
}

View File

@@ -1,537 +0,0 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
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';
import '../../domain/repositories/reports_connector_repository.dart';
/// Implementation of [ReportsConnectorRepository].
///
/// Fetches report-related data from the Data Connect backend.
class ReportsConnectorRepositoryImpl implements ReportsConnectorRepository {
/// Creates a new [ReportsConnectorRepositoryImpl].
ReportsConnectorRepositoryImpl({
dc.DataConnectService? service,
}) : _service = service ?? dc.DataConnectService.instance;
final dc.DataConnectService _service;
@override
Future<DailyOpsReport> getDailyOpsReport({
String? businessId,
required DateTime date,
}) async {
return _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final QueryResult<dc.ListShiftsForDailyOpsByBusinessData, dc.ListShiftsForDailyOpsByBusinessVariables> response = await _service.connector
.listShiftsForDailyOpsByBusiness(
businessId: id,
date: _service.toTimestamp(date),
)
.execute();
final List<dc.ListShiftsForDailyOpsByBusinessShifts> shifts = response.data.shifts;
final int scheduledShifts = shifts.length;
int workersConfirmed = 0;
int inProgressShifts = 0;
int completedShifts = 0;
final List<DailyOpsShift> dailyOpsShifts = <DailyOpsShift>[];
for (final dc.ListShiftsForDailyOpsByBusinessShifts shift in shifts) {
workersConfirmed += shift.filled ?? 0;
final String statusStr = shift.status?.stringValue ?? '';
if (statusStr == 'IN_PROGRESS') inProgressShifts++;
if (statusStr == 'COMPLETED') completedShifts++;
dailyOpsShifts.add(DailyOpsShift(
id: shift.id,
title: shift.title ?? '',
location: shift.location ?? '',
startTime: shift.startTime?.toDateTime() ?? DateTime.now(),
endTime: shift.endTime?.toDateTime() ?? DateTime.now(),
workersNeeded: shift.workersNeeded ?? 0,
filled: shift.filled ?? 0,
status: statusStr,
));
}
return DailyOpsReport(
scheduledShifts: scheduledShifts,
workersConfirmed: workersConfirmed,
inProgressShifts: inProgressShifts,
completedShifts: completedShifts,
shifts: dailyOpsShifts,
);
});
}
@override
Future<SpendReport> getSpendReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final QueryResult<dc.ListInvoicesForSpendByBusinessData, dc.ListInvoicesForSpendByBusinessVariables> response = await _service.connector
.listInvoicesForSpendByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final List<dc.ListInvoicesForSpendByBusinessInvoices> invoices = response.data.invoices;
double totalSpend = 0.0;
int paidInvoices = 0;
int pendingInvoices = 0;
int overdueInvoices = 0;
final List<SpendInvoice> spendInvoices = <SpendInvoice>[];
final Map<DateTime, double> dailyAggregates = <DateTime, double>{};
final Map<String, double> industryAggregates = <String, double>{};
for (final dc.ListInvoicesForSpendByBusinessInvoices inv in invoices) {
final double amount = (inv.amount ?? 0.0).toDouble();
totalSpend += amount;
final String statusStr = inv.status.stringValue;
if (statusStr == 'PAID') {
paidInvoices++;
} else if (statusStr == 'PENDING') {
pendingInvoices++;
} else if (statusStr == 'OVERDUE') {
overdueInvoices++;
}
final String industry = inv.vendor.serviceSpecialty ?? 'Other';
industryAggregates[industry] = (industryAggregates[industry] ?? 0.0) + amount;
final DateTime issueDateTime = inv.issueDate.toDateTime();
spendInvoices.add(SpendInvoice(
id: inv.id,
invoiceNumber: inv.invoiceNumber ?? '',
issueDate: issueDateTime,
amount: amount,
status: statusStr,
vendorName: inv.vendor.companyName ?? 'Unknown',
industry: industry,
));
// Chart data aggregation
final DateTime date = DateTime(issueDateTime.year, issueDateTime.month, issueDateTime.day);
dailyAggregates[date] = (dailyAggregates[date] ?? 0.0) + amount;
}
// Ensure chart data covers all days in range
final Map<DateTime, double> completeDailyAggregates = <DateTime, double>{};
for (int i = 0; i <= endDate.difference(startDate).inDays; i++) {
final DateTime date = startDate.add(Duration(days: i));
final DateTime normalizedDate = DateTime(date.year, date.month, date.day);
completeDailyAggregates[normalizedDate] =
dailyAggregates[normalizedDate] ?? 0.0;
}
final List<SpendChartPoint> chartData = completeDailyAggregates.entries
.map((MapEntry<DateTime, double> e) => SpendChartPoint(date: e.key, amount: e.value))
.toList()
..sort((SpendChartPoint a, SpendChartPoint b) => a.date.compareTo(b.date));
final List<SpendIndustryCategory> industryBreakdown = industryAggregates.entries
.map((MapEntry<String, double> e) => SpendIndustryCategory(
name: e.key,
amount: e.value,
percentage: totalSpend > 0 ? (e.value / totalSpend * 100) : 0,
))
.toList()
..sort((SpendIndustryCategory a, SpendIndustryCategory b) => b.amount.compareTo(a.amount));
final int daysCount = endDate.difference(startDate).inDays + 1;
return SpendReport(
totalSpend: totalSpend,
averageCost: daysCount > 0 ? totalSpend / daysCount : 0,
paidInvoices: paidInvoices,
pendingInvoices: pendingInvoices,
overdueInvoices: overdueInvoices,
invoices: spendInvoices,
chartData: chartData,
industryBreakdown: industryBreakdown,
);
});
}
@override
Future<CoverageReport> getCoverageReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final QueryResult<dc.ListShiftsForCoverageData, dc.ListShiftsForCoverageVariables> response = await _service.connector
.listShiftsForCoverage(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final List<dc.ListShiftsForCoverageShifts> shifts = response.data.shifts;
int totalNeeded = 0;
int totalFilled = 0;
final Map<DateTime, (int, int)> dailyStats = <DateTime, (int, int)>{};
for (final dc.ListShiftsForCoverageShifts shift in shifts) {
final DateTime shiftDate = shift.date?.toDateTime() ?? DateTime.now();
final DateTime date = DateTime(shiftDate.year, shiftDate.month, shiftDate.day);
final int needed = shift.workersNeeded ?? 0;
final int filled = shift.filled ?? 0;
totalNeeded += needed;
totalFilled += filled;
final (int, int) current = dailyStats[date] ?? (0, 0);
dailyStats[date] = (current.$1 + needed, current.$2 + filled);
}
final List<CoverageDay> dailyCoverage = dailyStats.entries.map((MapEntry<DateTime, (int, int)> e) {
final int needed = e.value.$1;
final int filled = e.value.$2;
return CoverageDay(
date: e.key,
needed: needed,
filled: filled,
percentage: needed == 0 ? 100.0 : (filled / needed) * 100.0,
);
}).toList()..sort((CoverageDay a, CoverageDay b) => a.date.compareTo(b.date));
return CoverageReport(
overallCoverage: totalNeeded == 0 ? 100.0 : (totalFilled / totalNeeded) * 100.0,
totalNeeded: totalNeeded,
totalFilled: totalFilled,
dailyCoverage: dailyCoverage,
);
});
}
@override
Future<ForecastReport> getForecastReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final QueryResult<dc.ListShiftsForForecastByBusinessData, dc.ListShiftsForForecastByBusinessVariables> response = await _service.connector
.listShiftsForForecastByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final List<dc.ListShiftsForForecastByBusinessShifts> shifts = response.data.shifts;
double projectedSpend = 0.0;
int projectedWorkers = 0;
double totalHours = 0.0;
final Map<DateTime, (double, int)> dailyStats = <DateTime, (double, int)>{};
// Weekly stats: index -> (cost, count, hours)
final Map<int, (double, int, double)> weeklyStats = <int, (double, int, double)>{
0: (0.0, 0, 0.0),
1: (0.0, 0, 0.0),
2: (0.0, 0, 0.0),
3: (0.0, 0, 0.0),
};
for (final dc.ListShiftsForForecastByBusinessShifts shift in shifts) {
final DateTime shiftDate = shift.date?.toDateTime() ?? DateTime.now();
final DateTime date = DateTime(shiftDate.year, shiftDate.month, shiftDate.day);
final double cost = (shift.cost ?? 0.0).toDouble();
final int workers = shift.workersNeeded ?? 0;
final double hoursVal = (shift.hours ?? 0).toDouble();
final double shiftTotalHours = hoursVal * workers;
projectedSpend += cost;
projectedWorkers += workers;
totalHours += shiftTotalHours;
final (double, int) current = dailyStats[date] ?? (0.0, 0);
dailyStats[date] = (current.$1 + cost, current.$2 + workers);
// Weekly logic
final int diffDays = shiftDate.difference(startDate).inDays;
if (diffDays >= 0) {
final int weekIndex = diffDays ~/ 7;
if (weekIndex < 4) {
final (double, int, double) wCurrent = weeklyStats[weekIndex]!;
weeklyStats[weekIndex] = (
wCurrent.$1 + cost,
wCurrent.$2 + 1,
wCurrent.$3 + shiftTotalHours,
);
}
}
}
final List<ForecastPoint> chartData = dailyStats.entries.map((MapEntry<DateTime, (double, int)> e) {
return ForecastPoint(
date: e.key,
projectedCost: e.value.$1,
workersNeeded: e.value.$2,
);
}).toList()..sort((ForecastPoint a, ForecastPoint b) => a.date.compareTo(b.date));
final List<ForecastWeek> weeklyBreakdown = <ForecastWeek>[];
for (int i = 0; i < 4; i++) {
final (double, int, double) stats = weeklyStats[i]!;
weeklyBreakdown.add(ForecastWeek(
weekNumber: i + 1,
totalCost: stats.$1,
shiftsCount: stats.$2,
hoursCount: stats.$3,
avgCostPerShift: stats.$2 == 0 ? 0.0 : stats.$1 / stats.$2,
));
}
final int weeksCount = (endDate.difference(startDate).inDays / 7).ceil();
final double avgWeeklySpend = weeksCount > 0 ? projectedSpend / weeksCount : 0.0;
return ForecastReport(
projectedSpend: projectedSpend,
projectedWorkers: projectedWorkers,
averageLaborCost: projectedWorkers == 0 ? 0.0 : projectedSpend / projectedWorkers,
chartData: chartData,
totalShifts: shifts.length,
totalHours: totalHours,
avgWeeklySpend: avgWeeklySpend,
weeklyBreakdown: weeklyBreakdown,
);
});
}
@override
Future<PerformanceReport> getPerformanceReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final QueryResult<dc.ListShiftsForPerformanceByBusinessData, dc.ListShiftsForPerformanceByBusinessVariables> response = await _service.connector
.listShiftsForPerformanceByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final List<dc.ListShiftsForPerformanceByBusinessShifts> shifts = response.data.shifts;
int totalNeeded = 0;
int totalFilled = 0;
int completedCount = 0;
double totalFillTimeSeconds = 0.0;
int filledShiftsWithTime = 0;
for (final dc.ListShiftsForPerformanceByBusinessShifts shift in shifts) {
totalNeeded += shift.workersNeeded ?? 0;
totalFilled += shift.filled ?? 0;
if ((shift.status?.stringValue ?? '') == 'COMPLETED') {
completedCount++;
}
if (shift.filledAt != null && shift.createdAt != null) {
final DateTime createdAt = shift.createdAt!.toDateTime();
final DateTime filledAt = shift.filledAt!.toDateTime();
totalFillTimeSeconds += filledAt.difference(createdAt).inSeconds;
filledShiftsWithTime++;
}
}
final double fillRate = totalNeeded == 0 ? 100.0 : (totalFilled / totalNeeded) * 100.0;
final double completionRate = shifts.isEmpty ? 100.0 : (completedCount / shifts.length) * 100.0;
final double avgFillTimeHours = filledShiftsWithTime == 0
? 0
: (totalFillTimeSeconds / filledShiftsWithTime) / 3600;
return PerformanceReport(
fillRate: fillRate,
completionRate: completionRate,
onTimeRate: 95.0,
avgFillTimeHours: avgFillTimeHours,
keyPerformanceIndicators: <PerformanceMetric>[
PerformanceMetric(label: 'Fill Rate', value: '${fillRate.toStringAsFixed(1)}%', trend: 0.02),
PerformanceMetric(label: 'Completion', value: '${completionRate.toStringAsFixed(1)}%', trend: 0.05),
PerformanceMetric(label: 'Avg Fill Time', value: '${avgFillTimeHours.toStringAsFixed(1)}h', trend: -0.1),
],
);
});
}
@override
Future<NoShowReport> getNoShowReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
final QueryResult<dc.ListShiftsForNoShowRangeByBusinessData, dc.ListShiftsForNoShowRangeByBusinessVariables> shiftsResponse = await _service.connector
.listShiftsForNoShowRangeByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final List<String> shiftIds = shiftsResponse.data.shifts.map((dc.ListShiftsForNoShowRangeByBusinessShifts s) => s.id).toList();
if (shiftIds.isEmpty) {
return const NoShowReport(totalNoShows: 0, noShowRate: 0, flaggedWorkers: <NoShowWorker>[]);
}
final QueryResult<dc.ListApplicationsForNoShowRangeData, dc.ListApplicationsForNoShowRangeVariables> appsResponse = await _service.connector
.listApplicationsForNoShowRange(shiftIds: shiftIds)
.execute();
final List<dc.ListApplicationsForNoShowRangeApplications> apps = appsResponse.data.applications;
final List<dc.ListApplicationsForNoShowRangeApplications> noShowApps = apps.where((dc.ListApplicationsForNoShowRangeApplications a) => (a.status.stringValue) == 'NO_SHOW').toList();
final List<String> noShowStaffIds = noShowApps.map((dc.ListApplicationsForNoShowRangeApplications a) => a.staffId).toSet().toList();
if (noShowStaffIds.isEmpty) {
return NoShowReport(
totalNoShows: noShowApps.length,
noShowRate: apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0,
flaggedWorkers: <NoShowWorker>[],
);
}
final QueryResult<dc.ListStaffForNoShowReportData, dc.ListStaffForNoShowReportVariables> staffResponse = await _service.connector
.listStaffForNoShowReport(staffIds: noShowStaffIds)
.execute();
final List<dc.ListStaffForNoShowReportStaffs> staffList = staffResponse.data.staffs;
final List<NoShowWorker> flaggedWorkers = staffList.map((dc.ListStaffForNoShowReportStaffs s) => NoShowWorker(
id: s.id,
fullName: s.fullName ?? '',
noShowCount: s.noShowCount ?? 0,
reliabilityScore: (s.reliabilityScore ?? 0.0).toDouble(),
)).toList();
return NoShowReport(
totalNoShows: noShowApps.length,
noShowRate: apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0,
flaggedWorkers: flaggedWorkers,
);
});
}
@override
Future<ReportsSummary> getReportsSummary({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) async {
return _service.run(() async {
final String id = businessId ?? await _service.getBusinessId();
// Use forecast query for hours/cost data
final QueryResult<dc.ListShiftsForForecastByBusinessData, dc.ListShiftsForForecastByBusinessVariables> shiftsResponse = await _service.connector
.listShiftsForForecastByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
// Use performance query for avgFillTime (has filledAt + createdAt)
final QueryResult<dc.ListShiftsForPerformanceByBusinessData, dc.ListShiftsForPerformanceByBusinessVariables> perfResponse = await _service.connector
.listShiftsForPerformanceByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final QueryResult<dc.ListInvoicesForSpendByBusinessData, dc.ListInvoicesForSpendByBusinessVariables> invoicesResponse = await _service.connector
.listInvoicesForSpendByBusiness(
businessId: id,
startDate: _service.toTimestamp(startDate),
endDate: _service.toTimestamp(endDate),
)
.execute();
final List<dc.ListShiftsForForecastByBusinessShifts> forecastShifts = shiftsResponse.data.shifts;
final List<dc.ListShiftsForPerformanceByBusinessShifts> perfShifts = perfResponse.data.shifts;
final List<dc.ListInvoicesForSpendByBusinessInvoices> invoices = invoicesResponse.data.invoices;
// Aggregate hours and fill rate from forecast shifts
double totalHours = 0;
int totalNeeded = 0;
for (final dc.ListShiftsForForecastByBusinessShifts shift in forecastShifts) {
totalHours += (shift.hours ?? 0).toDouble();
totalNeeded += shift.workersNeeded ?? 0;
}
// Aggregate fill rate from performance shifts (has 'filled' field)
int perfNeeded = 0;
int perfFilled = 0;
double totalFillTimeSeconds = 0;
int filledShiftsWithTime = 0;
for (final dc.ListShiftsForPerformanceByBusinessShifts shift in perfShifts) {
perfNeeded += shift.workersNeeded ?? 0;
perfFilled += shift.filled ?? 0;
if (shift.filledAt != null && shift.createdAt != null) {
final DateTime createdAt = shift.createdAt!.toDateTime();
final DateTime filledAt = shift.filledAt!.toDateTime();
totalFillTimeSeconds += filledAt.difference(createdAt).inSeconds;
filledShiftsWithTime++;
}
}
// Aggregate total spend from invoices
double totalSpend = 0;
for (final dc.ListInvoicesForSpendByBusinessInvoices inv in invoices) {
totalSpend += (inv.amount ?? 0).toDouble();
}
// Fetch no-show rate using forecast shift IDs
final List<String> shiftIds = forecastShifts.map((dc.ListShiftsForForecastByBusinessShifts s) => s.id).toList();
double noShowRate = 0;
if (shiftIds.isNotEmpty) {
final QueryResult<dc.ListApplicationsForNoShowRangeData, dc.ListApplicationsForNoShowRangeVariables> appsResponse = await _service.connector
.listApplicationsForNoShowRange(shiftIds: shiftIds)
.execute();
final List<dc.ListApplicationsForNoShowRangeApplications> apps = appsResponse.data.applications;
final List<dc.ListApplicationsForNoShowRangeApplications> noShowApps = apps.where((dc.ListApplicationsForNoShowRangeApplications a) => (a.status.stringValue) == 'NO_SHOW').toList();
noShowRate = apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0;
}
final double fillRate = perfNeeded == 0 ? 100.0 : (perfFilled / perfNeeded) * 100.0;
return ReportsSummary(
totalHours: totalHours,
otHours: totalHours * 0.05, // ~5% OT approximation until schema supports it
totalSpend: totalSpend,
fillRate: fillRate,
avgFillTimeHours: filledShiftsWithTime == 0
? 0
: (totalFillTimeSeconds / filledShiftsWithTime) / 3600,
noShowRate: noShowRate,
);
});
}
}

View File

@@ -1,55 +0,0 @@
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for reports connector queries.
///
/// This interface defines the contract for accessing report-related data
/// from the backend via Data Connect.
abstract interface class ReportsConnectorRepository {
/// Fetches the daily operations report for a specific business and date.
Future<DailyOpsReport> getDailyOpsReport({
String? businessId,
required DateTime date,
});
/// Fetches the spend report for a specific business and date range.
Future<SpendReport> getSpendReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
/// Fetches the coverage report for a specific business and date range.
Future<CoverageReport> getCoverageReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
/// Fetches the forecast report for a specific business and date range.
Future<ForecastReport> getForecastReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
/// Fetches the performance report for a specific business and date range.
Future<PerformanceReport> getPerformanceReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
/// Fetches the no-show report for a specific business and date range.
Future<NoShowReport> getNoShowReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
/// Fetches a summary of all reports for a specific business and date range.
Future<ReportsSummary> getReportsSummary({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
}

View File

@@ -1,797 +0,0 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:intl/intl.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/shifts_connector_repository.dart';
/// Implementation of [ShiftsConnectorRepository].
///
/// Handles shift-related data operations by interacting with Data Connect.
class ShiftsConnectorRepositoryImpl implements ShiftsConnectorRepository {
/// Creates a new [ShiftsConnectorRepositoryImpl].
ShiftsConnectorRepositoryImpl({dc.DataConnectService? service})
: _service = service ?? dc.DataConnectService.instance;
final dc.DataConnectService _service;
@override
Future<List<Shift>> getMyShifts({
required String staffId,
required DateTime start,
required DateTime end,
}) async {
return _service.run(() async {
final dc.GetApplicationsByStaffIdVariablesBuilder query = _service
.connector
.getApplicationsByStaffId(staffId: staffId)
.dayStart(_service.toTimestamp(start))
.dayEnd(_service.toTimestamp(end));
final QueryResult<
dc.GetApplicationsByStaffIdData,
dc.GetApplicationsByStaffIdVariables
>
response = await query.execute();
return _mapApplicationsToShifts(response.data.applications);
});
}
@override
Future<List<Shift>> getAvailableShifts({
required String staffId,
String? query,
String? type,
}) async {
return _service.run(() async {
// First, fetch all available shift roles for the vendor/business
// Use the session owner ID (vendorId)
final String? vendorId = dc.StaffSessionStore.instance.session?.ownerId;
if (vendorId == null || vendorId.isEmpty) return <Shift>[];
final QueryResult<
dc.ListShiftRolesByVendorIdData,
dc.ListShiftRolesByVendorIdVariables
>
response = await _service.connector
.listShiftRolesByVendorId(vendorId: vendorId)
.execute();
final List<dc.ListShiftRolesByVendorIdShiftRoles> allShiftRoles =
response.data.shiftRoles;
// Fetch current applications to filter out already booked shifts
final QueryResult<
dc.GetApplicationsByStaffIdData,
dc.GetApplicationsByStaffIdVariables
>
myAppsResponse = await _service.connector
.getApplicationsByStaffId(staffId: staffId)
.execute();
final Set<String> appliedShiftIds = myAppsResponse.data.applications
.map((dc.GetApplicationsByStaffIdApplications a) => a.shiftId)
.toSet();
final List<Shift> mappedShifts = <Shift>[];
for (final dc.ListShiftRolesByVendorIdShiftRoles sr in allShiftRoles) {
if (appliedShiftIds.contains(sr.shiftId)) continue;
final DateTime? shiftDate = _service.toDateTime(sr.shift.date);
final DateTime? startDt = _service.toDateTime(sr.startTime);
final DateTime? endDt = _service.toDateTime(sr.endTime);
final DateTime? createdDt = _service.toDateTime(sr.createdAt);
// Normalise orderType to uppercase for consistent checks in the UI.
// RECURRING → groups shifts into Multi-Day cards.
// PERMANENT → groups shifts into Long Term cards.
final String orderTypeStr = sr.shift.order.orderType.stringValue
.toUpperCase();
final dc.ListShiftRolesByVendorIdShiftRolesShiftOrder order =
sr.shift.order;
final DateTime? startDate = _service.toDateTime(order.startDate);
final DateTime? endDate = _service.toDateTime(order.endDate);
final String startTime = startDt != null
? DateFormat('HH:mm').format(startDt)
: '';
final String endTime = endDt != null
? DateFormat('HH:mm').format(endDt)
: '';
final List<ShiftSchedule>? schedules = _generateSchedules(
orderType: orderTypeStr,
startDate: startDate,
endDate: endDate,
recurringDays: order.recurringDays,
permanentDays: order.permanentDays,
startTime: startTime,
endTime: endTime,
);
mappedShifts.add(
Shift(
id: sr.shiftId,
roleId: sr.roleId,
title: sr.role.name,
clientName: sr.shift.order.business.businessName,
logoUrl: null,
hourlyRate: sr.role.costPerHour,
location: sr.shift.location ?? '',
locationAddress: sr.shift.locationAddress ?? '',
date: shiftDate?.toIso8601String() ?? '',
startTime: startTime,
endTime: endTime,
createdDate: createdDt?.toIso8601String() ?? '',
status: sr.shift.status?.stringValue.toLowerCase() ?? 'open',
description: sr.shift.description,
durationDays: sr.shift.durationDays ?? schedules?.length,
requiredSlots: sr.count,
filledSlots: sr.assigned ?? 0,
latitude: sr.shift.latitude,
longitude: sr.shift.longitude,
// orderId + orderType power the grouping and type-badge logic in
// FindShiftsTab._groupMultiDayShifts and MyShiftCard._getShiftType.
orderId: sr.shift.orderId,
orderType: orderTypeStr,
startDate: startDate?.toIso8601String(),
endDate: endDate?.toIso8601String(),
recurringDays: sr.shift.order.recurringDays,
permanentDays: sr.shift.order.permanentDays,
schedules: schedules,
breakInfo: BreakAdapter.fromData(
isPaid: sr.isBreakPaid ?? false,
breakTime: sr.breakType?.stringValue,
),
),
);
}
if (query != null && query.isNotEmpty) {
final String lowerQuery = query.toLowerCase();
return mappedShifts.where((Shift s) {
return s.title.toLowerCase().contains(lowerQuery) ||
s.clientName.toLowerCase().contains(lowerQuery);
}).toList();
}
return mappedShifts;
});
}
@override
Future<List<Shift>> getPendingAssignments({required String staffId}) async {
return _service.run(() async {
// Current schema doesn't have a specific "pending assignment" query that differs from confirmed
// unless we filter by status. In the old repo it was returning an empty list.
return <Shift>[];
});
}
@override
Future<Shift?> getShiftDetails({
required String shiftId,
required String staffId,
String? roleId,
}) async {
return _service.run(() async {
if (roleId != null && roleId.isNotEmpty) {
final QueryResult<dc.GetShiftRoleByIdData, dc.GetShiftRoleByIdVariables>
roleResult = await _service.connector
.getShiftRoleById(shiftId: shiftId, roleId: roleId)
.execute();
final dc.GetShiftRoleByIdShiftRole? sr = roleResult.data.shiftRole;
if (sr == null) return null;
final DateTime? startDt = _service.toDateTime(sr.startTime);
final DateTime? endDt = _service.toDateTime(sr.endTime);
final DateTime? createdDt = _service.toDateTime(sr.createdAt);
bool hasApplied = false;
String status = 'open';
final QueryResult<
dc.GetApplicationsByStaffIdData,
dc.GetApplicationsByStaffIdVariables
>
appsResponse = await _service.connector
.getApplicationsByStaffId(staffId: staffId)
.execute();
final dc.GetApplicationsByStaffIdApplications? app = appsResponse
.data
.applications
.where(
(dc.GetApplicationsByStaffIdApplications a) =>
a.shiftId == shiftId && a.shiftRole.roleId == roleId,
)
.firstOrNull;
if (app != null) {
hasApplied = true;
final String s = app.status.stringValue;
status = _mapApplicationStatus(s);
}
return Shift(
id: sr.shiftId,
roleId: sr.roleId,
title: sr.shift.order.business.businessName,
clientName: sr.shift.order.business.businessName,
logoUrl: sr.shift.order.business.companyLogoUrl,
hourlyRate: sr.role.costPerHour,
location: sr.shift.location ?? sr.shift.order.teamHub.hubName,
locationAddress: sr.shift.locationAddress ?? '',
date: startDt?.toIso8601String() ?? '',
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
createdDate: createdDt?.toIso8601String() ?? '',
status: status,
description: sr.shift.description,
durationDays: null,
requiredSlots: sr.count,
filledSlots: sr.assigned ?? 0,
hasApplied: hasApplied,
totalValue: sr.totalValue,
latitude: sr.shift.latitude,
longitude: sr.shift.longitude,
breakInfo: BreakAdapter.fromData(
isPaid: sr.isBreakPaid ?? false,
breakTime: sr.breakType?.stringValue,
),
);
}
final QueryResult<dc.GetShiftByIdData, dc.GetShiftByIdVariables> result =
await _service.connector.getShiftById(id: shiftId).execute();
final dc.GetShiftByIdShift? s = result.data.shift;
if (s == null) return null;
int? required;
int? filled;
Break? breakInfo;
try {
final QueryResult<
dc.ListShiftRolesByShiftIdData,
dc.ListShiftRolesByShiftIdVariables
>
rolesRes = await _service.connector
.listShiftRolesByShiftId(shiftId: shiftId)
.execute();
if (rolesRes.data.shiftRoles.isNotEmpty) {
required = 0;
filled = 0;
for (dc.ListShiftRolesByShiftIdShiftRoles r
in rolesRes.data.shiftRoles) {
required = (required ?? 0) + r.count;
filled = (filled ?? 0) + (r.assigned ?? 0);
}
final dc.ListShiftRolesByShiftIdShiftRoles firstRole =
rolesRes.data.shiftRoles.first;
breakInfo = BreakAdapter.fromData(
isPaid: firstRole.isBreakPaid ?? false,
breakTime: firstRole.breakType?.stringValue,
);
}
} catch (_) {}
final DateTime? startDt = _service.toDateTime(s.startTime);
final DateTime? endDt = _service.toDateTime(s.endTime);
final DateTime? createdDt = _service.toDateTime(s.createdAt);
return Shift(
id: s.id,
title: s.title,
clientName: s.order.business.businessName,
logoUrl: null,
hourlyRate: s.cost ?? 0.0,
location: s.location ?? '',
locationAddress: s.locationAddress ?? '',
date: startDt?.toIso8601String() ?? '',
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
createdDate: createdDt?.toIso8601String() ?? '',
status: s.status?.stringValue ?? 'OPEN',
description: s.description,
durationDays: s.durationDays,
requiredSlots: required,
filledSlots: filled,
latitude: s.latitude,
longitude: s.longitude,
breakInfo: breakInfo,
);
});
}
@override
Future<void> applyForShift({
required String shiftId,
required String staffId,
bool isInstantBook = false,
String? roleId,
}) async {
return _service.run(() async {
final String targetRoleId = roleId ?? '';
if (targetRoleId.isEmpty) throw Exception('Missing role id.');
// 1. Fetch the initial shift to determine order type
final QueryResult<dc.GetShiftByIdData, dc.GetShiftByIdVariables>
shiftResult = await _service.connector
.getShiftById(id: shiftId)
.execute();
final dc.GetShiftByIdShift? initialShift = shiftResult.data.shift;
if (initialShift == null) throw Exception('Shift not found');
final dc.EnumValue<dc.OrderType> orderTypeEnum =
initialShift.order.orderType;
final bool isMultiDay =
orderTypeEnum is dc.Known<dc.OrderType> &&
(orderTypeEnum.value == dc.OrderType.RECURRING ||
orderTypeEnum.value == dc.OrderType.PERMANENT);
final List<_TargetShiftRole> targets = [];
if (isMultiDay) {
// 2. Fetch all shifts for this order to apply to all of them for the same role
final QueryResult<
dc.ListShiftRolesByBusinessAndOrderData,
dc.ListShiftRolesByBusinessAndOrderVariables
>
allRolesRes = await _service.connector
.listShiftRolesByBusinessAndOrder(
businessId: initialShift.order.businessId,
orderId: initialShift.orderId,
)
.execute();
for (final role in allRolesRes.data.shiftRoles) {
if (role.roleId == targetRoleId) {
targets.add(
_TargetShiftRole(
shiftId: role.shiftId,
roleId: role.roleId,
count: role.count,
assigned: role.assigned ?? 0,
shiftFilled: role.shift.filled ?? 0,
date: _service.toDateTime(role.shift.date),
),
);
}
}
} else {
// Single shift application
final QueryResult<dc.GetShiftRoleByIdData, dc.GetShiftRoleByIdVariables>
roleResult = await _service.connector
.getShiftRoleById(shiftId: shiftId, roleId: targetRoleId)
.execute();
final dc.GetShiftRoleByIdShiftRole? role = roleResult.data.shiftRole;
if (role == null) throw Exception('Shift role not found');
targets.add(
_TargetShiftRole(
shiftId: shiftId,
roleId: targetRoleId,
count: role.count,
assigned: role.assigned ?? 0,
shiftFilled: initialShift.filled ?? 0,
date: _service.toDateTime(initialShift.date),
),
);
}
if (targets.isEmpty) {
throw Exception('No valid shifts found to apply for.');
}
int appliedCount = 0;
final List<String> errors = [];
for (final target in targets) {
try {
await _applyToSingleShiftRole(target: target, staffId: staffId);
appliedCount++;
} catch (e) {
// For multi-shift apply, we might want to continue even if some fail due to conflicts
if (targets.length == 1) rethrow;
errors.add('Shift on ${target.date}: ${e.toString()}');
}
}
if (appliedCount == 0 && targets.length > 1) {
throw Exception('Failed to apply for any shifts: ${errors.join(", ")}');
}
});
}
Future<void> _applyToSingleShiftRole({
required _TargetShiftRole target,
required String staffId,
}) async {
// Validate daily limit
if (target.date != null) {
final DateTime dayStartUtc = DateTime.utc(
target.date!.year,
target.date!.month,
target.date!.day,
);
final DateTime dayEndUtc = dayStartUtc
.add(const Duration(days: 1))
.subtract(const Duration(microseconds: 1));
final QueryResult<
dc.VaidateDayStaffApplicationData,
dc.VaidateDayStaffApplicationVariables
>
validationResponse = await _service.connector
.vaidateDayStaffApplication(staffId: staffId)
.dayStart(_service.toTimestamp(dayStartUtc))
.dayEnd(_service.toTimestamp(dayEndUtc))
.execute();
// if (validationResponse.data.applications.isNotEmpty) {
// throw Exception('The user already has a shift that day.');
// }
}
// Check for existing application
final QueryResult<
dc.GetApplicationByStaffShiftAndRoleData,
dc.GetApplicationByStaffShiftAndRoleVariables
>
existingAppRes = await _service.connector
.getApplicationByStaffShiftAndRole(
staffId: staffId,
shiftId: target.shiftId,
roleId: target.roleId,
)
.execute();
if (existingAppRes.data.applications.isNotEmpty) {
throw Exception('Application already exists.');
}
if (target.assigned >= target.count) {
throw Exception('This shift is full.');
}
String? createdAppId;
try {
final OperationResult<
dc.CreateApplicationData,
dc.CreateApplicationVariables
>
createRes = await _service.connector
.createApplication(
shiftId: target.shiftId,
staffId: staffId,
roleId: target.roleId,
status: dc.ApplicationStatus.CONFIRMED,
origin: dc.ApplicationOrigin.STAFF,
)
.execute();
createdAppId = createRes.data.application_insert.id;
await _service.connector
.updateShiftRole(shiftId: target.shiftId, roleId: target.roleId)
.assigned(target.assigned + 1)
.execute();
await _service.connector
.updateShift(id: target.shiftId)
.filled(target.shiftFilled + 1)
.execute();
} catch (e) {
// Simple rollback attempt (not guaranteed)
if (createdAppId != null) {
await _service.connector.deleteApplication(id: createdAppId).execute();
}
rethrow;
}
}
@override
Future<void> acceptShift({required String shiftId, required String staffId}) {
return _updateApplicationStatus(
shiftId,
staffId,
dc.ApplicationStatus.CONFIRMED,
);
}
@override
Future<void> declineShift({
required String shiftId,
required String staffId,
}) {
return _updateApplicationStatus(
shiftId,
staffId,
dc.ApplicationStatus.REJECTED,
);
}
@override
Future<List<Shift>> getCancelledShifts({required String staffId}) async {
return _service.run(() async {
// Logic would go here to fetch by REJECTED status if needed
return <Shift>[];
});
}
@override
Future<List<Shift>> getHistoryShifts({required String staffId}) async {
return _service.run(() async {
final QueryResult<
dc.ListCompletedApplicationsByStaffIdData,
dc.ListCompletedApplicationsByStaffIdVariables
>
response = await _service.connector
.listCompletedApplicationsByStaffId(staffId: staffId)
.execute();
final List<Shift> shifts = <Shift>[];
for (final dc.ListCompletedApplicationsByStaffIdApplications app
in response.data.applications) {
final String roleName = app.shiftRole.role.name;
final String orderName =
(app.shift.order.eventName ?? '').trim().isNotEmpty
? app.shift.order.eventName!
: app.shift.order.business.businessName;
final String title = '$roleName - $orderName';
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(
id: app.shift.id,
roleId: app.shiftRole.roleId,
title: title,
clientName: app.shift.order.business.businessName,
logoUrl: app.shift.order.business.companyLogoUrl,
hourlyRate: app.shiftRole.role.costPerHour,
location: app.shift.location ?? '',
locationAddress: app.shift.order.teamHub.hubName,
date: shiftDate?.toIso8601String() ?? '',
startTime: startDt != null
? DateFormat('HH:mm').format(startDt)
: '',
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
createdDate: createdDt?.toIso8601String() ?? '',
status: 'completed', // Hardcoded as checked out implies completion
description: app.shift.description,
durationDays: app.shift.durationDays,
requiredSlots: app.shiftRole.count,
filledSlots: app.shiftRole.assigned ?? 0,
hasApplied: true,
latitude: app.shift.latitude,
longitude: app.shift.longitude,
breakInfo: BreakAdapter.fromData(
isPaid: app.shiftRole.isBreakPaid ?? false,
breakTime: app.shiftRole.breakType?.stringValue,
),
),
);
}
return shifts;
});
}
// --- PRIVATE HELPERS ---
List<Shift> _mapApplicationsToShifts(List<dynamic> apps) {
return apps.map((app) {
final String roleName = app.shiftRole.role.name;
final String orderName =
(app.shift.order.eventName ?? '').trim().isNotEmpty
? app.shift.order.eventName!
: app.shift.order.business.businessName;
final String title = '$roleName - $orderName';
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);
final bool hasCheckIn = app.checkInTime != null;
final bool hasCheckOut = app.checkOutTime != null;
String status;
if (hasCheckOut) {
status = 'completed';
} else if (hasCheckIn) {
status = 'checked_in';
} else {
status = _mapApplicationStatus(app.status.stringValue);
}
return Shift(
id: app.shift.id,
roleId: app.shiftRole.roleId,
title: title,
clientName: app.shift.order.business.businessName,
logoUrl: app.shift.order.business.companyLogoUrl,
hourlyRate: app.shiftRole.role.costPerHour,
location: app.shift.location ?? '',
locationAddress: app.shift.order.teamHub.hubName,
date: shiftDate?.toIso8601String() ?? '',
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
createdDate: createdDt?.toIso8601String() ?? '',
status: status,
description: app.shift.description,
durationDays: app.shift.durationDays,
requiredSlots: app.shiftRole.count,
filledSlots: app.shiftRole.assigned ?? 0,
hasApplied: true,
latitude: app.shift.latitude,
longitude: app.shift.longitude,
breakInfo: BreakAdapter.fromData(
isPaid: app.shiftRole.isBreakPaid ?? false,
breakTime: app.shiftRole.breakType?.stringValue,
),
);
}).toList();
}
String _mapApplicationStatus(String status) {
switch (status) {
case 'CONFIRMED':
return 'confirmed';
case 'PENDING':
return 'pending';
case 'CHECKED_OUT':
return 'completed';
case 'REJECTED':
return 'cancelled';
default:
return 'open';
}
}
Future<void> _updateApplicationStatus(
String shiftId,
String staffId,
dc.ApplicationStatus newStatus,
) async {
return _service.run(() async {
// First try to find the application
final QueryResult<
dc.GetApplicationsByStaffIdData,
dc.GetApplicationsByStaffIdVariables
>
appsResponse = await _service.connector
.getApplicationsByStaffId(staffId: staffId)
.execute();
final dc.GetApplicationsByStaffIdApplications? app = appsResponse
.data
.applications
.where(
(dc.GetApplicationsByStaffIdApplications a) => a.shiftId == shiftId,
)
.firstOrNull;
if (app != null) {
await _service.connector
.updateApplicationStatus(id: app.id)
.status(newStatus)
.execute();
} else if (newStatus == dc.ApplicationStatus.REJECTED) {
// If declining but no app found, create a rejected application
final QueryResult<
dc.ListShiftRolesByShiftIdData,
dc.ListShiftRolesByShiftIdVariables
>
rolesRes = await _service.connector
.listShiftRolesByShiftId(shiftId: shiftId)
.execute();
if (rolesRes.data.shiftRoles.isNotEmpty) {
final dc.ListShiftRolesByShiftIdShiftRoles firstRole =
rolesRes.data.shiftRoles.first;
await _service.connector
.createApplication(
shiftId: shiftId,
staffId: staffId,
roleId: firstRole.id,
status: dc.ApplicationStatus.REJECTED,
origin: dc.ApplicationOrigin.STAFF,
)
.execute();
}
} else {
throw Exception("Application not found for shift $shiftId");
}
});
}
/// Generates a list of [ShiftSchedule] for RECURRING or PERMANENT orders.
List<ShiftSchedule>? _generateSchedules({
required String orderType,
required DateTime? startDate,
required DateTime? endDate,
required List<String>? recurringDays,
required List<String>? permanentDays,
required String startTime,
required String endTime,
}) {
if (orderType != 'RECURRING' && orderType != 'PERMANENT') return null;
if (startDate == null || endDate == null) return null;
final List<String>? daysToInclude = orderType == 'RECURRING'
? recurringDays
: permanentDays;
if (daysToInclude == null || daysToInclude.isEmpty) return null;
final List<ShiftSchedule> schedules = <ShiftSchedule>[];
final Set<int> targetWeekdayIndex = daysToInclude
.map((String day) {
switch (day.toUpperCase()) {
case 'MONDAY':
return DateTime.monday;
case 'TUESDAY':
return DateTime.tuesday;
case 'WEDNESDAY':
return DateTime.wednesday;
case 'THURSDAY':
return DateTime.thursday;
case 'FRIDAY':
return DateTime.friday;
case 'SATURDAY':
return DateTime.saturday;
case 'SUNDAY':
return DateTime.sunday;
default:
return -1;
}
})
.where((int idx) => idx != -1)
.toSet();
DateTime current = startDate;
while (current.isBefore(endDate) ||
current.isAtSameMomentAs(endDate) ||
// Handle cases where the time component might differ slightly by checking date equality
(current.year == endDate.year &&
current.month == endDate.month &&
current.day == endDate.day)) {
if (targetWeekdayIndex.contains(current.weekday)) {
schedules.add(
ShiftSchedule(
date: current.toIso8601String(),
startTime: startTime,
endTime: endTime,
),
);
}
current = current.add(const Duration(days: 1));
// Safety break to prevent infinite loops if dates are messed up
if (schedules.length > 365) break;
}
return schedules;
}
}
class _TargetShiftRole {
final String shiftId;
final String roleId;
final int count;
final int assigned;
final int shiftFilled;
final DateTime? date;
_TargetShiftRole({
required this.shiftId,
required this.roleId,
required this.count,
required this.assigned,
required this.shiftFilled,
this.date,
});
}

View File

@@ -1,56 +0,0 @@
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for shifts connector operations.
///
/// This acts as a buffer layer between the domain repository and the Data Connect SDK.
abstract interface class ShiftsConnectorRepository {
/// Retrieves shifts assigned to the current staff member.
Future<List<Shift>> getMyShifts({
required String staffId,
required DateTime start,
required DateTime end,
});
/// Retrieves available shifts.
Future<List<Shift>> getAvailableShifts({
required String staffId,
String? query,
String? type,
});
/// Retrieves pending shift assignments for the current staff member.
Future<List<Shift>> getPendingAssignments({required String staffId});
/// Retrieves detailed information for a specific shift.
Future<Shift?> getShiftDetails({
required String shiftId,
required String staffId,
String? roleId,
});
/// Applies for a specific open shift.
Future<void> applyForShift({
required String shiftId,
required String staffId,
bool isInstantBook = false,
String? roleId,
});
/// Accepts a pending shift assignment.
Future<void> acceptShift({
required String shiftId,
required String staffId,
});
/// Declines a pending shift assignment.
Future<void> declineShift({
required String shiftId,
required String staffId,
});
/// Retrieves cancelled shifts for the current staff member.
Future<List<Shift>> getCancelledShifts({required String staffId});
/// Retrieves historical (completed) shifts for the current staff member.
Future<List<Shift>> getHistoryShifts({required String staffId});
}

View File

@@ -1,876 +0,0 @@
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart' as domain;
import '../../domain/repositories/staff_connector_repository.dart';
/// Implementation of [StaffConnectorRepository].
///
/// Fetches staff-related data from the Data Connect backend using
/// the staff connector queries.
class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
/// Creates a new [StaffConnectorRepositoryImpl].
///
/// Requires a [DataConnectService] instance for backend communication.
StaffConnectorRepositoryImpl({dc.DataConnectService? service})
: _service = service ?? dc.DataConnectService.instance;
final dc.DataConnectService _service;
@override
Future<bool> getProfileCompletion() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.GetStaffProfileCompletionData,
dc.GetStaffProfileCompletionVariables
>
response = await _service.connector
.getStaffProfileCompletion(id: staffId)
.execute();
final dc.GetStaffProfileCompletionStaff? staff = response.data.staff;
final List<dc.GetStaffProfileCompletionEmergencyContacts>
emergencyContacts = response.data.emergencyContacts;
return _isProfileComplete(staff, emergencyContacts);
});
}
@override
Future<bool> getPersonalInfoCompletion() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.GetStaffPersonalInfoCompletionData,
dc.GetStaffPersonalInfoCompletionVariables
>
response = await _service.connector
.getStaffPersonalInfoCompletion(id: staffId)
.execute();
final dc.GetStaffPersonalInfoCompletionStaff? staff = response.data.staff;
return _isPersonalInfoComplete(staff);
});
}
@override
Future<bool> getEmergencyContactsCompletion() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.GetStaffEmergencyProfileCompletionData,
dc.GetStaffEmergencyProfileCompletionVariables
>
response = await _service.connector
.getStaffEmergencyProfileCompletion(id: staffId)
.execute();
return response.data.emergencyContacts.isNotEmpty;
});
}
@override
Future<bool> getExperienceCompletion() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.GetStaffExperienceProfileCompletionData,
dc.GetStaffExperienceProfileCompletionVariables
>
response = await _service.connector
.getStaffExperienceProfileCompletion(id: staffId)
.execute();
final dc.GetStaffExperienceProfileCompletionStaff? staff =
response.data.staff;
return _hasExperience(staff);
});
}
@override
Future<bool> getTaxFormsCompletion() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.GetStaffTaxFormsProfileCompletionData,
dc.GetStaffTaxFormsProfileCompletionVariables
>
response = await _service.connector
.getStaffTaxFormsProfileCompletion(id: staffId)
.execute();
final List<dc.GetStaffTaxFormsProfileCompletionTaxForms> taxForms =
response.data.taxForms;
// Return false if no tax forms exist
if (taxForms.isEmpty) return false;
// Return true only if all tax forms have status == "SUBMITTED"
return taxForms.every(
(dc.GetStaffTaxFormsProfileCompletionTaxForms form) {
if (form.status is dc.Unknown) return false;
final dc.TaxFormStatus status =
(form.status as dc.Known<dc.TaxFormStatus>).value;
return status == dc.TaxFormStatus.SUBMITTED;
},
);
});
}
@override
Future<bool?> getAttireOptionsCompletion() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final List<QueryResult<Object, Object?>> results =
await Future.wait<QueryResult<Object, Object?>>(
<Future<QueryResult<Object, Object?>>>[
_service.connector.listAttireOptions().execute(),
_service.connector.getStaffAttire(staffId: staffId).execute(),
],
);
final QueryResult<dc.ListAttireOptionsData, void> optionsRes =
results[0] as QueryResult<dc.ListAttireOptionsData, void>;
final QueryResult<dc.GetStaffAttireData, dc.GetStaffAttireVariables>
staffAttireRes =
results[1]
as QueryResult<dc.GetStaffAttireData, dc.GetStaffAttireVariables>;
final List<dc.ListAttireOptionsAttireOptions> attireOptions =
optionsRes.data.attireOptions;
final List<dc.GetStaffAttireStaffAttires> staffAttire =
staffAttireRes.data.staffAttires;
// Get only mandatory attire options
final List<dc.ListAttireOptionsAttireOptions> mandatoryOptions =
attireOptions
.where((dc.ListAttireOptionsAttireOptions opt) =>
opt.isMandatory ?? false)
.toList();
// Return null if no mandatory attire options
if (mandatoryOptions.isEmpty) return null;
// Return true only if all mandatory attire items are verified
return mandatoryOptions.every(
(dc.ListAttireOptionsAttireOptions mandatoryOpt) {
final dc.GetStaffAttireStaffAttires? currentAttire = staffAttire
.where(
(dc.GetStaffAttireStaffAttires a) =>
a.attireOptionId == mandatoryOpt.id,
)
.firstOrNull;
if (currentAttire == null) return false; // Not uploaded
if (currentAttire.verificationStatus is dc.Unknown) return false;
final dc.AttireVerificationStatus status =
(currentAttire.verificationStatus
as dc.Known<dc.AttireVerificationStatus>)
.value;
return status == dc.AttireVerificationStatus.APPROVED;
},
);
});
}
@override
Future<bool?> getStaffDocumentsCompletion() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.ListStaffDocumentsByStaffIdData,
dc.ListStaffDocumentsByStaffIdVariables
>
response = await _service.connector
.listStaffDocumentsByStaffId(staffId: staffId)
.execute();
final List<dc.ListStaffDocumentsByStaffIdStaffDocuments> staffDocs =
response.data.staffDocuments;
// Return null if no documents
if (staffDocs.isEmpty) return null;
// Return true only if all documents are verified
return staffDocs.every(
(dc.ListStaffDocumentsByStaffIdStaffDocuments doc) {
if (doc.status is dc.Unknown) return false;
final dc.DocumentStatus status =
(doc.status as dc.Known<dc.DocumentStatus>).value;
return status == dc.DocumentStatus.VERIFIED;
},
);
});
}
@override
Future<bool?> getStaffCertificatesCompletion() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.ListCertificatesByStaffIdData,
dc.ListCertificatesByStaffIdVariables
>
response = await _service.connector
.listCertificatesByStaffId(staffId: staffId)
.execute();
final List<dc.ListCertificatesByStaffIdCertificates> certificates =
response.data.certificates;
// Return false if no certificates
if (certificates.isEmpty) return null;
// Return true only if all certificates are fully validated
return certificates.every(
(dc.ListCertificatesByStaffIdCertificates cert) {
if (cert.validationStatus is dc.Unknown) return false;
final dc.ValidationStatus status =
(cert.validationStatus as dc.Known<dc.ValidationStatus>).value;
return status == dc.ValidationStatus.APPROVED;
},
);
});
}
/// Checks if personal info is complete.
bool _isPersonalInfoComplete(dc.GetStaffPersonalInfoCompletionStaff? staff) {
if (staff == null) return false;
final String fullName = staff.fullName;
final String? email = staff.email;
final String? phone = staff.phone;
return fullName.trim().isNotEmpty &&
(email?.trim().isNotEmpty ?? false) &&
(phone?.trim().isNotEmpty ?? false);
}
/// Checks if staff has experience data (skills or industries).
bool _hasExperience(dc.GetStaffExperienceProfileCompletionStaff? staff) {
if (staff == null) return false;
final List<String>? skills = staff.skills;
final List<String>? industries = staff.industries;
return (skills?.isNotEmpty ?? false) || (industries?.isNotEmpty ?? false);
}
/// Determines if the profile is complete based on all sections.
bool _isProfileComplete(
dc.GetStaffProfileCompletionStaff? staff,
List<dc.GetStaffProfileCompletionEmergencyContacts> emergencyContacts,
) {
if (staff == null) return false;
final List<String>? skills = staff.skills;
final List<String>? industries = staff.industries;
final bool hasExperience =
(skills?.isNotEmpty ?? false) || (industries?.isNotEmpty ?? false);
return (staff.fullName.trim().isNotEmpty) &&
(staff.email?.trim().isNotEmpty ?? false) &&
emergencyContacts.isNotEmpty &&
hasExperience;
}
@override
Future<domain.Staff> getStaffProfile() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<dc.GetStaffByIdData, dc.GetStaffByIdVariables>
response = await _service.connector.getStaffById(id: staffId).execute();
final dc.GetStaffByIdStaff? staff = response.data.staff;
if (staff == null) {
throw Exception('Staff not found');
}
return domain.Staff(
id: staff.id,
authProviderId: staff.userId,
name: staff.fullName,
email: staff.email ?? '',
phone: staff.phone,
avatar: staff.photoUrl,
status: domain.StaffStatus.active,
address: staff.addres,
totalShifts: staff.totalShifts,
averageRating: staff.averageRating,
onTimeRate: staff.onTimeRate,
noShowCount: staff.noShowCount,
cancellationCount: staff.cancellationCount,
reliabilityScore: staff.reliabilityScore,
);
});
}
@override
Future<List<domain.Benefit>> getBenefits() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.ListBenefitsDataByStaffIdData,
dc.ListBenefitsDataByStaffIdVariables
>
response = await _service.connector
.listBenefitsDataByStaffId(staffId: staffId)
.execute();
return response.data.benefitsDatas.map((
dc.ListBenefitsDataByStaffIdBenefitsDatas e,
) {
final double total = e.vendorBenefitPlan.total?.toDouble() ?? 0.0;
final double remaining = e.current.toDouble();
return domain.Benefit(
title: e.vendorBenefitPlan.title,
entitlementHours: total,
usedHours: (total - remaining).clamp(0.0, total),
);
}).toList();
});
}
@override
Future<List<domain.AttireItem>> getAttireOptions() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final List<QueryResult<Object, Object?>> results =
await Future.wait<QueryResult<Object, Object?>>(
<Future<QueryResult<Object, Object?>>>[
_service.connector.listAttireOptions().execute(),
_service.connector.getStaffAttire(staffId: staffId).execute(),
],
);
final QueryResult<dc.ListAttireOptionsData, void> optionsRes =
results[0] as QueryResult<dc.ListAttireOptionsData, void>;
final QueryResult<dc.GetStaffAttireData, dc.GetStaffAttireVariables>
staffAttireRes =
results[1]
as QueryResult<dc.GetStaffAttireData, dc.GetStaffAttireVariables>;
final List<dc.GetStaffAttireStaffAttires> staffAttire =
staffAttireRes.data.staffAttires;
return optionsRes.data.attireOptions.map((
dc.ListAttireOptionsAttireOptions opt,
) {
final dc.GetStaffAttireStaffAttires currentAttire = staffAttire
.firstWhere(
(dc.GetStaffAttireStaffAttires a) => a.attireOptionId == opt.id,
orElse: () => dc.GetStaffAttireStaffAttires(
attireOptionId: opt.id,
verificationPhotoUrl: null,
verificationId: null,
verificationStatus: null,
),
);
return domain.AttireItem(
id: opt.id,
code: opt.itemId,
label: opt.label,
description: opt.description,
imageUrl: opt.imageUrl,
isMandatory: opt.isMandatory ?? false,
photoUrl: currentAttire.verificationPhotoUrl,
verificationId: currentAttire.verificationId,
verificationStatus: currentAttire.verificationStatus != null
? _mapFromDCStatus(currentAttire.verificationStatus!)
: null,
);
}).toList();
});
}
@override
Future<void> upsertStaffAttire({
required String attireOptionId,
required String photoUrl,
String? verificationId,
domain.AttireVerificationStatus? verificationStatus,
}) async {
await _service.run(() async {
final String staffId = await _service.getStaffId();
await _service.connector
.upsertStaffAttire(staffId: staffId, attireOptionId: attireOptionId)
.verificationPhotoUrl(photoUrl)
.verificationId(verificationId)
.verificationStatus(
verificationStatus != null
? dc.AttireVerificationStatus.values.firstWhere(
(dc.AttireVerificationStatus e) =>
e.name == verificationStatus.value.toUpperCase(),
orElse: () => dc.AttireVerificationStatus.PENDING,
)
: null,
)
.execute();
});
}
domain.AttireVerificationStatus _mapFromDCStatus(
dc.EnumValue<dc.AttireVerificationStatus> status,
) {
if (status is dc.Unknown) {
return domain.AttireVerificationStatus.error;
}
final String name =
(status as dc.Known<dc.AttireVerificationStatus>).value.name;
switch (name) {
case 'PENDING':
return domain.AttireVerificationStatus.pending;
case 'PROCESSING':
return domain.AttireVerificationStatus.processing;
case 'AUTO_PASS':
return domain.AttireVerificationStatus.autoPass;
case 'AUTO_FAIL':
return domain.AttireVerificationStatus.autoFail;
case 'NEEDS_REVIEW':
return domain.AttireVerificationStatus.needsReview;
case 'APPROVED':
return domain.AttireVerificationStatus.approved;
case 'REJECTED':
return domain.AttireVerificationStatus.rejected;
case 'ERROR':
return domain.AttireVerificationStatus.error;
default:
return domain.AttireVerificationStatus.error;
}
}
@override
Future<void> saveStaffProfile({
String? firstName,
String? lastName,
String? bio,
String? profilePictureUrl,
}) async {
await _service.run(() async {
final String staffId = await _service.getStaffId();
final String? fullName = (firstName != null || lastName != null)
? '${firstName ?? ''} ${lastName ?? ''}'.trim()
: null;
await _service.connector
.updateStaff(id: staffId)
.fullName(fullName)
.bio(bio)
.photoUrl(profilePictureUrl)
.execute();
});
}
@override
Future<void> signOut() async {
try {
await _service.signOut();
} catch (e) {
throw Exception('Error signing out: ${e.toString()}');
}
}
@override
Future<List<domain.StaffDocument>> getStaffDocuments() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final List<QueryResult<Object, Object?>> results =
await Future.wait<QueryResult<Object, Object?>>(
<Future<QueryResult<Object, Object?>>>[
_service.connector.listDocuments().execute(),
_service.connector
.listStaffDocumentsByStaffId(staffId: staffId)
.execute(),
],
);
final QueryResult<dc.ListDocumentsData, void> documentsRes =
results[0] as QueryResult<dc.ListDocumentsData, void>;
final QueryResult<
dc.ListStaffDocumentsByStaffIdData,
dc.ListStaffDocumentsByStaffIdVariables
>
staffDocsRes =
results[1]
as QueryResult<
dc.ListStaffDocumentsByStaffIdData,
dc.ListStaffDocumentsByStaffIdVariables
>;
final List<dc.ListStaffDocumentsByStaffIdStaffDocuments> staffDocs =
staffDocsRes.data.staffDocuments;
return documentsRes.data.documents.map((dc.ListDocumentsDocuments doc) {
// Find if this staff member has already uploaded this document
final dc.ListStaffDocumentsByStaffIdStaffDocuments? currentDoc =
staffDocs
.where(
(dc.ListStaffDocumentsByStaffIdStaffDocuments d) =>
d.documentId == doc.id,
)
.firstOrNull;
return domain.StaffDocument(
id: currentDoc?.id ?? '',
staffId: staffId,
documentId: doc.id,
name: doc.name,
description: doc.description,
status: currentDoc != null
? _mapDocumentStatus(currentDoc.status)
: domain.DocumentStatus.missing,
documentUrl: currentDoc?.documentUrl,
expiryDate: currentDoc?.expiryDate == null
? null
: DateTimeUtils.toDeviceTime(
currentDoc!.expiryDate!.toDateTime(),
),
verificationId: currentDoc?.verificationId,
verificationStatus: currentDoc != null
? _mapFromDCDocumentVerificationStatus(currentDoc.status)
: null,
);
}).toList();
});
}
@override
Future<void> upsertStaffDocument({
required String documentId,
required String documentUrl,
domain.DocumentStatus? status,
String? verificationId,
}) async {
await _service.run(() async {
final String staffId = await _service.getStaffId();
final domain.Staff staff = await getStaffProfile();
await _service.connector
.upsertStaffDocument(
staffId: staffId,
staffName: staff.name,
documentId: documentId,
status: _mapToDCDocumentStatus(status),
)
.documentUrl(documentUrl)
.verificationId(verificationId)
.execute();
});
}
domain.DocumentStatus _mapDocumentStatus(
dc.EnumValue<dc.DocumentStatus> status,
) {
if (status is dc.Unknown) {
return domain.DocumentStatus.pending;
}
final dc.DocumentStatus value =
(status as dc.Known<dc.DocumentStatus>).value;
switch (value) {
case dc.DocumentStatus.VERIFIED:
return domain.DocumentStatus.verified;
case dc.DocumentStatus.PENDING:
return domain.DocumentStatus.pending;
case dc.DocumentStatus.MISSING:
return domain.DocumentStatus.missing;
case dc.DocumentStatus.UPLOADED:
case dc.DocumentStatus.EXPIRING:
return domain.DocumentStatus.pending;
case dc.DocumentStatus.PROCESSING:
case dc.DocumentStatus.AUTO_PASS:
case dc.DocumentStatus.AUTO_FAIL:
case dc.DocumentStatus.NEEDS_REVIEW:
case dc.DocumentStatus.APPROVED:
case dc.DocumentStatus.REJECTED:
case dc.DocumentStatus.ERROR:
if (value == dc.DocumentStatus.AUTO_PASS ||
value == dc.DocumentStatus.APPROVED) {
return domain.DocumentStatus.verified;
}
if (value == dc.DocumentStatus.AUTO_FAIL ||
value == dc.DocumentStatus.REJECTED ||
value == dc.DocumentStatus.ERROR) {
return domain.DocumentStatus.rejected;
}
return domain.DocumentStatus.pending;
}
}
domain.DocumentVerificationStatus _mapFromDCDocumentVerificationStatus(
dc.EnumValue<dc.DocumentStatus> status,
) {
if (status is dc.Unknown) {
return domain.DocumentVerificationStatus.error;
}
final String name = (status as dc.Known<dc.DocumentStatus>).value.name;
switch (name) {
case 'PENDING':
return domain.DocumentVerificationStatus.pending;
case 'PROCESSING':
return domain.DocumentVerificationStatus.processing;
case 'AUTO_PASS':
return domain.DocumentVerificationStatus.autoPass;
case 'AUTO_FAIL':
return domain.DocumentVerificationStatus.autoFail;
case 'NEEDS_REVIEW':
return domain.DocumentVerificationStatus.needsReview;
case 'APPROVED':
return domain.DocumentVerificationStatus.approved;
case 'REJECTED':
return domain.DocumentVerificationStatus.rejected;
case 'VERIFIED':
return domain.DocumentVerificationStatus.approved;
case 'ERROR':
return domain.DocumentVerificationStatus.error;
default:
return domain.DocumentVerificationStatus.error;
}
}
dc.DocumentStatus _mapToDCDocumentStatus(domain.DocumentStatus? status) {
if (status == null) return dc.DocumentStatus.PENDING;
switch (status) {
case domain.DocumentStatus.verified:
return dc.DocumentStatus.VERIFIED;
case domain.DocumentStatus.pending:
return dc.DocumentStatus.PENDING;
case domain.DocumentStatus.missing:
return dc.DocumentStatus.MISSING;
case domain.DocumentStatus.rejected:
return dc.DocumentStatus.REJECTED;
case domain.DocumentStatus.expired:
return dc.DocumentStatus.EXPIRING;
}
}
@override
Future<List<domain.StaffCertificate>> getStaffCertificates() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.ListCertificatesByStaffIdData,
dc.ListCertificatesByStaffIdVariables
>
response = await _service.connector
.listCertificatesByStaffId(staffId: staffId)
.execute();
return response.data.certificates.map((
dc.ListCertificatesByStaffIdCertificates cert,
) {
return domain.StaffCertificate(
id: cert.id,
staffId: cert.staffId,
name: cert.name,
description: cert.description,
expiryDate: _service.toDateTime(cert.expiry),
status: _mapToDomainCertificateStatus(cert.status),
certificateUrl: cert.fileUrl,
icon: cert.icon,
certificationType: _mapToDomainComplianceType(cert.certificationType),
issuer: cert.issuer,
certificateNumber: cert.certificateNumber,
validationStatus: _mapToDomainValidationStatus(cert.validationStatus),
createdAt: _service.toDateTime(cert.createdAt),
);
}).toList();
});
}
@override
Future<void> upsertStaffCertificate({
required domain.ComplianceType certificationType,
required String name,
required domain.StaffCertificateStatus status,
String? fileUrl,
DateTime? expiry,
String? issuer,
String? certificateNumber,
domain.StaffCertificateValidationStatus? validationStatus,
String? verificationId,
}) async {
await _service.run(() async {
final String staffId = await _service.getStaffId();
await _service.connector
.upsertStaffCertificate(
staffId: staffId,
certificationType: _mapToDCComplianceType(certificationType),
name: name,
status: _mapToDCCertificateStatus(status),
)
.fileUrl(fileUrl)
.expiry(_service.tryToTimestamp(expiry))
.issuer(issuer)
.certificateNumber(certificateNumber)
.validationStatus(_mapToDCValidationStatus(validationStatus))
// .verificationId(verificationId) // FIXME: Uncomment after running 'make dataconnect-generate-sdk'
.execute();
});
}
@override
Future<void> deleteStaffCertificate({
required domain.ComplianceType certificationType,
}) async {
await _service.run(() async {
final String staffId = await _service.getStaffId();
await _service.connector
.deleteCertificate(
staffId: staffId,
certificationType: _mapToDCComplianceType(certificationType),
)
.execute();
});
}
domain.StaffCertificateStatus _mapToDomainCertificateStatus(
dc.EnumValue<dc.CertificateStatus> status,
) {
if (status is dc.Unknown) return domain.StaffCertificateStatus.notStarted;
final dc.CertificateStatus value =
(status as dc.Known<dc.CertificateStatus>).value;
switch (value) {
case dc.CertificateStatus.CURRENT:
return domain.StaffCertificateStatus.current;
case dc.CertificateStatus.EXPIRING_SOON:
return domain.StaffCertificateStatus.expiringSoon;
case dc.CertificateStatus.COMPLETED:
return domain.StaffCertificateStatus.completed;
case dc.CertificateStatus.PENDING:
return domain.StaffCertificateStatus.pending;
case dc.CertificateStatus.EXPIRED:
return domain.StaffCertificateStatus.expired;
case dc.CertificateStatus.EXPIRING:
return domain.StaffCertificateStatus.expiring;
case dc.CertificateStatus.NOT_STARTED:
return domain.StaffCertificateStatus.notStarted;
}
}
dc.CertificateStatus _mapToDCCertificateStatus(
domain.StaffCertificateStatus status,
) {
switch (status) {
case domain.StaffCertificateStatus.current:
return dc.CertificateStatus.CURRENT;
case domain.StaffCertificateStatus.expiringSoon:
return dc.CertificateStatus.EXPIRING_SOON;
case domain.StaffCertificateStatus.completed:
return dc.CertificateStatus.COMPLETED;
case domain.StaffCertificateStatus.pending:
return dc.CertificateStatus.PENDING;
case domain.StaffCertificateStatus.expired:
return dc.CertificateStatus.EXPIRED;
case domain.StaffCertificateStatus.expiring:
return dc.CertificateStatus.EXPIRING;
case domain.StaffCertificateStatus.notStarted:
return dc.CertificateStatus.NOT_STARTED;
}
}
domain.ComplianceType _mapToDomainComplianceType(
dc.EnumValue<dc.ComplianceType> type,
) {
if (type is dc.Unknown) return domain.ComplianceType.other;
final dc.ComplianceType value = (type as dc.Known<dc.ComplianceType>).value;
switch (value) {
case dc.ComplianceType.BACKGROUND_CHECK:
return domain.ComplianceType.backgroundCheck;
case dc.ComplianceType.FOOD_HANDLER:
return domain.ComplianceType.foodHandler;
case dc.ComplianceType.RBS:
return domain.ComplianceType.rbs;
case dc.ComplianceType.LEGAL:
return domain.ComplianceType.legal;
case dc.ComplianceType.OPERATIONAL:
return domain.ComplianceType.operational;
case dc.ComplianceType.SAFETY:
return domain.ComplianceType.safety;
case dc.ComplianceType.TRAINING:
return domain.ComplianceType.training;
case dc.ComplianceType.LICENSE:
return domain.ComplianceType.license;
case dc.ComplianceType.OTHER:
return domain.ComplianceType.other;
}
}
dc.ComplianceType _mapToDCComplianceType(domain.ComplianceType type) {
switch (type) {
case domain.ComplianceType.backgroundCheck:
return dc.ComplianceType.BACKGROUND_CHECK;
case domain.ComplianceType.foodHandler:
return dc.ComplianceType.FOOD_HANDLER;
case domain.ComplianceType.rbs:
return dc.ComplianceType.RBS;
case domain.ComplianceType.legal:
return dc.ComplianceType.LEGAL;
case domain.ComplianceType.operational:
return dc.ComplianceType.OPERATIONAL;
case domain.ComplianceType.safety:
return dc.ComplianceType.SAFETY;
case domain.ComplianceType.training:
return dc.ComplianceType.TRAINING;
case domain.ComplianceType.license:
return dc.ComplianceType.LICENSE;
case domain.ComplianceType.other:
return dc.ComplianceType.OTHER;
}
}
domain.StaffCertificateValidationStatus? _mapToDomainValidationStatus(
dc.EnumValue<dc.ValidationStatus>? status,
) {
if (status == null || status is dc.Unknown) return null;
final dc.ValidationStatus value =
(status as dc.Known<dc.ValidationStatus>).value;
switch (value) {
case dc.ValidationStatus.APPROVED:
return domain.StaffCertificateValidationStatus.approved;
case dc.ValidationStatus.PENDING_EXPERT_REVIEW:
return domain.StaffCertificateValidationStatus.pendingExpertReview;
case dc.ValidationStatus.REJECTED:
return domain.StaffCertificateValidationStatus.rejected;
case dc.ValidationStatus.AI_VERIFIED:
return domain.StaffCertificateValidationStatus.aiVerified;
case dc.ValidationStatus.AI_FLAGGED:
return domain.StaffCertificateValidationStatus.aiFlagged;
case dc.ValidationStatus.MANUAL_REVIEW_NEEDED:
return domain.StaffCertificateValidationStatus.manualReviewNeeded;
}
}
dc.ValidationStatus? _mapToDCValidationStatus(
domain.StaffCertificateValidationStatus? status,
) {
if (status == null) return null;
switch (status) {
case domain.StaffCertificateValidationStatus.approved:
return dc.ValidationStatus.APPROVED;
case domain.StaffCertificateValidationStatus.pendingExpertReview:
return dc.ValidationStatus.PENDING_EXPERT_REVIEW;
case domain.StaffCertificateValidationStatus.rejected:
return dc.ValidationStatus.REJECTED;
case domain.StaffCertificateValidationStatus.aiVerified:
return dc.ValidationStatus.AI_VERIFIED;
case domain.StaffCertificateValidationStatus.aiFlagged:
return dc.ValidationStatus.AI_FLAGGED;
case domain.StaffCertificateValidationStatus.manualReviewNeeded:
return dc.ValidationStatus.MANUAL_REVIEW_NEEDED;
}
}
}

View File

@@ -1,122 +0,0 @@
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for staff connector queries.
///
/// This interface defines the contract for accessing staff-related data
/// from the backend via Data Connect.
abstract interface class StaffConnectorRepository {
/// Fetches whether the profile is complete for the current staff member.
///
/// Returns true if all required profile sections have been completed,
/// false otherwise.
///
/// Throws an exception if the query fails.
Future<bool> getProfileCompletion();
/// Fetches personal information completion status.
///
/// Returns true if personal info (name, email, phone, locations) is complete.
Future<bool> getPersonalInfoCompletion();
/// Fetches emergency contacts completion status.
///
/// Returns true if at least one emergency contact exists.
Future<bool> getEmergencyContactsCompletion();
/// Fetches experience completion status.
///
/// Returns true if staff has industries or skills defined.
Future<bool> getExperienceCompletion();
/// Fetches tax forms completion status.
///
/// Returns true if at least one tax form exists.
Future<bool> getTaxFormsCompletion();
/// Fetches attire options completion status.
///
/// Returns true if all mandatory attire options are verified.
Future<bool?> getAttireOptionsCompletion();
/// Fetches documents completion status.
///
/// Returns true if all mandatory documents are verified.
Future<bool?> getStaffDocumentsCompletion();
/// Fetches certificates completion status.
///
/// Returns true if all certificates are validated.
Future<bool?> getStaffCertificatesCompletion();
/// Fetches the full staff profile for the current authenticated user.
///
/// Returns a [Staff] entity containing all profile information.
///
/// Throws an exception if the profile cannot be retrieved.
Future<Staff> getStaffProfile();
/// Fetches the benefits for the current authenticated user.
///
/// Returns a list of [Benefit] entities.
Future<List<Benefit>> getBenefits();
/// Fetches the attire options for the current authenticated user.
///
/// Returns a list of [AttireItem] entities.
Future<List<AttireItem>> getAttireOptions();
/// Upserts staff attire photo information.
Future<void> upsertStaffAttire({
required String attireOptionId,
required String photoUrl,
String? verificationId,
AttireVerificationStatus? verificationStatus,
});
/// Signs out the current user.
///
/// Clears the user's session and authentication state.
///
/// Throws an exception if the sign-out fails.
Future<void> signOut();
/// Saves the staff profile information.
Future<void> saveStaffProfile({
String? firstName,
String? lastName,
String? bio,
String? profilePictureUrl,
});
/// Fetches the staff documents for the current authenticated user.
Future<List<StaffDocument>> getStaffDocuments();
/// Upserts staff document information.
Future<void> upsertStaffDocument({
required String documentId,
required String documentUrl,
DocumentStatus? status,
String? verificationId,
});
/// Fetches the staff certificates for the current authenticated user.
Future<List<StaffCertificate>> getStaffCertificates();
/// Upserts staff certificate information.
Future<void> upsertStaffCertificate({
required ComplianceType certificationType,
required String name,
required StaffCertificateStatus status,
String? fileUrl,
DateTime? expiry,
String? issuer,
String? certificateNumber,
StaffCertificateValidationStatus? validationStatus,
String? verificationId,
});
/// Deletes a staff certificate.
Future<void> deleteStaffCertificate({
required ComplianceType certificationType,
});
}

View File

@@ -1,27 +0,0 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving attire options completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member has fully uploaded and verified all mandatory attire options.
/// It delegates to the repository for data access.
class GetAttireOptionsCompletionUseCase extends NoInputUseCase<bool?> {
/// Creates a [GetAttireOptionsCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetAttireOptionsCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get attire options completion status.
///
/// Returns true if all mandatory attire options are verified, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool?> call() => _repository.getAttireOptionsCompletion();
}

View File

@@ -1,27 +0,0 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving emergency contacts completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member has at least one emergency contact registered.
/// It delegates to the repository for data access.
class GetEmergencyContactsCompletionUseCase extends NoInputUseCase<bool> {
/// Creates a [GetEmergencyContactsCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetEmergencyContactsCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get emergency contacts completion status.
///
/// Returns true if emergency contacts are registered, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool> call() => _repository.getEmergencyContactsCompletion();
}

View File

@@ -1,27 +0,0 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving experience completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member has experience data (skills or industries) defined.
/// It delegates to the repository for data access.
class GetExperienceCompletionUseCase extends NoInputUseCase<bool> {
/// Creates a [GetExperienceCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetExperienceCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get experience completion status.
///
/// Returns true if experience data is defined, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool> call() => _repository.getExperienceCompletion();
}

View File

@@ -1,27 +0,0 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving personal information completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member's personal information is complete (name, email, phone).
/// It delegates to the repository for data access.
class GetPersonalInfoCompletionUseCase extends NoInputUseCase<bool> {
/// Creates a [GetPersonalInfoCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetPersonalInfoCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get personal info completion status.
///
/// Returns true if personal information is complete, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool> call() => _repository.getPersonalInfoCompletion();
}

View File

@@ -1,27 +0,0 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving staff profile completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member's profile is complete. It delegates to the repository
/// for data access.
class GetProfileCompletionUseCase extends NoInputUseCase<bool> {
/// Creates a [GetProfileCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetProfileCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get profile completion status.
///
/// Returns true if the profile is complete, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool> call() => _repository.getProfileCompletion();
}

View File

@@ -1,27 +0,0 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving certificates completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member has fully validated all certificates.
/// It delegates to the repository for data access.
class GetStaffCertificatesCompletionUseCase extends NoInputUseCase<bool?> {
/// Creates a [GetStaffCertificatesCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetStaffCertificatesCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get certificates completion status.
///
/// Returns true if all certificates are validated, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool?> call() => _repository.getStaffCertificatesCompletion();
}

View File

@@ -1,27 +0,0 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving documents completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member has fully uploaded and verified all mandatory documents.
/// It delegates to the repository for data access.
class GetStaffDocumentsCompletionUseCase extends NoInputUseCase<bool?> {
/// Creates a [GetStaffDocumentsCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetStaffDocumentsCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get documents completion status.
///
/// Returns true if all mandatory documents are verified, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool?> call() => _repository.getStaffDocumentsCompletion();
}

View File

@@ -1,28 +0,0 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for fetching a staff member's full profile information.
///
/// This use case encapsulates the business logic for retrieving the complete
/// staff profile including personal info, ratings, and reliability scores.
/// It delegates to the repository for data access.
class GetStaffProfileUseCase extends UseCase<void, Staff> {
/// Creates a [GetStaffProfileUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetStaffProfileUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get the staff profile.
///
/// Returns a [Staff] entity containing all profile information.
///
/// Throws an exception if the operation fails.
@override
Future<Staff> call([void params]) => _repository.getStaffProfile();
}

View File

@@ -1,27 +0,0 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for retrieving tax forms completion status.
///
/// This use case encapsulates the business logic for determining whether
/// a staff member has at least one tax form submitted.
/// It delegates to the repository for data access.
class GetTaxFormsCompletionUseCase extends NoInputUseCase<bool> {
/// Creates a [GetTaxFormsCompletionUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
GetTaxFormsCompletionUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to get tax forms completion status.
///
/// Returns true if tax forms are submitted, false otherwise.
///
/// Throws an exception if the operation fails.
@override
Future<bool> call() => _repository.getTaxFormsCompletion();
}

View File

@@ -1,25 +0,0 @@
import 'package:krow_core/core.dart';
import '../repositories/staff_connector_repository.dart';
/// Use case for signing out the current staff user.
///
/// This use case encapsulates the business logic for signing out,
/// including clearing authentication state and cache.
/// It delegates to the repository for data access.
class SignOutStaffUseCase extends NoInputUseCase<void> {
/// Creates a [SignOutStaffUseCase].
///
/// Requires a [StaffConnectorRepository] for data access.
SignOutStaffUseCase({
required StaffConnectorRepository repository,
}) : _repository = repository;
final StaffConnectorRepository _repository;
/// Executes the use case to sign out the user.
///
/// Throws an exception if the operation fails.
@override
Future<void> call() => _repository.signOut();
}

View File

@@ -1,37 +0,0 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'connectors/reports/domain/repositories/reports_connector_repository.dart';
import 'connectors/reports/data/repositories/reports_connector_repository_impl.dart';
import 'connectors/shifts/domain/repositories/shifts_connector_repository.dart';
import 'connectors/shifts/data/repositories/shifts_connector_repository_impl.dart';
import 'connectors/hubs/domain/repositories/hubs_connector_repository.dart';
import 'connectors/hubs/data/repositories/hubs_connector_repository_impl.dart';
import 'connectors/billing/domain/repositories/billing_connector_repository.dart';
import 'connectors/billing/data/repositories/billing_connector_repository_impl.dart';
import 'connectors/coverage/domain/repositories/coverage_connector_repository.dart';
import 'connectors/coverage/data/repositories/coverage_connector_repository_impl.dart';
import 'services/data_connect_service.dart';
/// A module that provides Data Connect dependencies.
class DataConnectModule extends Module {
@override
void exportedBinds(Injector i) {
i.addInstance<DataConnectService>(DataConnectService.instance);
// Repositories
i.addLazySingleton<ReportsConnectorRepository>(
ReportsConnectorRepositoryImpl.new,
);
i.addLazySingleton<ShiftsConnectorRepository>(
ShiftsConnectorRepositoryImpl.new,
);
i.addLazySingleton<HubsConnectorRepository>(
HubsConnectorRepositoryImpl.new,
);
i.addLazySingleton<BillingConnectorRepository>(
BillingConnectorRepositoryImpl.new,
);
i.addLazySingleton<CoverageConnectorRepository>(
CoverageConnectorRepositoryImpl.new,
);
}
}

View File

@@ -1,250 +0,0 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:flutter/foundation.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart' as domain;
import '../connectors/reports/domain/repositories/reports_connector_repository.dart';
import '../connectors/reports/data/repositories/reports_connector_repository_impl.dart';
import '../connectors/shifts/domain/repositories/shifts_connector_repository.dart';
import '../connectors/shifts/data/repositories/shifts_connector_repository_impl.dart';
import '../connectors/hubs/domain/repositories/hubs_connector_repository.dart';
import '../connectors/hubs/data/repositories/hubs_connector_repository_impl.dart';
import '../connectors/billing/domain/repositories/billing_connector_repository.dart';
import '../connectors/billing/data/repositories/billing_connector_repository_impl.dart';
import '../connectors/coverage/domain/repositories/coverage_connector_repository.dart';
import '../connectors/coverage/data/repositories/coverage_connector_repository_impl.dart';
import '../connectors/staff/domain/repositories/staff_connector_repository.dart';
import '../connectors/staff/data/repositories/staff_connector_repository_impl.dart';
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, SessionHandlerMixin {
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;
// Repositories
ReportsConnectorRepository? _reportsRepository;
ShiftsConnectorRepository? _shiftsRepository;
HubsConnectorRepository? _hubsRepository;
BillingConnectorRepository? _billingRepository;
CoverageConnectorRepository? _coverageRepository;
StaffConnectorRepository? _staffRepository;
/// Gets the reports connector repository.
ReportsConnectorRepository getReportsRepository() {
return _reportsRepository ??= ReportsConnectorRepositoryImpl(service: this);
}
/// Gets the shifts connector repository.
ShiftsConnectorRepository getShiftsRepository() {
return _shiftsRepository ??= ShiftsConnectorRepositoryImpl(service: this);
}
/// Gets the hubs connector repository.
HubsConnectorRepository getHubsRepository() {
return _hubsRepository ??= HubsConnectorRepositoryImpl(service: this);
}
/// Gets the billing connector repository.
BillingConnectorRepository getBillingRepository() {
return _billingRepository ??= BillingConnectorRepositoryImpl(service: this);
}
/// Gets the coverage connector repository.
CoverageConnectorRepository getCoverageRepository() {
return _coverageRepository ??= CoverageConnectorRepositoryImpl(
service: this,
);
}
/// Gets the staff connector repository.
StaffConnectorRepository getStaffRepository() {
return _staffRepository ??= StaffConnectorRepositoryImpl(service: this);
}
/// Returns the current Firebase Auth instance.
@override
firebase.FirebaseAuth get auth => firebase.FirebaseAuth.instance;
/// Helper to get the current staff ID from the session.
Future<String> getStaffId() async {
String? staffId = dc.StaffSessionStore.instance.session?.staff?.id;
if (staffId == null || staffId.isEmpty) {
// Attempt to recover session if user is signed in
final user = auth.currentUser;
if (user != null) {
await _loadSession(user.uid);
staffId = dc.StaffSessionStore.instance.session?.staff?.id;
}
}
if (staffId == null || staffId.isEmpty) {
throw Exception('No staff ID found in session.');
}
return staffId;
}
/// Helper to get the current business ID from the session.
Future<String> getBusinessId() async {
String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
// Attempt to recover session if user is signed in
final user = auth.currentUser;
if (user != null) {
await _loadSession(user.uid);
businessId = dc.ClientSessionStore.instance.session?.business?.id;
}
}
if (businessId == null || businessId.isEmpty) {
throw Exception('No business ID found in session.');
}
return businessId;
}
/// Logic to load session data from backend and populate stores.
Future<void> _loadSession(String userId) async {
try {
final role = await fetchUserRole(userId);
if (role == null) return;
// Load Staff Session if applicable
if (role == 'STAFF' || role == 'BOTH') {
final response = await connector
.getStaffByUserId(userId: userId)
.execute();
if (response.data.staffs.isNotEmpty) {
final s = response.data.staffs.first;
dc.StaffSessionStore.instance.setSession(
dc.StaffSession(
ownerId: s.ownerId,
staff: domain.Staff(
id: s.id,
authProviderId: s.userId,
name: s.fullName,
email: s.email ?? '',
phone: s.phone,
status: domain.StaffStatus.completedProfile,
address: s.addres,
avatar: s.photoUrl,
),
),
);
}
}
// Load Client Session if applicable
if (role == 'BUSINESS' || role == 'BOTH') {
final response = await connector
.getBusinessesByUserId(userId: userId)
.execute();
if (response.data.businesses.isNotEmpty) {
final b = response.data.businesses.first;
dc.ClientSessionStore.instance.setSession(
dc.ClientSession(
business: dc.ClientBusinessSession(
id: b.id,
businessName: b.businessName,
email: b.email,
city: b.city,
contactName: b.contactName,
companyLogoUrl: b.companyLogoUrl,
),
),
);
}
}
} catch (e) {
debugPrint('DataConnectService: Error loading session for $userId: $e');
}
}
/// Converts a Data Connect [Timestamp] to a Dart [DateTime] in local time.
///
/// Firebase Data Connect always stores and returns timestamps in UTC.
/// Calling [toLocal] ensures the result reflects the device's timezone so
/// that shift dates, start/end times, and formatted strings are correct for
/// the end user.
DateTime? toDateTime(dynamic timestamp) {
if (timestamp == null) return null;
if (timestamp is fdc.Timestamp) {
return timestamp.toDateTime().toLocal();
}
return null;
}
/// Converts a Dart [DateTime] to a Data Connect [Timestamp].
///
/// Converts the [DateTime] to UTC before creating the [Timestamp].
fdc.Timestamp toTimestamp(DateTime dateTime) {
final DateTime utc = dateTime.toUtc();
final int millis = utc.millisecondsSinceEpoch;
final int seconds = millis ~/ 1000;
final int nanos = (millis % 1000) * 1000000;
return fdc.Timestamp(nanos, seconds);
}
/// Converts a nullable Dart [DateTime] to a nullable Data Connect [Timestamp].
fdc.Timestamp? tryToTimestamp(DateTime? dateTime) {
if (dateTime == null) return null;
return toTimestamp(dateTime);
}
/// Executes an operation with centralized error handling.
Future<T> run<T>(
Future<T> Function() operation, {
bool requiresAuthentication = true,
}) async {
if (requiresAuthentication) {
await ensureSessionValid();
}
return executeProtected(operation);
}
/// Implementation for SessionHandlerMixin.
@override
Future<String?> fetchUserRole(String userId) async {
try {
final response = await connector.getUserById(id: userId).execute();
return response.data.user?.userRole;
} catch (e) {
return null;
}
}
/// Signs out the current user from Firebase Auth and clears all session data.
Future<void> signOut() async {
try {
await auth.signOut();
_clearCache();
} catch (e) {
debugPrint('DataConnectService: Error signing out: $e');
rethrow;
}
}
/// Clears Cached Repositories and Session data.
void _clearCache() {
_reportsRepository = null;
_shiftsRepository = null;
_hubsRepository = null;
_billingRepository = null;
_coverageRepository = null;
_staffRepository = null;
dc.StaffSessionStore.instance.clear();
dc.ClientSessionStore.instance.clear();
}
}

View File

@@ -1,86 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
/// Mixin to handle Data Layer errors and map them to Domain Failures.
///
/// Use this in Repositories to wrap remote calls.
/// It catches [SocketException], [FirebaseException], etc., and throws [AppException].
mixin DataErrorHandler {
/// Executes a Future and maps low-level exceptions to [AppException].
///
/// [timeout] defaults to 30 seconds.
Future<T> executeProtected<T>(
Future<T> Function() action, {
Duration timeout = const Duration(seconds: 30),
}) async {
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',
);
} on SocketException catch (e) {
throw NetworkException(technicalMessage: 'SocketException: ${e.message}');
} on FirebaseException catch (e) {
final String code = e.code.toLowerCase();
final String msg = (e.message ?? '').toLowerCase();
if (code == 'unavailable' ||
code == 'network-request-failed' ||
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}',
);
}
if (code == 'deadline-exceeded') {
debugPrint(
'DataErrorHandler: Firebase timeout error: ${e.code} - ${e.message}',
);
throw ServiceUnavailableException(
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}',
);
} catch (e) {
final String errorStr = e.toString().toLowerCase();
if (errorStr.contains('socketexception') ||
errorStr.contains('network') ||
errorStr.contains('offline') ||
errorStr.contains('connection failed') ||
errorStr.contains('unavailable') ||
errorStr.contains('handshake') ||
errorStr.contains('clientexception') ||
errorStr.contains('failed host lookup') ||
errorStr.contains('connection error') ||
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());
}
}
}

View File

@@ -1,265 +0,0 @@
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<SessionState> _sessionStateController =
StreamController<SessionState>.broadcast();
/// Last emitted session state (for late subscribers).
SessionState? _lastSessionState;
/// Public stream for listening to session state changes.
/// Late subscribers will immediately receive the last emitted state.
Stream<SessionState> 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<SessionState> _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;
/// Subscription to auth state changes.
StreamSubscription<firebase_auth.User?>? _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;
/// List of allowed roles for this app (to be set during initialization).
List<String> _allowedRoles = <String>[];
/// Initialize the auth state listener (call once on app startup).
void initializeAuthListener({List<String> allowedRoles = const <String>[]}) {
_allowedRoles = allowedRoles;
// 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()));
},
);
}
/// Validates if user has one of the allowed roles.
/// Returns true if user role is in allowed roles, false otherwise.
Future<bool> validateUserRole(
String userId,
List<String> 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<String?> 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<void> 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<void>.delayed(backoffDuration);
}
}
}
/// Handle user sign-in event.
Future<void> _handleSignIn(firebase_auth.User user) async {
try {
_emitSessionState(SessionState.loading());
// Validate role only when allowed roles are specified.
if (_allowedRoles.isNotEmpty) {
final String? userRole = await fetchUserRole(user.uid);
if (userRole == null) {
// User has no record in the database yet. This is expected during
// the sign-up flow: Firebase Auth fires authStateChanges before the
// repository has created the PostgreSQL user record. Do NOT sign out —
// just emit unauthenticated and let the registration flow complete.
_emitSessionState(SessionState.unauthenticated());
return;
}
if (!_allowedRoles.contains(userRole)) {
// User IS in the database but has a role that is not permitted in
// this app (e.g., a STAFF-only user trying to use the Client app).
// Sign them out to force them to use the correct app.
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 &&
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) {
_lastSessionState = state;
if (!_sessionStateController.isClosed) {
_sessionStateController.add(state);
}
}
/// Dispose session handler resources.
Future<void> disposeSessionHandler() async {
await _authStateSubscription?.cancel();
await _sessionStateController.close();
}
}

View File

@@ -1,41 +0,0 @@
class ClientBusinessSession {
const ClientBusinessSession({
required this.id,
required this.businessName,
this.email,
this.city,
this.contactName,
this.companyLogoUrl,
});
final String id;
final String businessName;
final String? email;
final String? city;
final String? contactName;
final String? companyLogoUrl;
}
class ClientSession {
const ClientSession({required this.business});
final ClientBusinessSession? business;
}
class ClientSessionStore {
ClientSessionStore._();
ClientSession? _session;
ClientSession? get session => _session;
void setSession(ClientSession session) {
_session = session;
}
void clear() {
_session = null;
}
static final ClientSessionStore instance = ClientSessionStore._();
}

View File

@@ -1,25 +0,0 @@
import 'package:krow_domain/krow_domain.dart' as domain;
class StaffSession {
const StaffSession({this.staff, this.ownerId});
final domain.Staff? staff;
final String? ownerId;
}
class StaffSessionStore {
StaffSessionStore._();
StaffSession? _session;
StaffSession? get session => _session;
void setSession(StaffSession session) {
_session = session;
}
void clear() {
_session = null;
}
static final StaffSessionStore instance = StaffSessionStore._();
}

View File

@@ -1,21 +0,0 @@
name: krow_data_connect
description: Firebase Data Connect access layer.
version: 0.0.1
publish_to: none
resolution: workspace
environment:
sdk: '>=3.10.0 <4.0.0'
flutter: ">=3.0.0"
dependencies:
flutter:
sdk: flutter
krow_domain:
path: ../domain
krow_core:
path: ../core
flutter_modular: ^6.3.0
firebase_data_connect: ^0.2.2+2
firebase_core: ^4.4.0
firebase_auth: ^6.1.4

View File

@@ -13,10 +13,16 @@ class TimeSlot extends Equatable {
});
/// Deserialises from a JSON map inside the availability slots array.
///
/// Supports both V2 API keys (`start`/`end`) and legacy keys
/// (`startTime`/`endTime`).
factory TimeSlot.fromJson(Map<String, dynamic> json) {
return TimeSlot(
startTime: json['startTime'] as String? ?? '00:00',
endTime: json['endTime'] as String? ?? '00:00',
startTime: json['start'] as String? ??
json['startTime'] as String? ??
'00:00',
endTime:
json['end'] as String? ?? json['endTime'] as String? ?? '00:00',
);
}
@@ -26,11 +32,11 @@ class TimeSlot extends Equatable {
/// End time in `HH:MM` format.
final String endTime;
/// Serialises to JSON.
/// Serialises to JSON matching the V2 API contract.
Map<String, dynamic> toJson() {
return <String, dynamic>{
'startTime': startTime,
'endTime': endTime,
'start': startTime,
'end': endTime,
};
}

View File

@@ -1,6 +1,9 @@
import 'package:equatable/equatable.dart';
import '../benefits/benefit.dart';
import '../shifts/assigned_shift.dart';
import '../shifts/open_shift.dart';
import '../shifts/today_shift.dart';
/// Staff dashboard data with shifts and benefits overview.
///
@@ -9,9 +12,9 @@ class StaffDashboard extends Equatable {
/// Creates a [StaffDashboard] instance.
const StaffDashboard({
required this.staffName,
this.todaysShifts = const <Map<String, dynamic>>[],
this.tomorrowsShifts = const <Map<String, dynamic>>[],
this.recommendedShifts = const <Map<String, dynamic>>[],
this.todaysShifts = const <TodayShift>[],
this.tomorrowsShifts = const <AssignedShift>[],
this.recommendedShifts = const <OpenShift>[],
this.benefits = const <Benefit>[],
});
@@ -25,10 +28,19 @@ class StaffDashboard extends Equatable {
: const <Benefit>[];
return StaffDashboard(
staffName: json['staffName'] as String,
todaysShifts: _castShiftList(json['todaysShifts']),
tomorrowsShifts: _castShiftList(json['tomorrowsShifts']),
recommendedShifts: _castShiftList(json['recommendedShifts']),
staffName: json['staffName'] as String? ?? '',
todaysShifts: _parseList<TodayShift>(
json['todaysShifts'],
TodayShift.fromJson,
),
tomorrowsShifts: _parseList<AssignedShift>(
json['tomorrowsShifts'],
AssignedShift.fromJson,
),
recommendedShifts: _parseList<OpenShift>(
json['recommendedShifts'],
OpenShift.fromJson,
),
benefits: benefitsList,
);
}
@@ -37,13 +49,13 @@ class StaffDashboard extends Equatable {
final String staffName;
/// Shifts assigned for today.
final List<Map<String, dynamic>> todaysShifts;
final List<TodayShift> todaysShifts;
/// Shifts assigned for tomorrow.
final List<Map<String, dynamic>> tomorrowsShifts;
final List<AssignedShift> tomorrowsShifts;
/// Recommended open shifts.
final List<Map<String, dynamic>> recommendedShifts;
final List<OpenShift> recommendedShifts;
/// Active benefits.
final List<Benefit> benefits;
@@ -52,21 +64,27 @@ class StaffDashboard extends Equatable {
Map<String, dynamic> toJson() {
return <String, dynamic>{
'staffName': staffName,
'todaysShifts': todaysShifts,
'tomorrowsShifts': tomorrowsShifts,
'recommendedShifts': recommendedShifts,
'todaysShifts':
todaysShifts.map((TodayShift s) => s.toJson()).toList(),
'tomorrowsShifts':
tomorrowsShifts.map((AssignedShift s) => s.toJson()).toList(),
'recommendedShifts':
recommendedShifts.map((OpenShift s) => s.toJson()).toList(),
'benefits': benefits.map((Benefit b) => b.toJson()).toList(),
};
}
static List<Map<String, dynamic>> _castShiftList(dynamic raw) {
/// Safely parses a JSON list into a typed [List].
static List<T> _parseList<T>(
dynamic raw,
T Function(Map<String, dynamic>) fromJson,
) {
if (raw is List) {
return raw
.map((dynamic e) =>
Map<String, dynamic>.from(e as Map<dynamic, dynamic>))
.map((dynamic e) => fromJson(e as Map<String, dynamic>))
.toList();
}
return const <Map<String, dynamic>>[];
return <T>[];
}
@override

View File

@@ -17,6 +17,7 @@ class StaffPersonalInfo extends Equatable {
this.skills = const <String>[],
this.email,
this.phone,
this.photoUrl,
});
/// Deserialises a [StaffPersonalInfo] from the V2 API JSON response.
@@ -32,6 +33,7 @@ class StaffPersonalInfo extends Equatable {
skills: _parseStringList(json['skills']),
email: json['email'] as String?,
phone: json['phone'] as String?,
photoUrl: json['photoUrl'] as String?,
);
}
@@ -65,6 +67,9 @@ class StaffPersonalInfo extends Equatable {
/// Contact phone number.
final String? phone;
/// URL of the staff member's profile photo.
final String? photoUrl;
/// Serialises this [StaffPersonalInfo] to a JSON map.
Map<String, dynamic> toJson() {
return <String, dynamic>{
@@ -78,6 +83,7 @@ class StaffPersonalInfo extends Equatable {
'skills': skills,
'email': email,
'phone': phone,
'photoUrl': photoUrl,
};
}
@@ -93,6 +99,7 @@ class StaffPersonalInfo extends Equatable {
skills,
email,
phone,
photoUrl,
];
/// Parses a dynamic value into a list of strings.

View File

@@ -1,31 +1,6 @@
import 'package:equatable/equatable.dart';
/// Lifecycle status of a staff account in V2.
enum StaffStatus {
/// Staff is active and eligible for work.
active,
/// Staff has been invited but has not completed onboarding.
invited,
/// Staff account has been deactivated.
inactive,
/// Staff account has been blocked by an admin.
blocked,
}
/// Onboarding progress of a staff member.
enum OnboardingStatus {
/// Onboarding has not started.
pending,
/// Onboarding is in progress.
inProgress,
/// Onboarding is complete.
completed,
}
import 'package:krow_domain/krow_domain.dart' show OnboardingStatus, StaffStatus;
/// Represents a worker profile in the KROW platform.
///
@@ -63,9 +38,9 @@ class Staff extends Equatable {
fullName: json['fullName'] as String,
email: json['email'] as String?,
phone: json['phone'] as String?,
status: _parseStaffStatus(json['status'] as String?),
status: StaffStatus.fromJson(json['status'] as String?),
primaryRole: json['primaryRole'] as String?,
onboardingStatus: _parseOnboardingStatus(json['onboardingStatus'] as String?),
onboardingStatus: OnboardingStatus.fromJson(json['onboardingStatus'] as String?),
averageRating: _parseDouble(json['averageRating']),
ratingCount: (json['ratingCount'] as num?)?.toInt() ?? 0,
metadata: (json['metadata'] as Map<String, dynamic>?) ?? const <String, dynamic>{},
@@ -137,9 +112,9 @@ class Staff extends Equatable {
'fullName': fullName,
'email': email,
'phone': phone,
'status': status.name.toUpperCase(),
'status': status.toJson(),
'primaryRole': primaryRole,
'onboardingStatus': onboardingStatus.name.toUpperCase(),
'onboardingStatus': onboardingStatus.toJson(),
'averageRating': averageRating,
'ratingCount': ratingCount,
'metadata': metadata,
@@ -172,36 +147,6 @@ class Staff extends Equatable {
updatedAt,
];
/// Parses a status string into a [StaffStatus].
static StaffStatus _parseStaffStatus(String? value) {
switch (value?.toUpperCase()) {
case 'ACTIVE':
return StaffStatus.active;
case 'INVITED':
return StaffStatus.invited;
case 'INACTIVE':
return StaffStatus.inactive;
case 'BLOCKED':
return StaffStatus.blocked;
default:
return StaffStatus.active;
}
}
/// Parses an onboarding status string into an [OnboardingStatus].
static OnboardingStatus _parseOnboardingStatus(String? value) {
switch (value?.toUpperCase()) {
case 'PENDING':
return OnboardingStatus.pending;
case 'IN_PROGRESS':
return OnboardingStatus.inProgress;
case 'COMPLETED':
return OnboardingStatus.completed;
default:
return OnboardingStatus.pending;
}
}
/// Safely parses a numeric value to double.
static double _parseDouble(Object? value) {
if (value is num) return value.toDouble();

View File

@@ -2,7 +2,8 @@ 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:krow_domain/krow_domain.dart';
import 'src/data/repositories_impl/auth_repository_impl.dart';
import 'src/domain/repositories/auth_repository_interface.dart';
import 'src/domain/usecases/sign_in_with_email_use_case.dart';
@@ -21,14 +22,19 @@ export 'src/presentation/pages/client_sign_up_page.dart';
export 'package:core_localization/core_localization.dart';
/// A [Module] for the client authentication feature.
///
/// Imports [CoreModule] for [BaseApiService] and registers repositories,
/// use cases, and BLoCs for the client authentication flow.
class ClientAuthenticationModule extends Module {
@override
List<Module> get imports => <Module>[DataConnectModule()];
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<AuthRepositoryInterface>(AuthRepositoryImpl.new);
i.addLazySingleton<AuthRepositoryInterface>(
() => AuthRepositoryImpl(apiService: i.get<BaseApiService>()),
);
// UseCases
i.addLazySingleton(

View File

@@ -1,68 +1,96 @@
import 'dart:developer' as developer;
import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'
show
AccountExistsException,
ApiResponse,
AppException,
BaseApiService,
ClientSession,
InvalidCredentialsException,
NetworkException,
PasswordMismatchException,
SignInFailedException,
SignUpFailedException,
WeakPasswordException,
AccountExistsException,
UserNotFoundException,
UnauthorizedAppException,
PasswordMismatchException,
NetworkException;
import 'package:krow_domain/krow_domain.dart' as domain;
User,
UserStatus,
WeakPasswordException;
import '../../domain/repositories/auth_repository_interface.dart';
import 'package:client_authentication/src/domain/repositories/auth_repository_interface.dart';
/// Production-ready implementation of the [AuthRepositoryInterface] for the client app.
/// Production implementation of the [AuthRepositoryInterface] for the client app.
///
/// This implementation integrates with Firebase Authentication for user
/// identity management and KROW's Data Connect SDK for storing user profile data.
/// Uses Firebase Auth client-side for sign-in (to maintain local auth state for
/// the [AuthInterceptor]), then calls V2 `GET /auth/session` to retrieve
/// business context. Sign-up provisioning (tenant, business, memberships) is
/// handled entirely server-side by the V2 API.
class AuthRepositoryImpl implements AuthRepositoryInterface {
/// Creates an [AuthRepositoryImpl] with the real dependencies.
AuthRepositoryImpl({dc.DataConnectService? service})
: _service = service ?? dc.DataConnectService.instance;
/// Creates an [AuthRepositoryImpl] with the given [BaseApiService].
AuthRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
final dc.DataConnectService _service;
/// The V2 API service for backend calls.
final BaseApiService _apiService;
/// Firebase Auth instance for client-side sign-in/sign-up.
firebase.FirebaseAuth get _auth => firebase.FirebaseAuth.instance;
@override
Future<domain.User> signInWithEmail({
Future<User> signInWithEmail({
required String email,
required String password,
}) async {
try {
final firebase.UserCredential credential = await _service.auth
.signInWithEmailAndPassword(email: email, password: password);
// Step 1: Call V2 sign-in endpoint — server handles Firebase Auth
// via Identity Toolkit and returns a full auth envelope.
final ApiResponse response = await _apiService.post(
V2ApiEndpoints.clientSignIn,
data: <String, dynamic>{
'email': email,
'password': password,
},
);
final Map<String, dynamic> body =
response.data as Map<String, dynamic>;
// Check for V2 error responses.
if (response.code != '200' && response.code != '201') {
final String errorCode = body['code']?.toString() ?? response.code;
if (errorCode == 'INVALID_CREDENTIALS' ||
response.message.contains('INVALID_LOGIN_CREDENTIALS')) {
throw InvalidCredentialsException(
technicalMessage: response.message,
);
}
throw SignInFailedException(
technicalMessage: '$errorCode: ${response.message}',
);
}
// Step 2: Sign in locally so AuthInterceptor can attach Bearer tokens
// to subsequent requests. The V2 API already validated credentials, so
// email/password sign-in establishes the local Firebase Auth state.
final firebase.UserCredential credential =
await _auth.signInWithEmailAndPassword(
email: email,
password: password,
);
final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) {
throw const SignInFailedException(
technicalMessage: 'No Firebase user received after sign-in',
technicalMessage: 'Local Firebase sign-in failed after V2 sign-in',
);
}
return _getUserProfile(
firebaseUserId: firebaseUser.uid,
fallbackEmail: firebaseUser.email ?? email,
requireBusinessRole: true,
);
} on firebase.FirebaseAuthException catch (e) {
if (e.code == 'invalid-credential' || e.code == 'wrong-password') {
throw InvalidCredentialsException(
technicalMessage: 'Firebase error code: ${e.code}',
);
} else if (e.code == 'network-request-failed') {
throw NetworkException(technicalMessage: 'Firebase: ${e.message}');
} else {
throw SignInFailedException(
technicalMessage: 'Firebase auth error: ${e.message}',
);
}
} on domain.AppException {
// Step 3: Populate session store from the V2 auth envelope directly
// (no need for a separate GET /auth/session call).
return _populateStoreFromAuthEnvelope(body, firebaseUser, email);
} on AppException {
rethrow;
} catch (e) {
throw SignInFailedException(technicalMessage: 'Unexpected error: $e');
@@ -70,50 +98,57 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
}
@override
Future<domain.User> signUpWithEmail({
Future<User> signUpWithEmail({
required String companyName,
required String email,
required String password,
}) async {
firebase.User? firebaseUser;
String? createdBusinessId;
try {
// Step 1: Try to create Firebase Auth user
final firebase.UserCredential credential = await _service.auth
.createUserWithEmailAndPassword(email: email, password: password);
// Step 1: Call V2 sign-up endpoint which handles everything server-side:
// - Creates Firebase Auth account via Identity Toolkit
// - Creates user, tenant, business, memberships in one transaction
// - Returns full auth envelope with session tokens
final ApiResponse response = await _apiService.post(
V2ApiEndpoints.clientSignUp,
data: <String, dynamic>{
'companyName': companyName,
'email': email,
'password': password,
},
);
firebaseUser = credential.user;
// Check for V2 error responses.
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
if (response.code != '201' && response.code != '200') {
final String errorCode = body['code']?.toString() ?? response.code;
_throwSignUpError(errorCode, response.message);
}
// Step 2: Sign in locally to Firebase Auth so AuthInterceptor works
// for subsequent requests. The V2 API already created the Firebase
// account, so this should succeed.
final firebase.UserCredential credential =
await _auth.signInWithEmailAndPassword(
email: email,
password: password,
);
final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) {
throw const SignUpFailedException(
technicalMessage: 'Firebase user could not be created',
technicalMessage: 'Local Firebase sign-in failed after V2 sign-up',
);
}
// Force-refresh the ID token so the Data Connect SDK has a valid bearer
// token before we fire any mutations. Without this, there is a race
// condition where the gRPC layer sends the request unauthenticated
// immediately after account creation (gRPC code 16 UNAUTHENTICATED).
await firebaseUser.getIdToken(true);
// New user created successfully, proceed to create PostgreSQL entities
return await _createBusinessAndUser(
firebaseUser: firebaseUser,
companyName: companyName,
email: email,
onBusinessCreated: (String businessId) =>
createdBusinessId = businessId,
);
// Step 3: Populate store from the sign-up response envelope.
return _populateStoreFromAuthEnvelope(body, firebaseUser, email);
} on firebase.FirebaseAuthException catch (e) {
if (e.code == 'weak-password') {
throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}');
} else if (e.code == 'email-already-in-use') {
// Email exists in Firebase Auth - try to sign in and complete registration
return await _handleExistingFirebaseAccount(
email: email,
password: password,
companyName: companyName,
if (e.code == 'email-already-in-use') {
throw AccountExistsException(
technicalMessage: 'Firebase: ${e.message}',
);
} else if (e.code == 'weak-password') {
throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}');
} else if (e.code == 'network-request-failed') {
throw NetworkException(technicalMessage: 'Firebase: ${e.message}');
} else {
@@ -121,304 +156,103 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
technicalMessage: 'Firebase auth error: ${e.message}',
);
}
} on domain.AppException {
// Rollback for our known exceptions
await _rollbackSignUp(
firebaseUser: firebaseUser,
businessId: createdBusinessId,
);
} on AppException {
rethrow;
} catch (e) {
// Rollback: Clean up any partially created resources
await _rollbackSignUp(
firebaseUser: firebaseUser,
businessId: createdBusinessId,
);
throw SignUpFailedException(technicalMessage: 'Unexpected error: $e');
}
}
/// Handles the case where email already exists in Firebase Auth.
///
/// This can happen when:
/// 1. User signed up with Google in another app sharing the same Firebase project
/// 2. User already has a KROW account
///
/// The flow:
/// 1. Try to sign in with provided password
/// 2. If sign-in succeeds, check if BUSINESS user exists in PostgreSQL
/// 3. If not, create Business + User (user is new to KROW)
/// 4. If yes, they already have a KROW account
Future<domain.User> _handleExistingFirebaseAccount({
required String email,
required String password,
required String companyName,
}) async {
developer.log(
'Email exists in Firebase, attempting sign-in: $email',
name: 'AuthRepository',
);
try {
// Try to sign in with the provided password
final firebase.UserCredential credential = await _service.auth
.signInWithEmailAndPassword(email: email, password: password);
final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) {
throw const SignUpFailedException(
technicalMessage: 'Sign-in succeeded but no user returned',
);
}
// Force-refresh the ID token so the Data Connect SDK receives a valid
// bearer token before any subsequent Data Connect queries run.
await firebaseUser.getIdToken(true);
// Sign-in succeeded! Check if user already has a BUSINESS account in PostgreSQL
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',
);
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',
);
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',
);
if (e.code == 'wrong-password' || e.code == 'invalid-credential') {
// Password doesn't match - check what providers are available
return await _handlePasswordMismatch(email);
} else {
throw SignUpFailedException(
technicalMessage: 'Firebase sign-in error: ${e.message}',
);
}
} on domain.AppException {
rethrow;
}
}
/// Handles the case where the password doesn't match the existing account.
///
/// Note: fetchSignInMethodsForEmail was deprecated by Firebase for security
/// reasons (email enumeration). We show a combined message that covers both
/// cases: wrong password OR account uses different sign-in method (Google).
Future<Never> _handlePasswordMismatch(String email) async {
// We can't distinguish between "wrong password" and "no password provider"
// due to Firebase deprecating fetchSignInMethodsForEmail.
// The PasswordMismatchException message covers both scenarios.
developer.log(
'Password mismatch or different provider for: $email',
name: 'AuthRepository',
);
throw PasswordMismatchException(
technicalMessage:
'Email $email: password mismatch or different auth provider',
);
}
/// Checks if a user with BUSINESS role exists in PostgreSQL.
Future<bool> _checkBusinessUserExists(String firebaseUserId) async {
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response =
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');
}
/// Creates Business and User entities in PostgreSQL for a Firebase user.
Future<domain.User> _createBusinessAndUser({
required firebase.User firebaseUser,
required String companyName,
required String email,
required void Function(String businessId) onBusinessCreated,
}) async {
// Create Business entity in PostgreSQL
final OperationResult<dc.CreateBusinessData, dc.CreateBusinessVariables>
createBusinessResponse = await _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;
onBusinessCreated(businessData.id);
// Check if User entity already exists in PostgreSQL
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> userResult =
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(),
);
} 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(),
);
}
return _getUserProfile(
firebaseUserId: firebaseUser.uid,
fallbackEmail: firebaseUser.email ?? email,
);
}
/// Rollback helper to clean up partially created resources during sign-up.
Future<void> _rollbackSignUp({
firebase.User? firebaseUser,
String? businessId,
}) async {
// Delete business first (if created)
if (businessId != null) {
try {
await _service.connector.deleteBusiness(id: businessId).execute();
} catch (_) {
// Log but don't throw - we're already in error recovery
}
}
// Delete Firebase user (if created)
if (firebaseUser != null) {
try {
await firebaseUser.delete();
} catch (_) {
// Log but don't throw - we're already in error recovery
}
}
}
@override
Future<void> signOut() async {
try {
await _service.signOut();
} catch (e) {
throw Exception('Error signing out: ${e.toString()}');
}
}
@override
Future<domain.User> signInWithSocial({required String provider}) {
Future<User> signInWithSocial({required String provider}) {
throw UnimplementedError(
'Social authentication with $provider is not yet implemented.',
);
}
Future<domain.User> _getUserProfile({
required String firebaseUserId,
required String? fallbackEmail,
bool requireBusinessRole = false,
}) async {
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response =
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',
);
}
if (requireBusinessRole &&
user.userRole != 'BUSINESS' &&
user.userRole != 'BOTH') {
await _service.signOut();
throw UnauthorizedAppException(
technicalMessage:
'User role is ${user.userRole}, expected BUSINESS or BOTH',
@override
Future<void> signOut() async {
try {
// Step 1: Call V2 sign-out endpoint for server-side token revocation.
await _apiService.post(V2ApiEndpoints.clientSignOut);
} catch (e) {
developer.log(
'V2 sign-out request failed: $e',
name: 'AuthRepository',
);
// Continue with local sign-out even if server-side fails.
}
final String? email = user.email ?? fallbackEmail;
if (email == null || email.isEmpty) {
throw UserNotFoundException(
technicalMessage: 'User email missing for UID $firebaseUserId',
);
try {
// Step 2: Sign out from local Firebase Auth.
await _auth.signOut();
} catch (e) {
throw Exception('Error signing out locally: $e');
}
final domain.User domainUser = domain.User(
id: user.id,
// Step 3: Clear the client session store.
ClientSessionStore.instance.clear();
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
/// Populates the session store from a V2 auth envelope response and
/// returns a domain [User].
User _populateStoreFromAuthEnvelope(
Map<String, dynamic> envelope,
firebase.User firebaseUser,
String fallbackEmail,
) {
final Map<String, dynamic>? userJson =
envelope['user'] as Map<String, dynamic>?;
final Map<String, dynamic>? businessJson =
envelope['business'] as Map<String, dynamic>?;
if (businessJson != null) {
final ClientSession clientSession = ClientSession.fromJson(envelope);
ClientSessionStore.instance.setSession(clientSession);
}
final String userId =
userJson?['id'] as String? ?? firebaseUser.uid;
final String? email = userJson?['email'] as String? ?? fallbackEmail;
return User(
id: userId,
email: email,
role: user.role.stringValue,
displayName: userJson?['displayName'] as String?,
phone: userJson?['phone'] as String?,
status: _parseUserStatus(userJson?['status'] as String?),
);
}
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;
/// Maps a V2 error code to the appropriate domain exception for sign-up.
Never _throwSignUpError(String errorCode, String message) {
switch (errorCode) {
case 'AUTH_PROVIDER_ERROR' when message.contains('EMAIL_EXISTS'):
throw AccountExistsException(technicalMessage: message);
case 'AUTH_PROVIDER_ERROR' when message.contains('WEAK_PASSWORD'):
throw WeakPasswordException(technicalMessage: message);
case 'FORBIDDEN':
throw PasswordMismatchException(technicalMessage: message);
default:
throw SignUpFailedException(technicalMessage: '$errorCode: $message');
}
}
dc.ClientSessionStore.instance.setSession(
dc.ClientSession(
business: business == null
? null
: dc.ClientBusinessSession(
id: business.id,
businessName: business.businessName,
email: business.email,
city: business.city,
contactName: business.contactName,
companyLogoUrl: business.companyLogoUrl,
),
),
);
return domainUser;
/// Parses a status string from the API into a [UserStatus].
static UserStatus _parseUserStatus(String? value) {
switch (value?.toUpperCase()) {
case 'ACTIVE':
return UserStatus.active;
case 'INVITED':
return UserStatus.invited;
case 'DISABLED':
return UserStatus.disabled;
default:
return UserStatus.active;
}
}
}

View File

@@ -14,17 +14,13 @@ dependencies:
flutter_bloc: ^8.1.0
flutter_modular: ^6.3.0
equatable: ^2.0.5
firebase_core: ^4.2.1
firebase_auth: ^6.1.2 # Updated for compatibility
firebase_data_connect: ^0.2.2+1
firebase_auth: ^6.1.2
# Architecture Packages
design_system:
path: ../../../design_system
core_localization:
path: ../../../core_localization
krow_data_connect:
path: ../../../data_connect
krow_domain:
path: ../../../domain
krow_core:
@@ -35,7 +31,6 @@ dev_dependencies:
sdk: flutter
bloc_test: ^9.1.0
mocktail: ^1.0.0
build_runner: ^2.4.15
flutter:
uses-material-design: true

View File

@@ -1,30 +1,37 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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';
import 'domain/usecases/get_savings_amount.dart';
import 'domain/usecases/get_spending_breakdown.dart';
import 'domain/usecases/approve_invoice.dart';
import 'domain/usecases/dispute_invoice.dart';
import 'presentation/blocs/billing_bloc.dart';
import 'presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
import 'presentation/models/billing_invoice_model.dart';
import 'presentation/pages/billing_page.dart';
import 'presentation/pages/completion_review_page.dart';
import 'presentation/pages/invoice_ready_page.dart';
import 'presentation/pages/pending_invoices_page.dart';
import 'package:billing/src/data/repositories_impl/billing_repository_impl.dart';
import 'package:billing/src/domain/repositories/billing_repository.dart';
import 'package:billing/src/domain/usecases/approve_invoice.dart';
import 'package:billing/src/domain/usecases/dispute_invoice.dart';
import 'package:billing/src/domain/usecases/get_bank_accounts.dart';
import 'package:billing/src/domain/usecases/get_current_bill_amount.dart';
import 'package:billing/src/domain/usecases/get_invoice_history.dart';
import 'package:billing/src/domain/usecases/get_pending_invoices.dart';
import 'package:billing/src/domain/usecases/get_savings_amount.dart';
import 'package:billing/src/domain/usecases/get_spending_breakdown.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
import 'package:billing/src/presentation/pages/billing_page.dart';
import 'package:billing/src/presentation/pages/completion_review_page.dart';
import 'package:billing/src/presentation/pages/invoice_ready_page.dart';
import 'package:billing/src/presentation/pages/pending_invoices_page.dart';
/// Modular module for the billing feature.
///
/// Uses [BaseApiService] for all backend access via V2 REST API.
class BillingModule extends Module {
@override
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<BillingRepository>(BillingRepositoryImpl.new);
i.addLazySingleton<BillingRepository>(
() => BillingRepositoryImpl(apiService: i.get<BaseApiService>()),
);
// Use Cases
i.addLazySingleton(GetBankAccountsUseCase.new);
@@ -32,7 +39,7 @@ class BillingModule extends Module {
i.addLazySingleton(GetSavingsAmountUseCase.new);
i.addLazySingleton(GetPendingInvoicesUseCase.new);
i.addLazySingleton(GetInvoiceHistoryUseCase.new);
i.addLazySingleton(GetSpendingBreakdownUseCase.new);
i.addLazySingleton(GetSpendBreakdownUseCase.new);
i.addLazySingleton(ApproveInvoiceUseCase.new);
i.addLazySingleton(DisputeInvoiceUseCase.new);
@@ -44,7 +51,7 @@ class BillingModule extends Module {
getSavingsAmount: i.get<GetSavingsAmountUseCase>(),
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),
getInvoiceHistory: i.get<GetInvoiceHistoryUseCase>(),
getSpendingBreakdown: i.get<GetSpendingBreakdownUseCase>(),
getSpendBreakdown: i.get<GetSpendBreakdownUseCase>(),
),
);
i.add<ShiftCompletionReviewBloc>(
@@ -62,16 +69,20 @@ class BillingModule extends Module {
child: (_) => const BillingPage(),
);
r.child(
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.completionReview),
child: (_) =>
ShiftCompletionReviewPage(invoice: r.args.data as BillingInvoice?),
ClientPaths.childRoute(
ClientPaths.billing, ClientPaths.completionReview),
child: (_) => ShiftCompletionReviewPage(
invoice:
r.args.data is Invoice ? r.args.data as Invoice : null,
),
);
r.child(
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.invoiceReady),
child: (_) => const InvoiceReadyPage(),
);
r.child(
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.awaitingApproval),
ClientPaths.childRoute(
ClientPaths.billing, ClientPaths.awaitingApproval),
child: (_) => const PendingInvoicesPage(),
);
}

View File

@@ -1,70 +1,103 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/billing_repository.dart';
/// Implementation of [BillingRepository] that delegates to [dc.BillingConnectorRepository].
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Implementation of [BillingRepository] using the V2 REST API.
///
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
/// connector repository from the data_connect package.
/// All backend calls go through [BaseApiService] with [V2ApiEndpoints].
class BillingRepositoryImpl implements BillingRepository {
/// Creates a [BillingRepositoryImpl].
BillingRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
BillingRepositoryImpl({
dc.BillingConnectorRepository? connectorRepository,
dc.DataConnectService? service,
}) : _connectorRepository = connectorRepository ??
dc.DataConnectService.instance.getBillingRepository(),
_service = service ?? dc.DataConnectService.instance;
final dc.BillingConnectorRepository _connectorRepository;
final dc.DataConnectService _service;
/// The API service used for all HTTP requests.
final BaseApiService _apiService;
@override
Future<List<BusinessBankAccount>> getBankAccounts() async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.getBankAccounts(businessId: businessId);
}
@override
Future<double> getCurrentBillAmount() async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.getCurrentBillAmount(businessId: businessId);
}
@override
Future<List<Invoice>> getInvoiceHistory() async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.getInvoiceHistory(businessId: businessId);
Future<List<BillingAccount>> getBankAccounts() async {
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientBillingAccounts);
final List<dynamic> items =
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
return items
.map((dynamic json) =>
BillingAccount.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<List<Invoice>> getPendingInvoices() async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.getPendingInvoices(businessId: businessId);
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientBillingInvoicesPending);
final List<dynamic> items =
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
return items
.map(
(dynamic json) => Invoice.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<double> getSavingsAmount() async {
// Simulating savings calculation
return 0.0;
Future<List<Invoice>> getInvoiceHistory() async {
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientBillingInvoicesHistory);
final List<dynamic> items =
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
return items
.map(
(dynamic json) => Invoice.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period) async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.getSpendingBreakdown(
businessId: businessId,
period: period,
Future<int> getCurrentBillCents() async {
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientBillingCurrentBill);
final Map<String, dynamic> data =
response.data as Map<String, dynamic>;
return (data['currentBillCents'] as num).toInt();
}
@override
Future<int> getSavingsCents() async {
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientBillingSavings);
final Map<String, dynamic> data =
response.data as Map<String, dynamic>;
return (data['savingsCents'] as num).toInt();
}
@override
Future<List<SpendItem>> getSpendBreakdown({
required String startDate,
required String endDate,
}) async {
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.clientBillingSpendBreakdown,
params: <String, dynamic>{
'startDate': startDate,
'endDate': endDate,
},
);
final List<dynamic> items =
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
return items
.map((dynamic json) =>
SpendItem.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<void> approveInvoice(String id) async {
return _connectorRepository.approveInvoice(id: id);
await _apiService.post(V2ApiEndpoints.clientInvoiceApprove(id));
}
@override
Future<void> disputeInvoice(String id, String reason) async {
return _connectorRepository.disputeInvoice(id: id, reason: reason);
await _apiService.post(
V2ApiEndpoints.clientInvoiceDispute(id),
data: <String, dynamic>{'reason': reason},
);
}
}

View File

@@ -7,7 +7,7 @@ import 'package:krow_domain/krow_domain.dart';
/// It allows the Domain layer to remain independent of specific data sources.
abstract class BillingRepository {
/// Fetches bank accounts associated with the business.
Future<List<BusinessBankAccount>> getBankAccounts();
Future<List<BillingAccount>> getBankAccounts();
/// Fetches invoices that are pending approval or payment.
Future<List<Invoice>> getPendingInvoices();
@@ -15,14 +15,17 @@ abstract class BillingRepository {
/// Fetches historically paid invoices.
Future<List<Invoice>> getInvoiceHistory();
/// Fetches the current bill amount for the period.
Future<double> getCurrentBillAmount();
/// Fetches the current bill amount in cents for the period.
Future<int> getCurrentBillCents();
/// Fetches the savings amount.
Future<double> getSavingsAmount();
/// Fetches the savings amount in cents.
Future<int> getSavingsCents();
/// Fetches invoice items for spending breakdown analysis.
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period);
/// Fetches spending breakdown by category for a date range.
Future<List<SpendItem>> getSpendBreakdown({
required String startDate,
required String endDate,
});
/// Approves an invoice.
Future<void> approveInvoice(String id);

View File

@@ -1,11 +1,13 @@
import 'package:krow_core/core.dart';
import '../repositories/billing_repository.dart';
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Use case for approving an invoice.
class ApproveInvoiceUseCase extends UseCase<String, void> {
/// Creates an [ApproveInvoiceUseCase].
ApproveInvoiceUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override

View File

@@ -1,10 +1,16 @@
import 'package:krow_core/core.dart';
import '../repositories/billing_repository.dart';
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Params for [DisputeInvoiceUseCase].
class DisputeInvoiceParams {
/// Creates [DisputeInvoiceParams].
const DisputeInvoiceParams({required this.id, required this.reason});
/// The invoice ID to dispute.
final String id;
/// The reason for the dispute.
final String reason;
}
@@ -13,6 +19,7 @@ class DisputeInvoiceUseCase extends UseCase<DisputeInvoiceParams, void> {
/// Creates a [DisputeInvoiceUseCase].
DisputeInvoiceUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override

View File

@@ -1,14 +1,16 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/billing_repository.dart';
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Use case for fetching the bank accounts associated with the business.
class GetBankAccountsUseCase extends NoInputUseCase<List<BusinessBankAccount>> {
class GetBankAccountsUseCase extends NoInputUseCase<List<BillingAccount>> {
/// Creates a [GetBankAccountsUseCase].
GetBankAccountsUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override
Future<List<BusinessBankAccount>> call() => _repository.getBankAccounts();
Future<List<BillingAccount>> call() => _repository.getBankAccounts();
}

View File

@@ -1,16 +1,17 @@
import 'package:krow_core/core.dart';
import '../repositories/billing_repository.dart';
/// Use case for fetching the current bill amount.
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Use case for fetching the current bill amount in cents.
///
/// This use case encapsulates the logic for retrieving the total amount due for the current billing period.
/// It delegates the data retrieval to the [BillingRepository].
class GetCurrentBillAmountUseCase extends NoInputUseCase<double> {
/// Delegates data retrieval to the [BillingRepository].
class GetCurrentBillAmountUseCase extends NoInputUseCase<int> {
/// Creates a [GetCurrentBillAmountUseCase].
GetCurrentBillAmountUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override
Future<double> call() => _repository.getCurrentBillAmount();
Future<int> call() => _repository.getCurrentBillCents();
}

View File

@@ -1,15 +1,16 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/billing_repository.dart';
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Use case for fetching the invoice history.
///
/// This use case encapsulates the logic for retrieving the list of past paid invoices.
/// It delegates the data retrieval to the [BillingRepository].
/// Retrieves the list of past paid invoices.
class GetInvoiceHistoryUseCase extends NoInputUseCase<List<Invoice>> {
/// Creates a [GetInvoiceHistoryUseCase].
GetInvoiceHistoryUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override

View File

@@ -1,15 +1,16 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/billing_repository.dart';
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Use case for fetching the pending invoices.
///
/// This use case encapsulates the logic for retrieving invoices that are currently open or disputed.
/// It delegates the data retrieval to the [BillingRepository].
/// Retrieves invoices that are currently open or disputed.
class GetPendingInvoicesUseCase extends NoInputUseCase<List<Invoice>> {
/// Creates a [GetPendingInvoicesUseCase].
GetPendingInvoicesUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override

View File

@@ -1,16 +1,17 @@
import 'package:krow_core/core.dart';
import '../repositories/billing_repository.dart';
/// Use case for fetching the savings amount.
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Use case for fetching the savings amount in cents.
///
/// This use case encapsulates the logic for retrieving the estimated savings for the client.
/// It delegates the data retrieval to the [BillingRepository].
class GetSavingsAmountUseCase extends NoInputUseCase<double> {
/// Delegates data retrieval to the [BillingRepository].
class GetSavingsAmountUseCase extends NoInputUseCase<int> {
/// Creates a [GetSavingsAmountUseCase].
GetSavingsAmountUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override
Future<double> call() => _repository.getSavingsAmount();
Future<int> call() => _repository.getSavingsCents();
}

View File

@@ -1,19 +1,38 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/billing_repository.dart';
/// Use case for fetching the spending breakdown items.
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Parameters for [GetSpendBreakdownUseCase].
class SpendBreakdownParams {
/// Creates [SpendBreakdownParams].
const SpendBreakdownParams({
required this.startDate,
required this.endDate,
});
/// ISO-8601 start date for the range.
final String startDate;
/// ISO-8601 end date for the range.
final String endDate;
}
/// Use case for fetching the spending breakdown by category.
///
/// This use case encapsulates the logic for retrieving the spending breakdown by category or item.
/// It delegates the data retrieval to the [BillingRepository].
class GetSpendingBreakdownUseCase
extends UseCase<BillingPeriod, List<InvoiceItem>> {
/// Creates a [GetSpendingBreakdownUseCase].
GetSpendingBreakdownUseCase(this._repository);
/// Delegates data retrieval to the [BillingRepository].
class GetSpendBreakdownUseCase
extends UseCase<SpendBreakdownParams, List<SpendItem>> {
/// Creates a [GetSpendBreakdownUseCase].
GetSpendBreakdownUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override
Future<List<InvoiceItem>> call(BillingPeriod period) =>
_repository.getSpendingBreakdown(period);
Future<List<SpendItem>> call(SpendBreakdownParams input) =>
_repository.getSpendBreakdown(
startDate: input.startDate,
endDate: input.endDate,
);
}

View File

@@ -1,17 +1,17 @@
import 'dart:developer' as developer;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.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';
import '../../domain/usecases/get_savings_amount.dart';
import '../../domain/usecases/get_spending_breakdown.dart';
import '../models/billing_invoice_model.dart';
import '../models/spending_breakdown_model.dart';
import 'billing_event.dart';
import 'billing_state.dart';
import 'package:billing/src/domain/usecases/get_bank_accounts.dart';
import 'package:billing/src/domain/usecases/get_current_bill_amount.dart';
import 'package:billing/src/domain/usecases/get_invoice_history.dart';
import 'package:billing/src/domain/usecases/get_pending_invoices.dart';
import 'package:billing/src/domain/usecases/get_savings_amount.dart';
import 'package:billing/src/domain/usecases/get_spending_breakdown.dart';
import 'package:billing/src/presentation/blocs/billing_event.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
/// BLoC for managing billing state and data loading.
class BillingBloc extends Bloc<BillingEvent, BillingState>
@@ -23,14 +23,14 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
required GetSavingsAmountUseCase getSavingsAmount,
required GetPendingInvoicesUseCase getPendingInvoices,
required GetInvoiceHistoryUseCase getInvoiceHistory,
required GetSpendingBreakdownUseCase getSpendingBreakdown,
}) : _getBankAccounts = getBankAccounts,
_getCurrentBillAmount = getCurrentBillAmount,
_getSavingsAmount = getSavingsAmount,
_getPendingInvoices = getPendingInvoices,
_getInvoiceHistory = getInvoiceHistory,
_getSpendingBreakdown = getSpendingBreakdown,
super(const BillingState()) {
required GetSpendBreakdownUseCase getSpendBreakdown,
}) : _getBankAccounts = getBankAccounts,
_getCurrentBillAmount = getCurrentBillAmount,
_getSavingsAmount = getSavingsAmount,
_getPendingInvoices = getPendingInvoices,
_getInvoiceHistory = getInvoiceHistory,
_getSpendBreakdown = getSpendBreakdown,
super(const BillingState()) {
on<BillingLoadStarted>(_onLoadStarted);
on<BillingPeriodChanged>(_onPeriodChanged);
}
@@ -40,61 +40,60 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
final GetSavingsAmountUseCase _getSavingsAmount;
final GetPendingInvoicesUseCase _getPendingInvoices;
final GetInvoiceHistoryUseCase _getInvoiceHistory;
final GetSpendingBreakdownUseCase _getSpendingBreakdown;
final GetSpendBreakdownUseCase _getSpendBreakdown;
/// Executes [loader] and returns null on failure, logging the error.
Future<T?> _loadSafe<T>(Future<T> Function() loader) async {
try {
return await loader();
} catch (e, stackTrace) {
developer.log(
'Partial billing load failed: $e',
name: 'BillingBloc',
error: e,
stackTrace: stackTrace,
);
return null;
}
}
Future<void> _onLoadStarted(
BillingLoadStarted event,
Emitter<BillingState> emit,
) async {
emit(state.copyWith(status: BillingStatus.loading));
await handleError(
emit: emit.call,
action: () async {
final List<dynamic> results =
await Future.wait<dynamic>(<Future<dynamic>>[
_getCurrentBillAmount.call(),
_getSavingsAmount.call(),
_getPendingInvoices.call(),
_getInvoiceHistory.call(),
_getSpendingBreakdown.call(state.period),
_getBankAccounts.call(),
]);
final double savings = results[1] as double;
final List<Invoice> pendingInvoices = results[2] as List<Invoice>;
final List<Invoice> invoiceHistory = results[3] as List<Invoice>;
final List<InvoiceItem> spendingItems = results[4] as List<InvoiceItem>;
final List<BusinessBankAccount> bankAccounts =
results[5] as List<BusinessBankAccount>;
final SpendBreakdownParams spendParams = _dateRangeFor(state.periodTab);
// Map Domain Entities to Presentation Models
final List<BillingInvoice> uiPendingInvoices = pendingInvoices
.map(_mapInvoiceToUiModel)
.toList();
final List<BillingInvoice> uiInvoiceHistory = invoiceHistory
.map(_mapInvoiceToUiModel)
.toList();
final List<SpendingBreakdownItem> uiSpendingBreakdown =
_mapSpendingItemsToUiModel(spendingItems);
final double periodTotal = uiSpendingBreakdown.fold(
0.0,
(double sum, SpendingBreakdownItem item) => sum + item.amount,
);
final List<Object?> results = await Future.wait<Object?>(
<Future<Object?>>[
_loadSafe<int>(() => _getCurrentBillAmount.call()),
_loadSafe<int>(() => _getSavingsAmount.call()),
_loadSafe<List<Invoice>>(() => _getPendingInvoices.call()),
_loadSafe<List<Invoice>>(() => _getInvoiceHistory.call()),
_loadSafe<List<SpendItem>>(() => _getSpendBreakdown.call(spendParams)),
_loadSafe<List<BillingAccount>>(() => _getBankAccounts.call()),
],
);
emit(
state.copyWith(
status: BillingStatus.success,
currentBill: periodTotal,
savings: savings,
pendingInvoices: uiPendingInvoices,
invoiceHistory: uiInvoiceHistory,
spendingBreakdown: uiSpendingBreakdown,
bankAccounts: bankAccounts,
),
);
},
onError: (String errorKey) =>
state.copyWith(status: BillingStatus.failure, errorMessage: errorKey),
final int? currentBillCents = results[0] as int?;
final int? savingsCents = results[1] as int?;
final List<Invoice>? pendingInvoices = results[2] as List<Invoice>?;
final List<Invoice>? invoiceHistory = results[3] as List<Invoice>?;
final List<SpendItem>? spendBreakdown = results[4] as List<SpendItem>?;
final List<BillingAccount>? bankAccounts =
results[5] as List<BillingAccount>?;
emit(
state.copyWith(
status: BillingStatus.success,
currentBillCents: currentBillCents ?? state.currentBillCents,
savingsCents: savingsCents ?? state.savingsCents,
pendingInvoices: pendingInvoices ?? state.pendingInvoices,
invoiceHistory: invoiceHistory ?? state.invoiceHistory,
spendBreakdown: spendBreakdown ?? state.spendBreakdown,
bankAccounts: bankAccounts ?? state.bankAccounts,
),
);
}
@@ -105,19 +104,15 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
await handleError(
emit: emit.call,
action: () async {
final List<InvoiceItem> spendingItems = await _getSpendingBreakdown
.call(event.period);
final List<SpendingBreakdownItem> uiSpendingBreakdown =
_mapSpendingItemsToUiModel(spendingItems);
final double periodTotal = uiSpendingBreakdown.fold(
0.0,
(double sum, SpendingBreakdownItem item) => sum + item.amount,
);
final SpendBreakdownParams params =
_dateRangeFor(event.periodTab);
final List<SpendItem> spendBreakdown =
await _getSpendBreakdown.call(params);
emit(
state.copyWith(
period: event.period,
spendingBreakdown: uiSpendingBreakdown,
currentBill: periodTotal,
periodTab: event.periodTab,
spendBreakdown: spendBreakdown,
),
);
},
@@ -126,98 +121,14 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
);
}
BillingInvoice _mapInvoiceToUiModel(Invoice invoice) {
final DateFormat formatter = DateFormat('EEEE, MMMM d');
final String dateLabel = invoice.issueDate == null
? 'N/A'
: formatter.format(invoice.issueDate!);
final List<BillingWorkerRecord> workers = invoice.workers.map((
InvoiceWorker w,
) {
final DateFormat timeFormat = DateFormat('h:mm a');
return BillingWorkerRecord(
workerName: w.name,
roleName: w.role,
totalAmount: w.amount,
hours: w.hours,
rate: w.rate,
startTime: w.checkIn != null ? timeFormat.format(w.checkIn!) : '--:--',
endTime: w.checkOut != null ? timeFormat.format(w.checkOut!) : '--:--',
breakMinutes: w.breakMinutes,
workerAvatarUrl: w.avatarUrl,
);
}).toList();
String? overallStart;
String? overallEnd;
// Find valid times from actual DateTime checks to ensure chronological sorting
final List<DateTime> validCheckIns = invoice.workers
.where((InvoiceWorker w) => w.checkIn != null)
.map((InvoiceWorker w) => w.checkIn!)
.toList();
final List<DateTime> validCheckOuts = invoice.workers
.where((InvoiceWorker w) => w.checkOut != null)
.map((InvoiceWorker w) => w.checkOut!)
.toList();
final DateFormat timeFormat = DateFormat('h:mm a');
if (validCheckIns.isNotEmpty) {
validCheckIns.sort();
overallStart = timeFormat.format(validCheckIns.first);
} else if (workers.isNotEmpty) {
overallStart = workers.first.startTime;
}
if (validCheckOuts.isNotEmpty) {
validCheckOuts.sort();
overallEnd = timeFormat.format(validCheckOuts.last);
} else if (workers.isNotEmpty) {
overallEnd = workers.first.endTime;
}
return BillingInvoice(
id: invoice.id,
title: invoice.title ?? 'N/A',
locationAddress: invoice.locationAddress ?? 'Remote',
clientName: invoice.clientName ?? 'N/A',
date: dateLabel,
totalAmount: invoice.totalAmount,
workersCount: invoice.staffCount ?? 0,
totalHours: invoice.totalHours ?? 0.0,
status: invoice.status.name.toUpperCase(),
workers: workers,
startTime: overallStart,
endTime: overallEnd,
/// Computes ISO-8601 date range for the selected period tab.
SpendBreakdownParams _dateRangeFor(BillingPeriodTab tab) {
final DateTime now = DateTime.now().toUtc();
final int days = tab == BillingPeriodTab.week ? 7 : 30;
final DateTime start = now.subtract(Duration(days: days));
return SpendBreakdownParams(
startDate: start.toIso8601String(),
endDate: now.toIso8601String(),
);
}
List<SpendingBreakdownItem> _mapSpendingItemsToUiModel(
List<InvoiceItem> items,
) {
final Map<String, SpendingBreakdownItem> aggregation =
<String, SpendingBreakdownItem>{};
for (final InvoiceItem item in items) {
final String category = item.staffId;
final SpendingBreakdownItem? existing = aggregation[category];
if (existing != null) {
aggregation[category] = SpendingBreakdownItem(
category: category,
hours: existing.hours + item.workHours.round(),
amount: existing.amount + item.amount,
);
} else {
aggregation[category] = SpendingBreakdownItem(
category: category,
hours: item.workHours.round(),
amount: item.amount,
);
}
}
return aggregation.values.toList();
}
}

View File

@@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
/// Base class for all billing events.
abstract class BillingEvent extends Equatable {
@@ -16,11 +17,14 @@ class BillingLoadStarted extends BillingEvent {
const BillingLoadStarted();
}
/// Event triggered when the spend breakdown period tab changes.
class BillingPeriodChanged extends BillingEvent {
const BillingPeriodChanged(this.period);
/// Creates a [BillingPeriodChanged] event.
const BillingPeriodChanged(this.periodTab);
final BillingPeriod period;
/// The selected period tab.
final BillingPeriodTab periodTab;
@override
List<Object?> get props => <Object?>[period];
List<Object?> get props => <Object?>[periodTab];
}

View File

@@ -1,7 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
import '../models/billing_invoice_model.dart';
import '../models/spending_breakdown_model.dart';
/// The loading status of the billing feature.
enum BillingStatus {
@@ -18,83 +16,104 @@ enum BillingStatus {
failure,
}
/// Which period the spend breakdown covers.
enum BillingPeriodTab {
/// Last 7 days.
week,
/// Last 30 days.
month,
}
/// Represents the state of the billing feature.
class BillingState extends Equatable {
/// Creates a [BillingState].
const BillingState({
this.status = BillingStatus.initial,
this.currentBill = 0.0,
this.savings = 0.0,
this.pendingInvoices = const <BillingInvoice>[],
this.invoiceHistory = const <BillingInvoice>[],
this.spendingBreakdown = const <SpendingBreakdownItem>[],
this.bankAccounts = const <BusinessBankAccount>[],
this.period = BillingPeriod.week,
this.currentBillCents = 0,
this.savingsCents = 0,
this.pendingInvoices = const <Invoice>[],
this.invoiceHistory = const <Invoice>[],
this.spendBreakdown = const <SpendItem>[],
this.bankAccounts = const <BillingAccount>[],
this.periodTab = BillingPeriodTab.week,
this.errorMessage,
});
/// The current feature status.
final BillingStatus status;
/// The total amount for the current billing period.
final double currentBill;
/// The total amount for the current billing period in cents.
final int currentBillCents;
/// Total savings achieved compared to traditional agencies.
final double savings;
/// Total savings in cents.
final int savingsCents;
/// Invoices awaiting client approval.
final List<BillingInvoice> pendingInvoices;
final List<Invoice> pendingInvoices;
/// History of paid invoices.
final List<BillingInvoice> invoiceHistory;
final List<Invoice> invoiceHistory;
/// Breakdown of spending by category.
final List<SpendingBreakdownItem> spendingBreakdown;
final List<SpendItem> spendBreakdown;
/// Bank accounts associated with the business.
final List<BusinessBankAccount> bankAccounts;
final List<BillingAccount> bankAccounts;
/// Selected period for the breakdown.
final BillingPeriod period;
/// Selected period tab for the breakdown.
final BillingPeriodTab periodTab;
/// Error message if loading failed.
final String? errorMessage;
/// Current bill formatted as dollars.
double get currentBillDollars => currentBillCents / 100.0;
/// Savings formatted as dollars.
double get savingsDollars => savingsCents / 100.0;
/// Total spend across the breakdown in cents.
int get spendTotalCents => spendBreakdown.fold(
0,
(int sum, SpendItem item) => sum + item.amountCents,
);
/// Creates a copy of this state with updated fields.
BillingState copyWith({
BillingStatus? status,
double? currentBill,
double? savings,
List<BillingInvoice>? pendingInvoices,
List<BillingInvoice>? invoiceHistory,
List<SpendingBreakdownItem>? spendingBreakdown,
List<BusinessBankAccount>? bankAccounts,
BillingPeriod? period,
int? currentBillCents,
int? savingsCents,
List<Invoice>? pendingInvoices,
List<Invoice>? invoiceHistory,
List<SpendItem>? spendBreakdown,
List<BillingAccount>? bankAccounts,
BillingPeriodTab? periodTab,
String? errorMessage,
}) {
return BillingState(
status: status ?? this.status,
currentBill: currentBill ?? this.currentBill,
savings: savings ?? this.savings,
currentBillCents: currentBillCents ?? this.currentBillCents,
savingsCents: savingsCents ?? this.savingsCents,
pendingInvoices: pendingInvoices ?? this.pendingInvoices,
invoiceHistory: invoiceHistory ?? this.invoiceHistory,
spendingBreakdown: spendingBreakdown ?? this.spendingBreakdown,
spendBreakdown: spendBreakdown ?? this.spendBreakdown,
bankAccounts: bankAccounts ?? this.bankAccounts,
period: period ?? this.period,
periodTab: periodTab ?? this.periodTab,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => <Object?>[
status,
currentBill,
savings,
pendingInvoices,
invoiceHistory,
spendingBreakdown,
bankAccounts,
period,
errorMessage,
];
status,
currentBillCents,
savingsCents,
pendingInvoices,
invoiceHistory,
spendBreakdown,
bankAccounts,
periodTab,
errorMessage,
];
}

View File

@@ -1,19 +1,22 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import '../../../domain/usecases/approve_invoice.dart';
import '../../../domain/usecases/dispute_invoice.dart';
import 'shift_completion_review_event.dart';
import 'shift_completion_review_state.dart';
import 'package:billing/src/domain/usecases/approve_invoice.dart';
import 'package:billing/src/domain/usecases/dispute_invoice.dart';
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_event.dart';
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_state.dart';
/// BLoC for approving or disputing an invoice from the review page.
class ShiftCompletionReviewBloc
extends Bloc<ShiftCompletionReviewEvent, ShiftCompletionReviewState>
with BlocErrorHandler<ShiftCompletionReviewState> {
/// Creates a [ShiftCompletionReviewBloc].
ShiftCompletionReviewBloc({
required ApproveInvoiceUseCase approveInvoice,
required DisputeInvoiceUseCase disputeInvoice,
}) : _approveInvoice = approveInvoice,
_disputeInvoice = disputeInvoice,
super(const ShiftCompletionReviewState()) {
}) : _approveInvoice = approveInvoice,
_disputeInvoice = disputeInvoice,
super(const ShiftCompletionReviewState()) {
on<ShiftCompletionReviewApproved>(_onApproved);
on<ShiftCompletionReviewDisputed>(_onDisputed);
}

View File

@@ -1,84 +0,0 @@
import 'package:equatable/equatable.dart';
class BillingInvoice extends Equatable {
const BillingInvoice({
required this.id,
required this.title,
required this.locationAddress,
required this.clientName,
required this.date,
required this.totalAmount,
required this.workersCount,
required this.totalHours,
required this.status,
this.workers = const <BillingWorkerRecord>[],
this.startTime,
this.endTime,
});
final String id;
final String title;
final String locationAddress;
final String clientName;
final String date;
final double totalAmount;
final int workersCount;
final double totalHours;
final String status;
final List<BillingWorkerRecord> workers;
final String? startTime;
final String? endTime;
@override
List<Object?> get props => <Object?>[
id,
title,
locationAddress,
clientName,
date,
totalAmount,
workersCount,
totalHours,
status,
workers,
startTime,
endTime,
];
}
class BillingWorkerRecord extends Equatable {
const BillingWorkerRecord({
required this.workerName,
required this.roleName,
required this.totalAmount,
required this.hours,
required this.rate,
required this.startTime,
required this.endTime,
required this.breakMinutes,
this.workerAvatarUrl,
});
final String workerName;
final String roleName;
final double totalAmount;
final double hours;
final double rate;
final String startTime;
final String endTime;
final int breakMinutes;
final String? workerAvatarUrl;
@override
List<Object?> get props => <Object?>[
workerName,
roleName,
totalAmount,
hours,
rate,
startTime,
endTime,
breakMinutes,
workerAvatarUrl,
];
}

View File

@@ -1,23 +0,0 @@
import 'package:equatable/equatable.dart';
/// Represents a single item in the spending breakdown.
class SpendingBreakdownItem extends Equatable {
/// Creates a [SpendingBreakdownItem].
const SpendingBreakdownItem({
required this.category,
required this.hours,
required this.amount,
});
/// The category name (e.g., "Server Staff").
final String category;
/// The total hours worked in this category.
final int hours;
/// The total amount spent in this category.
final double amount;
@override
List<Object?> get props => <Object?>[category, hours, amount];
}

View File

@@ -5,13 +5,13 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_event.dart';
import '../blocs/billing_state.dart';
import '../widgets/billing_page_skeleton.dart';
import '../widgets/invoice_history_section.dart';
import '../widgets/pending_invoices_section.dart';
import '../widgets/spending_breakdown_card.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/billing_event.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
import 'package:billing/src/presentation/widgets/billing_page_skeleton.dart';
import 'package:billing/src/presentation/widgets/invoice_history_section.dart';
import 'package:billing/src/presentation/widgets/pending_invoices_section.dart';
import 'package:billing/src/presentation/widgets/spending_breakdown_card.dart';
/// The entry point page for the client billing feature.
///
@@ -32,8 +32,7 @@ class BillingPage extends StatelessWidget {
/// The main view for the client billing feature.
///
/// This widget displays the billing dashboard content based on the current
/// state of the [BillingBloc].
/// Displays the billing dashboard content based on the current [BillingState].
class BillingView extends StatefulWidget {
/// Creates a [BillingView].
const BillingView({super.key});
@@ -125,7 +124,7 @@ class _BillingViewState extends State<BillingView> {
),
const SizedBox(height: UiConstants.space1),
Text(
'\$${state.currentBill.toStringAsFixed(2)}',
'\$${state.currentBillDollars.toStringAsFixed(2)}',
style: UiTypography.displayM.copyWith(
color: UiColors.white,
fontSize: 40,
@@ -152,7 +151,8 @@ class _BillingViewState extends State<BillingView> {
const SizedBox(width: UiConstants.space2),
Text(
t.client_billing.saved_amount(
amount: state.savings.toStringAsFixed(0),
amount: state.savingsDollars
.toStringAsFixed(0),
),
style: UiTypography.footnote2b.copyWith(
color: UiColors.accentForeground,
@@ -221,7 +221,6 @@ class _BillingViewState extends State<BillingView> {
if (state.pendingInvoices.isNotEmpty) ...<Widget>[
PendingInvoicesSection(invoices: state.pendingInvoices),
],
// const PaymentMethodCard(),
const SpendingBreakdownCard(),
if (state.invoiceHistory.isNotEmpty)
InvoiceHistorySection(invoices: state.invoiceHistory),

View File

@@ -1,19 +1,21 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import '../models/billing_invoice_model.dart';
import '../widgets/completion_review/completion_review_actions.dart';
import '../widgets/completion_review/completion_review_amount.dart';
import '../widgets/completion_review/completion_review_info.dart';
import '../widgets/completion_review/completion_review_search_and_tabs.dart';
import '../widgets/completion_review/completion_review_worker_card.dart';
import '../widgets/completion_review/completion_review_workers_header.dart';
import 'package:billing/src/presentation/widgets/completion_review/completion_review_actions.dart';
import 'package:billing/src/presentation/widgets/completion_review/completion_review_amount.dart';
import 'package:billing/src/presentation/widgets/completion_review/completion_review_info.dart';
/// Page for reviewing and approving/disputing an invoice.
class ShiftCompletionReviewPage extends StatefulWidget {
/// Creates a [ShiftCompletionReviewPage].
const ShiftCompletionReviewPage({this.invoice, super.key});
final BillingInvoice? invoice;
/// The invoice to review.
final Invoice? invoice;
@override
State<ShiftCompletionReviewPage> createState() =>
@@ -21,31 +23,45 @@ class ShiftCompletionReviewPage extends StatefulWidget {
}
class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
late BillingInvoice invoice;
String searchQuery = '';
int selectedTab = 0; // 0: Needs Review (mocked as empty), 1: All
/// The resolved invoice, or null if route data is missing/invalid.
late final Invoice? invoice;
@override
void initState() {
super.initState();
// Use widget.invoice if provided, else try to get from arguments
invoice = widget.invoice ?? Modular.args.data as BillingInvoice;
invoice = widget.invoice ??
(Modular.args.data is Invoice
? Modular.args.data as Invoice
: null);
}
@override
Widget build(BuildContext context) {
final List<BillingWorkerRecord> filteredWorkers = invoice.workers.where((
BillingWorkerRecord w,
) {
if (searchQuery.isEmpty) return true;
return w.workerName.toLowerCase().contains(searchQuery.toLowerCase()) ||
w.roleName.toLowerCase().contains(searchQuery.toLowerCase());
}).toList();
final Invoice? resolvedInvoice = invoice;
if (resolvedInvoice == null) {
return Scaffold(
appBar: UiAppBar(
title: t.client_billing.review_and_approve,
showBackButton: true,
),
body: Center(
child: Text(
t.errors.generic.unknown,
style: UiTypography.body1m.textError,
),
),
);
}
final DateFormat formatter = DateFormat('EEEE, MMMM d');
final String dateLabel = resolvedInvoice.dueDate != null
? formatter.format(resolvedInvoice.dueDate!)
: 'N/A';
return Scaffold(
appBar: UiAppBar(
title: invoice.title,
subtitle: invoice.clientName,
title: resolvedInvoice.invoiceNumber,
subtitle: resolvedInvoice.vendorName ?? '',
showBackButton: true,
),
body: SafeArea(
@@ -55,26 +71,13 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const SizedBox(height: UiConstants.space4),
CompletionReviewInfo(invoice: invoice),
CompletionReviewInfo(
dateLabel: dateLabel,
vendorName: resolvedInvoice.vendorName,
),
const SizedBox(height: UiConstants.space4),
CompletionReviewAmount(invoice: invoice),
CompletionReviewAmount(amountCents: resolvedInvoice.amountCents),
const SizedBox(height: UiConstants.space6),
// CompletionReviewWorkersHeader(workersCount: invoice.workersCount),
// const SizedBox(height: UiConstants.space4),
// CompletionReviewSearchAndTabs(
// selectedTab: selectedTab,
// workersCount: invoice.workersCount,
// onTabChanged: (int index) =>
// setState(() => selectedTab = index),
// onSearchChanged: (String val) =>
// setState(() => searchQuery = val),
// ),
// const SizedBox(height: UiConstants.space4),
// ...filteredWorkers.map(
// (BillingWorkerRecord worker) =>
// CompletionReviewWorkerCard(worker: worker),
// ),
// const SizedBox(height: UiConstants.space4),
],
),
),
@@ -87,7 +90,9 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
),
),
child: SafeArea(child: CompletionReviewActions(invoiceId: invoice.id)),
child: SafeArea(
child: CompletionReviewActions(invoiceId: resolvedInvoice.invoiceId),
),
),
);
}

View File

@@ -2,14 +2,17 @@ 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';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_event.dart';
import '../blocs/billing_state.dart';
import '../models/billing_invoice_model.dart';
import '../widgets/invoices_list_skeleton.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/billing_event.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
import 'package:billing/src/presentation/widgets/invoices_list_skeleton.dart';
/// Page displaying invoices that are ready.
class InvoiceReadyPage extends StatelessWidget {
/// Creates an [InvoiceReadyPage].
const InvoiceReadyPage({super.key});
@override
@@ -21,7 +24,9 @@ class InvoiceReadyPage extends StatelessWidget {
}
}
/// View for the invoice ready page.
class InvoiceReadyView extends StatelessWidget {
/// Creates an [InvoiceReadyView].
const InvoiceReadyView({super.key});
@override
@@ -60,7 +65,7 @@ class InvoiceReadyView extends StatelessWidget {
separatorBuilder: (BuildContext context, int index) =>
const SizedBox(height: 16),
itemBuilder: (BuildContext context, int index) {
final BillingInvoice invoice = state.invoiceHistory[index];
final Invoice invoice = state.invoiceHistory[index];
return _InvoiceSummaryCard(invoice: invoice);
},
);
@@ -72,10 +77,17 @@ class InvoiceReadyView extends StatelessWidget {
class _InvoiceSummaryCard extends StatelessWidget {
const _InvoiceSummaryCard({required this.invoice});
final BillingInvoice invoice;
final Invoice invoice;
@override
Widget build(BuildContext context) {
final DateFormat formatter = DateFormat('MMM d, yyyy');
final String dateLabel = invoice.dueDate != null
? formatter.format(invoice.dueDate!)
: 'N/A';
final double amountDollars = invoice.amountCents / 100.0;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
@@ -106,22 +118,26 @@ class _InvoiceSummaryCard extends StatelessWidget {
borderRadius: BorderRadius.circular(20),
),
child: Text(
'READY',
invoice.status.value.toUpperCase(),
style: UiTypography.titleUppercase4b.copyWith(
color: UiColors.success,
),
),
),
Text(invoice.date, style: UiTypography.footnote2r.textTertiary),
Text(dateLabel, style: UiTypography.footnote2r.textTertiary),
],
),
const SizedBox(height: 16),
Text(invoice.title, style: UiTypography.title2b.textPrimary),
const SizedBox(height: 8),
Text(
invoice.locationAddress,
style: UiTypography.body2r.textSecondary,
invoice.invoiceNumber,
style: UiTypography.title2b.textPrimary,
),
const SizedBox(height: 8),
if (invoice.vendorName != null)
Text(
invoice.vendorName!,
style: UiTypography.body2r.textSecondary,
),
const Divider(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -134,7 +150,7 @@ class _InvoiceSummaryCard extends StatelessWidget {
style: UiTypography.titleUppercase4m.textSecondary,
),
Text(
'\$${invoice.totalAmount.toStringAsFixed(2)}',
'\$${amountDollars.toStringAsFixed(2)}',
style: UiTypography.title2b.primary,
),
],

View File

@@ -5,12 +5,14 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_state.dart';
import '../widgets/invoices_list_skeleton.dart';
import '../widgets/pending_invoices_section.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
import 'package:billing/src/presentation/widgets/invoices_list_skeleton.dart';
import 'package:billing/src/presentation/widgets/pending_invoices_section.dart';
/// Page listing all invoices awaiting client approval.
class PendingInvoicesPage extends StatelessWidget {
/// Creates a [PendingInvoicesPage].
const PendingInvoicesPage({super.key});
@override
@@ -44,7 +46,7 @@ class PendingInvoicesPage extends StatelessWidget {
UiConstants.space5,
UiConstants.space5,
UiConstants.space5,
100, // Bottom padding for scroll clearance
100,
),
itemCount: state.pendingInvoices.length,
itemBuilder: (BuildContext context, int index) {
@@ -87,6 +89,3 @@ class PendingInvoicesPage extends StatelessWidget {
);
}
}
// We need to export the card widget from the section file if we want to reuse it,
// or move it to its own file. I'll move it to a shared file or just make it public in the section file.

View File

@@ -6,23 +6,26 @@ import 'package:flutter/material.dart';
class BillingHeader extends StatelessWidget {
/// Creates a [BillingHeader].
const BillingHeader({
required this.currentBill,
required this.savings,
required this.currentBillCents,
required this.savingsCents,
required this.onBack,
super.key,
});
/// The amount of the current bill.
final double currentBill;
/// The amount of the current bill in cents.
final int currentBillCents;
/// The amount saved in the current period.
final double savings;
/// The savings amount in cents.
final int savingsCents;
/// Callback when the back button is pressed.
final VoidCallback onBack;
@override
Widget build(BuildContext context) {
final double billDollars = currentBillCents / 100.0;
final double savingsDollars = savingsCents / 100.0;
return Container(
padding: EdgeInsets.fromLTRB(
UiConstants.space5,
@@ -54,10 +57,9 @@ class BillingHeader extends StatelessWidget {
),
const SizedBox(height: UiConstants.space1),
Text(
'\$${currentBill.toStringAsFixed(2)}',
'\$${billDollars.toStringAsFixed(2)}',
style: UiTypography.display1b.copyWith(color: UiColors.white),
),
const SizedBox(height: UiConstants.space2),
Container(
padding: const EdgeInsets.symmetric(
@@ -79,7 +81,7 @@ class BillingHeader extends StatelessWidget {
const SizedBox(width: UiConstants.space1),
Text(
t.client_billing.saved_amount(
amount: savings.toStringAsFixed(0),
amount: savingsDollars.toStringAsFixed(0),
),
style: UiTypography.footnote2b.copyWith(
color: UiColors.foreground,

View File

@@ -5,87 +5,91 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../../blocs/shift_completion_review/shift_completion_review_bloc.dart';
import '../../blocs/shift_completion_review/shift_completion_review_event.dart';
import '../../blocs/shift_completion_review/shift_completion_review_state.dart';
import '../../blocs/billing_bloc.dart';
import '../../blocs/billing_event.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/billing_event.dart';
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_event.dart';
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_state.dart';
/// Action buttons (approve / flag) at the bottom of the review page.
class CompletionReviewActions extends StatelessWidget {
/// Creates a [CompletionReviewActions].
const CompletionReviewActions({required this.invoiceId, super.key});
/// The invoice ID to act upon.
final String invoiceId;
@override
Widget build(BuildContext context) {
return BlocProvider<ShiftCompletionReviewBloc>.value(
value: Modular.get<ShiftCompletionReviewBloc>(),
return BlocProvider<ShiftCompletionReviewBloc>(
create: (_) => Modular.get<ShiftCompletionReviewBloc>(),
child:
BlocConsumer<ShiftCompletionReviewBloc, ShiftCompletionReviewState>(
listener: (BuildContext context, ShiftCompletionReviewState state) {
if (state.status == ShiftCompletionReviewStatus.success) {
final String message = state.message == 'approved'
? t.client_billing.approved_success
: t.client_billing.flagged_success;
final UiSnackbarType type = state.message == 'approved'
? UiSnackbarType.success
: UiSnackbarType.warning;
listener: (BuildContext context, ShiftCompletionReviewState state) {
if (state.status == ShiftCompletionReviewStatus.success) {
final String message = state.message == 'approved'
? t.client_billing.approved_success
: t.client_billing.flagged_success;
final UiSnackbarType type = state.message == 'approved'
? UiSnackbarType.success
: UiSnackbarType.warning;
UiSnackbar.show(context, message: message, type: type);
Modular.get<BillingBloc>().add(const BillingLoadStarted());
Modular.to.toAwaitingApproval();
} else if (state.status == ShiftCompletionReviewStatus.failure) {
UiSnackbar.show(
context,
message: state.errorMessage ?? t.errors.generic.unknown,
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, ShiftCompletionReviewState state) {
final bool isLoading =
state.status == ShiftCompletionReviewStatus.loading;
UiSnackbar.show(context, message: message, type: type);
Modular.get<BillingBloc>().add(const BillingLoadStarted());
Modular.to.toAwaitingApproval();
} else if (state.status == ShiftCompletionReviewStatus.failure) {
UiSnackbar.show(
context,
message: state.errorMessage ?? t.errors.generic.unknown,
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, ShiftCompletionReviewState state) {
final bool isLoading =
state.status == ShiftCompletionReviewStatus.loading;
return Row(
spacing: UiConstants.space2,
children: <Widget>[
Expanded(
child: UiButton.secondary(
text: t.client_billing.actions.flag_review,
leadingIcon: UiIcons.warning,
onPressed: isLoading
? null
: () => _showFlagDialog(context, state),
size: UiButtonSize.large,
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.destructive,
side: BorderSide.none,
),
),
return Row(
spacing: UiConstants.space2,
children: <Widget>[
Expanded(
child: UiButton.secondary(
text: t.client_billing.actions.flag_review,
leadingIcon: UiIcons.warning,
onPressed: isLoading
? null
: () => _showFlagDialog(context, state),
size: UiButtonSize.large,
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.destructive,
side: BorderSide.none,
),
Expanded(
child: UiButton.primary(
text: t.client_billing.actions.approve_pay,
leadingIcon: isLoading ? null : UiIcons.checkCircle,
isLoading: isLoading,
onPressed: isLoading
? null
: () {
BlocProvider.of<ShiftCompletionReviewBloc>(
context,
).add(ShiftCompletionReviewApproved(invoiceId));
},
size: UiButtonSize.large,
),
),
],
);
},
),
),
),
Expanded(
child: UiButton.primary(
text: t.client_billing.actions.approve_pay,
leadingIcon: isLoading ? null : UiIcons.checkCircle,
isLoading: isLoading,
onPressed: isLoading
? null
: () {
BlocProvider.of<ShiftCompletionReviewBloc>(
context,
).add(ShiftCompletionReviewApproved(invoiceId));
},
size: UiButtonSize.large,
),
),
],
);
},
),
);
}
void _showFlagDialog(BuildContext context, ShiftCompletionReviewState state) {
void _showFlagDialog(
BuildContext context, ShiftCompletionReviewState state) {
final TextEditingController controller = TextEditingController();
showDialog(
context: context,

View File

@@ -2,15 +2,18 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../models/billing_invoice_model.dart';
/// Displays the total invoice amount on the review page.
class CompletionReviewAmount extends StatelessWidget {
const CompletionReviewAmount({required this.invoice, super.key});
/// Creates a [CompletionReviewAmount].
const CompletionReviewAmount({required this.amountCents, super.key});
final BillingInvoice invoice;
/// The invoice total in cents.
final int amountCents;
@override
Widget build(BuildContext context) {
final double amountDollars = amountCents / 100.0;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(UiConstants.space6),
@@ -27,13 +30,9 @@ class CompletionReviewAmount extends StatelessWidget {
),
const SizedBox(height: UiConstants.space1),
Text(
'\$${invoice.totalAmount.toStringAsFixed(2)}',
'\$${amountDollars.toStringAsFixed(2)}',
style: UiTypography.headline1b.textPrimary.copyWith(fontSize: 40),
),
Text(
'${invoice.totalHours.toStringAsFixed(1)} ${t.client_billing.hours_suffix}\$${(invoice.totalAmount / (invoice.totalHours > 0.1 ? invoice.totalHours : 1)).toStringAsFixed(2)}${t.client_billing.avg_rate_suffix}',
style: UiTypography.footnote2b.textSecondary,
),
],
),
);

View File

@@ -1,12 +1,20 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../models/billing_invoice_model.dart';
/// Displays invoice metadata (date, vendor) on the review page.
class CompletionReviewInfo extends StatelessWidget {
const CompletionReviewInfo({required this.invoice, super.key});
/// Creates a [CompletionReviewInfo].
const CompletionReviewInfo({
required this.dateLabel,
this.vendorName,
super.key,
});
final BillingInvoice invoice;
/// Formatted date string.
final String dateLabel;
/// Vendor name, if available.
final String? vendorName;
@override
Widget build(BuildContext context) {
@@ -14,12 +22,9 @@ class CompletionReviewInfo extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space1,
children: <Widget>[
_buildInfoRow(UiIcons.calendar, invoice.date),
_buildInfoRow(
UiIcons.clock,
'${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}',
),
_buildInfoRow(UiIcons.mapPin, invoice.locationAddress),
_buildInfoRow(UiIcons.calendar, dateLabel),
if (vendorName != null)
_buildInfoRow(UiIcons.building, vendorName!),
],
);
}

View File

@@ -1,126 +1,18 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../models/billing_invoice_model.dart';
/// Card showing a single worker's details in the completion review.
///
/// Currently unused -- the V2 Invoice entity does not include per-worker
/// breakdown data. This widget is retained as a placeholder for when the
/// backend adds worker-level invoice detail endpoints.
class CompletionReviewWorkerCard extends StatelessWidget {
const CompletionReviewWorkerCard({required this.worker, super.key});
final BillingWorkerRecord worker;
/// Creates a [CompletionReviewWorkerCard].
const CompletionReviewWorkerCard({super.key});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border.withValues(alpha: 0.5)),
),
child: Column(
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
CircleAvatar(
radius: 20,
backgroundColor: UiColors.bgSecondary,
backgroundImage: worker.workerAvatarUrl != null
? NetworkImage(worker.workerAvatarUrl!)
: null,
child: worker.workerAvatarUrl == null
? const Icon(
UiIcons.user,
size: 20,
color: UiColors.iconSecondary,
)
: null,
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
worker.workerName,
style: UiTypography.body1b.textPrimary,
),
Text(
worker.roleName,
style: UiTypography.footnote2r.textSecondary,
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
'\$${worker.totalAmount.toStringAsFixed(2)}',
style: UiTypography.body1b.textPrimary,
),
Text(
'${worker.hours}h x \$${worker.rate.toStringAsFixed(2)}/hr',
style: UiTypography.footnote2r.textSecondary,
),
],
),
],
),
const SizedBox(height: UiConstants.space4),
Row(
children: <Widget>[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: Text(
'${worker.startTime} - ${worker.endTime}',
style: UiTypography.footnote2b.textPrimary,
),
),
const SizedBox(width: UiConstants.space2),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: Row(
children: <Widget>[
const Icon(
UiIcons.coffee,
size: 12,
color: UiColors.iconSecondary,
),
const SizedBox(width: 4),
Text(
'${worker.breakMinutes} ${t.client_billing.workers_tab.min_break}',
style: UiTypography.footnote2r.textSecondary,
),
],
),
),
const Spacer(),
UiIconButton.secondary(icon: UiIcons.edit, onTap: () {}),
const SizedBox(width: UiConstants.space2),
UiIconButton.secondary(icon: UiIcons.warning, onTap: () {}),
],
),
],
),
);
// Placeholder until V2 API provides worker-level invoice data.
return const SizedBox.shrink();
}
}

View File

@@ -1,7 +1,8 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../models/billing_invoice_model.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
/// Section showing the history of paid invoices.
class InvoiceHistorySection extends StatelessWidget {
@@ -9,7 +10,7 @@ class InvoiceHistorySection extends StatelessWidget {
const InvoiceHistorySection({required this.invoices, super.key});
/// The list of historical invoices.
final List<BillingInvoice> invoices;
final List<Invoice> invoices;
@override
Widget build(BuildContext context) {
@@ -36,10 +37,10 @@ class InvoiceHistorySection extends StatelessWidget {
),
child: Column(
children: invoices.asMap().entries.map((
MapEntry<int, BillingInvoice> entry,
MapEntry<int, Invoice> entry,
) {
final int index = entry.key;
final BillingInvoice invoice = entry.value;
final Invoice invoice = entry.value;
return Column(
children: <Widget>[
if (index > 0)
@@ -58,10 +59,18 @@ class InvoiceHistorySection extends StatelessWidget {
class _InvoiceItem extends StatelessWidget {
const _InvoiceItem({required this.invoice});
final BillingInvoice invoice;
final Invoice invoice;
@override
Widget build(BuildContext context) {
final DateFormat formatter = DateFormat('MMM d, yyyy');
final String dateLabel = invoice.paymentDate != null
? formatter.format(invoice.paymentDate!)
: invoice.dueDate != null
? formatter.format(invoice.dueDate!)
: 'N/A';
final double amountDollars = invoice.amountCents / 100.0;
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
@@ -86,11 +95,11 @@ class _InvoiceItem extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(invoice.title, style: UiTypography.body1r.textPrimary),
Text(
invoice.date,
style: UiTypography.footnote2r.textSecondary,
invoice.invoiceNumber,
style: UiTypography.body1r.textPrimary,
),
Text(dateLabel, style: UiTypography.footnote2r.textSecondary),
],
),
),
@@ -98,7 +107,7 @@ class _InvoiceItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
'\$${invoice.totalAmount.toStringAsFixed(2)}',
'\$${amountDollars.toStringAsFixed(2)}',
style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15),
),
_StatusBadge(status: invoice.status),
@@ -113,11 +122,11 @@ class _InvoiceItem extends StatelessWidget {
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.status});
final String status;
final InvoiceStatus status;
@override
Widget build(BuildContext context) {
final bool isPaid = status.toUpperCase() == 'PAID';
final bool isPaid = status == InvoiceStatus.paid;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space1 + 2,

View File

@@ -3,8 +3,9 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_state.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
/// Card showing the current payment method.
class PaymentMethodCard extends StatelessWidget {
@@ -15,8 +16,8 @@ class PaymentMethodCard extends StatelessWidget {
Widget build(BuildContext context) {
return BlocBuilder<BillingBloc, BillingState>(
builder: (BuildContext context, BillingState state) {
final List<BusinessBankAccount> accounts = state.bankAccounts;
final BusinessBankAccount? account =
final List<BillingAccount> accounts = state.bankAccounts;
final BillingAccount? account =
accounts.isNotEmpty ? accounts.first : null;
if (account == null) {
@@ -24,11 +25,10 @@ class PaymentMethodCard extends StatelessWidget {
}
final String bankLabel =
account.bankName.isNotEmpty == true ? account.bankName : '----';
account.bankName.isNotEmpty ? account.bankName : '----';
final String last4 =
account.last4.isNotEmpty == true ? account.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),
@@ -87,11 +87,11 @@ class PaymentMethodCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'•••• $last4',
'\u2022\u2022\u2022\u2022 $last4',
style: UiTypography.body2b.textPrimary,
),
Text(
t.client_billing.expires(date: expiryLabel),
account.accountType.name.toUpperCase(),
style: UiTypography.footnote2r.textSecondary,
),
],
@@ -121,13 +121,4 @@ class PaymentMethodCard extends StatelessWidget {
},
);
}
String _formatExpiry(DateTime? expiryTime) {
if (expiryTime == null) {
return 'N/A';
}
final String month = expiryTime.month.toString().padLeft(2, '0');
final String year = (expiryTime.year % 100).toString().padLeft(2, '0');
return '$month/$year';
}
}

View File

@@ -2,9 +2,9 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import '../models/billing_invoice_model.dart';
import 'package:krow_domain/krow_domain.dart';
/// Section showing a banner for invoices awaiting approval.
class PendingInvoicesSection extends StatelessWidget {
@@ -12,7 +12,7 @@ class PendingInvoicesSection extends StatelessWidget {
const PendingInvoicesSection({required this.invoices, super.key});
/// The list of pending invoices.
final List<BillingInvoice> invoices;
final List<Invoice> invoices;
@override
Widget build(BuildContext context) {
@@ -93,10 +93,17 @@ class PendingInvoiceCard extends StatelessWidget {
/// Creates a [PendingInvoiceCard].
const PendingInvoiceCard({required this.invoice, super.key});
final BillingInvoice invoice;
/// The invoice to display.
final Invoice invoice;
@override
Widget build(BuildContext context) {
final DateFormat formatter = DateFormat('EEEE, MMMM d');
final String dateLabel = invoice.dueDate != null
? formatter.format(invoice.dueDate!)
: 'N/A';
final double amountDollars = invoice.amountCents / 100.0;
return Container(
decoration: BoxDecoration(
color: UiColors.white,
@@ -108,42 +115,33 @@ class PendingInvoiceCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(invoice.title, style: UiTypography.headline4b.textPrimary),
Text(
invoice.invoiceNumber,
style: UiTypography.headline4b.textPrimary,
),
const SizedBox(height: UiConstants.space3),
Row(
children: <Widget>[
const Icon(
UiIcons.mapPin,
size: 16,
color: UiColors.iconSecondary,
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Text(
invoice.locationAddress,
style: UiTypography.footnote2r.textSecondary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
if (invoice.vendorName != null) ...<Widget>[
Row(
children: <Widget>[
const Icon(
UiIcons.building,
size: 16,
color: UiColors.iconSecondary,
),
),
],
),
const SizedBox(height: UiConstants.space2),
Row(
children: <Widget>[
Text(
invoice.clientName,
style: UiTypography.footnote2r.textSecondary,
),
const SizedBox(width: UiConstants.space2),
Text('', style: UiTypography.footnote2r.textInactive),
const SizedBox(width: UiConstants.space2),
Text(
invoice.date,
style: UiTypography.footnote2r.textSecondary,
),
],
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Text(
invoice.vendorName!,
style: UiTypography.footnote2r.textSecondary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: UiConstants.space2),
],
Text(dateLabel, style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: UiConstants.space3),
Row(
children: <Widget>[
@@ -157,7 +155,7 @@ class PendingInvoiceCard extends StatelessWidget {
),
const SizedBox(width: UiConstants.space2),
Text(
t.client_billing.pending_badge.toUpperCase(),
invoice.status.value.toUpperCase(),
style: UiTypography.titleUppercase4b.copyWith(
color: UiColors.textWarning,
),
@@ -168,40 +166,10 @@ class PendingInvoiceCard extends StatelessWidget {
const Divider(height: 1, color: UiColors.border),
Padding(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
child: Row(
children: <Widget>[
Expanded(
child: _buildStatItem(
UiIcons.dollar,
'\$${invoice.totalAmount.toStringAsFixed(2)}',
t.client_billing.stats.total,
),
),
Container(
width: 1,
height: 32,
color: UiColors.border.withValues(alpha: 0.3),
),
Expanded(
child: _buildStatItem(
UiIcons.users,
'${invoice.workersCount}',
t.client_billing.stats.workers,
),
),
Container(
width: 1,
height: 32,
color: UiColors.border.withValues(alpha: 0.3),
),
Expanded(
child: _buildStatItem(
UiIcons.clock,
invoice.totalHours.toStringAsFixed(1),
t.client_billing.stats.hrs,
),
),
],
child: _buildStatItem(
UiIcons.dollar,
'\$${amountDollars.toStringAsFixed(2)}',
t.client_billing.stats.total,
),
),
const Divider(height: 1, color: UiColors.border),

View File

@@ -3,10 +3,10 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_state.dart';
import '../blocs/billing_event.dart';
import '../models/spending_breakdown_model.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/billing_event.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
/// Card showing the spending breakdown for the current period.
class SpendingBreakdownCard extends StatefulWidget {
@@ -37,10 +37,7 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
Widget build(BuildContext context) {
return BlocBuilder<BillingBloc, BillingState>(
builder: (BuildContext context, BillingState state) {
final double total = state.spendingBreakdown.fold(
0.0,
(double sum, SpendingBreakdownItem item) => sum + item.amount,
);
final double totalDollars = state.spendTotalCents / 100.0;
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
@@ -97,11 +94,12 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
),
dividerColor: UiColors.transparent,
onTap: (int index) {
final BillingPeriod period =
index == 0 ? BillingPeriod.week : BillingPeriod.month;
ReadContext(context).read<BillingBloc>().add(
BillingPeriodChanged(period),
);
final BillingPeriodTab tab = index == 0
? BillingPeriodTab.week
: BillingPeriodTab.month;
ReadContext(context)
.read<BillingBloc>()
.add(BillingPeriodChanged(tab));
},
tabs: <Widget>[
Tab(text: t.client_billing.week),
@@ -112,8 +110,8 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
],
),
const SizedBox(height: UiConstants.space4),
...state.spendingBreakdown.map(
(SpendingBreakdownItem item) => _buildBreakdownRow(item),
...state.spendBreakdown.map(
(SpendItem item) => _buildBreakdownRow(item),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: UiConstants.space2),
@@ -127,7 +125,7 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
style: UiTypography.body2b.textPrimary,
),
Text(
'\$${total.toStringAsFixed(2)}',
'\$${totalDollars.toStringAsFixed(2)}',
style: UiTypography.body2b.textPrimary,
),
],
@@ -139,7 +137,8 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
);
}
Widget _buildBreakdownRow(SpendingBreakdownItem item) {
Widget _buildBreakdownRow(SpendItem item) {
final double amountDollars = item.amountCents / 100.0;
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space2),
child: Row(
@@ -151,14 +150,14 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
children: <Widget>[
Text(item.category, style: UiTypography.body2r.textPrimary),
Text(
t.client_billing.hours(count: item.hours),
'${item.percentage.toStringAsFixed(1)}%',
style: UiTypography.footnote2r.textSecondary,
),
],
),
),
Text(
'\$${item.amount.toStringAsFixed(2)}',
'\$${amountDollars.toStringAsFixed(2)}',
style: UiTypography.body2m.textPrimary,
),
],

View File

@@ -10,12 +10,12 @@ environment:
dependencies:
flutter:
sdk: flutter
# Architecture
flutter_modular: ^6.3.2
flutter_bloc: ^8.1.3
equatable: ^2.0.5
# Shared packages
design_system:
path: ../../../design_system
@@ -25,12 +25,10 @@ dependencies:
path: ../../../domain
krow_core:
path: ../../../core
krow_data_connect:
path: ../../../data_connect
# UI
intl: ^0.20.0
firebase_data_connect: ^0.2.2+1
dev_dependencies:
flutter_test:
sdk: flutter

View File

@@ -1,26 +1,35 @@
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/coverage_repository_impl.dart';
import 'domain/repositories/coverage_repository.dart';
import 'domain/usecases/get_coverage_stats_usecase.dart';
import 'domain/usecases/get_shifts_for_date_usecase.dart';
import 'presentation/blocs/coverage_bloc.dart';
import 'presentation/pages/coverage_page.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:client_coverage/src/data/repositories_impl/coverage_repository_impl.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
import 'package:client_coverage/src/domain/usecases/cancel_late_worker_usecase.dart';
import 'package:client_coverage/src/domain/usecases/get_coverage_stats_usecase.dart';
import 'package:client_coverage/src/domain/usecases/get_shifts_for_date_usecase.dart';
import 'package:client_coverage/src/domain/usecases/submit_worker_review_usecase.dart';
import 'package:client_coverage/src/presentation/blocs/coverage_bloc.dart';
import 'package:client_coverage/src/presentation/pages/coverage_page.dart';
/// Modular module for the coverage feature.
///
/// Uses the V2 REST API via [BaseApiService] for all backend access.
class CoverageModule extends Module {
@override
List<Module> get imports => <Module>[DataConnectModule()];
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<CoverageRepository>(CoverageRepositoryImpl.new);
i.addLazySingleton<CoverageRepository>(
() => CoverageRepositoryImpl(apiService: i.get<BaseApiService>()),
);
// Use Cases
i.addLazySingleton(GetShiftsForDateUseCase.new);
i.addLazySingleton(GetCoverageStatsUseCase.new);
i.addLazySingleton(SubmitWorkerReviewUseCase.new);
i.addLazySingleton(CancelLateWorkerUseCase.new);
// BLoCs
i.addLazySingleton<CoverageBloc>(CoverageBloc.new);
@@ -28,7 +37,9 @@ class CoverageModule extends Module {
@override
void routes(RouteManager r) {
r.child(ClientPaths.childRoute(ClientPaths.coverage, ClientPaths.coverage),
child: (_) => const CoveragePage());
r.child(
ClientPaths.childRoute(ClientPaths.coverage, ClientPaths.coverage),
child: (_) => const CoveragePage(),
);
}
}

View File

@@ -1,62 +1,89 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/coverage_repository.dart';
/// Implementation of [CoverageRepository] that delegates to [dc.CoverageConnectorRepository].
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
/// V2 API implementation of [CoverageRepository].
///
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
/// connector repository from the data_connect package.
/// Uses [BaseApiService] with [V2ApiEndpoints] for all backend access.
class CoverageRepositoryImpl implements CoverageRepository {
/// Creates a [CoverageRepositoryImpl].
CoverageRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
CoverageRepositoryImpl({
dc.CoverageConnectorRepository? connectorRepository,
dc.DataConnectService? service,
}) : _connectorRepository = connectorRepository ??
dc.DataConnectService.instance.getCoverageRepository(),
_service = service ?? dc.DataConnectService.instance;
final dc.CoverageConnectorRepository _connectorRepository;
final dc.DataConnectService _service;
final BaseApiService _apiService;
@override
Future<List<CoverageShift>> getShiftsForDate({required DateTime date}) async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.getShiftsForDate(
businessId: businessId,
date: date,
Future<List<ShiftWithWorkers>> getShiftsForDate({
required DateTime date,
}) async {
final String dateStr =
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.clientCoverage,
params: <String, dynamic>{'date': dateStr},
);
final List<dynamic> items = response.data['items'] as List<dynamic>;
return items
.map((dynamic e) =>
ShiftWithWorkers.fromJson(e as Map<String, dynamic>))
.toList();
}
@override
Future<CoverageStats> getCoverageStats({required DateTime date}) async {
final List<CoverageShift> shifts = await getShiftsForDate(date: date);
final int totalNeeded = shifts.fold<int>(
0,
(int sum, CoverageShift shift) => sum + shift.workersNeeded,
final String dateStr =
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.clientCoverageStats,
params: <String, dynamic>{'date': dateStr},
);
return CoverageStats.fromJson(response.data as Map<String, dynamic>);
}
final List<CoverageWorker> allWorkers =
shifts.expand((CoverageShift shift) => shift.workers).toList();
final int totalConfirmed = allWorkers.length;
final int checkedIn = allWorkers
.where((CoverageWorker w) => w.status == CoverageWorkerStatus.checkedIn)
.length;
final int enRoute = allWorkers
.where((CoverageWorker w) =>
w.status == CoverageWorkerStatus.confirmed && w.checkInTime == null)
.length;
final int late = allWorkers
.where((CoverageWorker w) => w.status == CoverageWorkerStatus.late)
.length;
@override
Future<void> submitWorkerReview({
required String staffId,
required int rating,
String? assignmentId,
String? feedback,
List<String>? issueFlags,
bool? markAsFavorite,
}) async {
final Map<String, dynamic> body = <String, dynamic>{
'staffId': staffId,
'rating': rating,
};
if (assignmentId != null) {
body['assignmentId'] = assignmentId;
}
if (feedback != null) {
body['feedback'] = feedback;
}
if (issueFlags != null && issueFlags.isNotEmpty) {
body['issueFlags'] = issueFlags;
}
if (markAsFavorite != null) {
body['markAsFavorite'] = markAsFavorite;
}
await _apiService.post(
V2ApiEndpoints.clientCoverageReviews,
data: body,
);
}
return CoverageStats(
totalNeeded: totalNeeded,
totalConfirmed: totalConfirmed,
checkedIn: checkedIn,
enRoute: enRoute,
late: late,
@override
Future<void> cancelLateWorker({
required String assignmentId,
String? reason,
}) async {
final Map<String, dynamic> body = <String, dynamic>{};
if (reason != null) {
body['reason'] = reason;
}
await _apiService.post(
V2ApiEndpoints.clientCoverageCancelLateWorker(assignmentId),
data: body,
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:krow_core/core.dart';
/// Arguments for cancelling a late worker's assignment.
class CancelLateWorkerArguments extends UseCaseArgument {
/// Creates [CancelLateWorkerArguments].
const CancelLateWorkerArguments({
required this.assignmentId,
this.reason,
});
/// The assignment ID to cancel.
final String assignmentId;
/// Optional cancellation reason.
final String? reason;
@override
List<Object?> get props => <Object?>[assignmentId, reason];
}

View File

@@ -1,9 +1,6 @@
import 'package:krow_core/core.dart';
/// Arguments for fetching coverage statistics for a specific date.
///
/// This argument class encapsulates the date parameter required by
/// the [GetCoverageStatsUseCase].
class GetCoverageStatsArguments extends UseCaseArgument {
/// Creates [GetCoverageStatsArguments].
const GetCoverageStatsArguments({required this.date});

View File

@@ -1,9 +1,6 @@
import 'package:krow_core/core.dart';
/// Arguments for fetching shifts for a specific date.
///
/// This argument class encapsulates the date parameter required by
/// the [GetShiftsForDateUseCase].
class GetShiftsForDateArguments extends UseCaseArgument {
/// Creates [GetShiftsForDateArguments].
const GetShiftsForDateArguments({required this.date});

View File

@@ -0,0 +1,42 @@
import 'package:krow_core/core.dart';
/// Arguments for submitting a worker review from the coverage page.
class SubmitWorkerReviewArguments extends UseCaseArgument {
/// Creates [SubmitWorkerReviewArguments].
const SubmitWorkerReviewArguments({
required this.staffId,
required this.rating,
this.assignmentId,
this.feedback,
this.issueFlags,
this.markAsFavorite,
});
/// The ID of the worker being reviewed.
final String staffId;
/// The rating value (1-5).
final int rating;
/// The assignment ID, if reviewing for a specific assignment.
final String? assignmentId;
/// Optional text feedback.
final String? feedback;
/// Optional list of issue flag labels.
final List<String>? issueFlags;
/// Whether to mark/unmark the worker as a favorite.
final bool? markAsFavorite;
@override
List<Object?> get props => <Object?>[
staffId,
rating,
assignmentId,
feedback,
issueFlags,
markAsFavorite,
];
}

View File

@@ -2,22 +2,35 @@ import 'package:krow_domain/krow_domain.dart';
/// Repository interface for coverage-related operations.
///
/// This interface defines the contract for accessing coverage data,
/// Defines the contract for accessing coverage data via the V2 REST API,
/// acting as a boundary between the Domain and Data layers.
/// It allows the Domain layer to remain independent of specific data sources.
///
/// Implementation of this interface must delegate all data access through
/// the `packages/data_connect` layer, ensuring compliance with Clean Architecture.
abstract interface class CoverageRepository {
/// Fetches shifts for a specific date.
///
/// Returns a list of [CoverageShift] entities representing all shifts
/// scheduled for the given [date].
Future<List<CoverageShift>> getShiftsForDate({required DateTime date});
/// Fetches shifts with assigned workers for a specific [date].
Future<List<ShiftWithWorkers>> getShiftsForDate({required DateTime date});
/// Fetches coverage statistics for a specific date.
///
/// Returns [CoverageStats] containing aggregated metrics including
/// total workers needed, confirmed, checked in, en route, and late.
/// Fetches aggregated coverage statistics for a specific [date].
Future<CoverageStats> getCoverageStats({required DateTime date});
/// Submits a worker review from the coverage page.
///
/// [staffId] identifies the worker being reviewed.
/// [rating] is an integer from 1 to 5.
/// Optional fields: [assignmentId], [feedback], [issueFlags], [markAsFavorite].
Future<void> submitWorkerReview({
required String staffId,
required int rating,
String? assignmentId,
String? feedback,
List<String>? issueFlags,
bool? markAsFavorite,
});
/// Cancels a late worker's assignment.
///
/// [assignmentId] identifies the assignment to cancel.
/// [reason] is an optional cancellation reason.
Future<void> cancelLateWorker({
required String assignmentId,
String? reason,
});
}

View File

@@ -0,0 +1,23 @@
import 'package:krow_core/core.dart';
import 'package:client_coverage/src/domain/arguments/cancel_late_worker_arguments.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
/// Use case for cancelling a late worker's assignment.
///
/// Delegates to [CoverageRepository] to cancel the assignment via V2 API.
class CancelLateWorkerUseCase
implements UseCase<CancelLateWorkerArguments, void> {
/// Creates a [CancelLateWorkerUseCase].
CancelLateWorkerUseCase(this._repository);
final CoverageRepository _repository;
@override
Future<void> call(CancelLateWorkerArguments arguments) {
return _repository.cancelLateWorker(
assignmentId: arguments.assignmentId,
reason: arguments.reason,
);
}
}

View File

@@ -1,20 +1,12 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../arguments/get_coverage_stats_arguments.dart';
import '../repositories/coverage_repository.dart';
import 'package:client_coverage/src/domain/arguments/get_coverage_stats_arguments.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
/// Use case for fetching coverage statistics for a specific date.
/// Use case for fetching aggregated coverage statistics for a specific date.
///
/// This use case encapsulates the logic for retrieving coverage metrics including
/// total workers needed, confirmed, checked in, en route, and late.
/// It delegates the data retrieval to the [CoverageRepository].
///
/// Follows the KROW Clean Architecture pattern by:
/// - Extending from [UseCase] base class
/// - Using [GetCoverageStatsArguments] for input
/// - Returning domain entities ([CoverageStats])
/// - Delegating to repository abstraction
/// Delegates to [CoverageRepository] and returns a [CoverageStats] entity.
class GetCoverageStatsUseCase
implements UseCase<GetCoverageStatsArguments, CoverageStats> {
/// Creates a [GetCoverageStatsUseCase].

View File

@@ -1,27 +1,21 @@
import 'package:krow_core/core.dart';
import '../arguments/get_shifts_for_date_arguments.dart';
import '../repositories/coverage_repository.dart';
import 'package:krow_domain/krow_domain.dart';
/// Use case for fetching shifts for a specific date.
import 'package:client_coverage/src/domain/arguments/get_shifts_for_date_arguments.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
/// Use case for fetching shifts with workers for a specific date.
///
/// This use case encapsulates the logic for retrieving all shifts scheduled for a given date.
/// It delegates the data retrieval to the [CoverageRepository].
///
/// Follows the KROW Clean Architecture pattern by:
/// - Extending from [UseCase] base class
/// - Using [GetShiftsForDateArguments] for input
/// - Returning domain entities ([CoverageShift])
/// - Delegating to repository abstraction
/// Delegates to [CoverageRepository] and returns V2 [ShiftWithWorkers] entities.
class GetShiftsForDateUseCase
implements UseCase<GetShiftsForDateArguments, List<CoverageShift>> {
implements UseCase<GetShiftsForDateArguments, List<ShiftWithWorkers>> {
/// Creates a [GetShiftsForDateUseCase].
GetShiftsForDateUseCase(this._repository);
final CoverageRepository _repository;
@override
Future<List<CoverageShift>> call(GetShiftsForDateArguments arguments) {
Future<List<ShiftWithWorkers>> call(GetShiftsForDateArguments arguments) {
return _repository.getShiftsForDate(date: arguments.date);
}
}

View File

@@ -0,0 +1,30 @@
import 'package:krow_core/core.dart';
import 'package:client_coverage/src/domain/arguments/submit_worker_review_arguments.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
/// Use case for submitting a worker review from the coverage page.
///
/// Validates the rating range and delegates to [CoverageRepository].
class SubmitWorkerReviewUseCase
implements UseCase<SubmitWorkerReviewArguments, void> {
/// Creates a [SubmitWorkerReviewUseCase].
SubmitWorkerReviewUseCase(this._repository);
final CoverageRepository _repository;
@override
Future<void> call(SubmitWorkerReviewArguments arguments) async {
if (arguments.rating < 1 || arguments.rating > 5) {
throw ArgumentError('Rating must be between 1 and 5');
}
return _repository.submitWorkerReview(
staffId: arguments.staffId,
rating: arguments.rating,
assignmentId: arguments.assignmentId,
feedback: arguments.feedback,
issueFlags: arguments.issueFlags,
markAsFavorite: arguments.markAsFavorite,
);
}
}

Some files were not shown because too many files have changed in this diff Show More