Merge 592-migrate-frontend-applications-to-v2-backend-and-database into feature/session-persistence-new
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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"]];
|
||||
|
||||
@@ -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,15 +30,6 @@ void main() async {
|
||||
logStateChanges: false, // Set to true for verbose debugging
|
||||
);
|
||||
|
||||
// Initialize session listener for Firebase Auth state changes
|
||||
DataConnectService.instance.initializeAuthListener(
|
||||
allowedRoles: <String>[
|
||||
'CLIENT',
|
||||
'BUSINESS',
|
||||
'BOTH',
|
||||
], // Only allow users with CLIENT, BUSINESS, or BOTH roles
|
||||
);
|
||||
|
||||
runApp(
|
||||
ModularApp(
|
||||
module: AppModule(),
|
||||
|
||||
@@ -3,7 +3,7 @@ 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';
|
||||
import 'package:krow_domain/krow_domain.dart' show UserRole;
|
||||
|
||||
/// A widget that listens to session state changes and handles global reactions.
|
||||
///
|
||||
@@ -28,11 +28,20 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupSessionListener();
|
||||
_initializeSession();
|
||||
}
|
||||
|
||||
void _setupSessionListener() {
|
||||
_sessionSubscription = DataConnectService.instance.onSessionStateChanged
|
||||
void _initializeSession() {
|
||||
// Resolve V2SessionService via DI — this triggers CoreModule's lazy
|
||||
// singleton, which wires setApiService(). Must happen before
|
||||
// initializeAuthListener so the session endpoint is reachable.
|
||||
final V2SessionService sessionService = Modular.get<V2SessionService>();
|
||||
|
||||
sessionService.initializeAuthListener(
|
||||
allowedRoles: const <UserRole>[UserRole.business, UserRole.both],
|
||||
);
|
||||
|
||||
_sessionSubscription = sessionService.onSessionStateChanged
|
||||
.listen((SessionState state) {
|
||||
_handleSessionChange(state);
|
||||
});
|
||||
@@ -134,7 +143,7 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Modular.to.popSafe();;
|
||||
Modular.to.popSafe();
|
||||
_proceedToLogin();
|
||||
},
|
||||
child: const Text('Log Out'),
|
||||
@@ -147,8 +156,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();
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -41,7 +41,6 @@ dependencies:
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
firebase_core: ^4.4.0
|
||||
krow_data_connect: ^0.0.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -94,12 +82,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"]];
|
||||
|
||||
@@ -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,14 +28,6 @@ void main() async {
|
||||
logStateChanges: false, // Set to true for verbose debugging
|
||||
);
|
||||
|
||||
// Initialize session listener for Firebase Auth state changes
|
||||
DataConnectService.instance.initializeAuthListener(
|
||||
allowedRoles: <String>[
|
||||
'STAFF',
|
||||
'BOTH',
|
||||
], // Only allow users with STAFF or BOTH roles
|
||||
);
|
||||
|
||||
runApp(
|
||||
ModularApp(
|
||||
module: AppModule(),
|
||||
|
||||
@@ -3,7 +3,7 @@ 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';
|
||||
import 'package:krow_domain/krow_domain.dart' show UserRole;
|
||||
|
||||
/// A widget that listens to session state changes and handles global reactions.
|
||||
///
|
||||
@@ -28,11 +28,20 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupSessionListener();
|
||||
_initializeSession();
|
||||
}
|
||||
|
||||
void _setupSessionListener() {
|
||||
_sessionSubscription = DataConnectService.instance.onSessionStateChanged
|
||||
void _initializeSession() {
|
||||
// Resolve V2SessionService via DI — this triggers CoreModule's lazy
|
||||
// singleton, which wires setApiService(). Must happen before
|
||||
// initializeAuthListener so the session endpoint is reachable.
|
||||
final V2SessionService sessionService = Modular.get<V2SessionService>();
|
||||
|
||||
sessionService.initializeAuthListener(
|
||||
allowedRoles: const <UserRole>[UserRole.staff, UserRole.both],
|
||||
);
|
||||
|
||||
_sessionSubscription = sessionService.onSessionStateChanged
|
||||
.listen((SessionState state) {
|
||||
_handleSessionChange(state);
|
||||
});
|
||||
@@ -65,6 +74,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 +126,7 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Modular.to.popSafe();;
|
||||
Modular.to.popSafe();
|
||||
_proceedToLogin();
|
||||
},
|
||||
child: const Text('Log In'),
|
||||
@@ -134,7 +156,7 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Modular.to.popSafe();;
|
||||
Modular.to.popSafe();
|
||||
_proceedToLogin();
|
||||
},
|
||||
child: const Text('Log Out'),
|
||||
@@ -147,8 +169,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();
|
||||
|
||||
@@ -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
|
||||
@@ -21,7 +20,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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,8 +16,16 @@ export 'src/routing/routing.dart';
|
||||
export 'src/services/api_service/api_service.dart';
|
||||
export 'src/services/api_service/dio_client.dart';
|
||||
|
||||
// Core API Services
|
||||
export 'src/services/api_service/core_api_services/core_api_endpoints.dart';
|
||||
// API Mixins
|
||||
export 'src/services/api_service/mixins/api_error_handler.dart';
|
||||
export 'src/services/api_service/mixins/session_handler_mixin.dart';
|
||||
|
||||
// Feature Gate & Endpoint classes
|
||||
export 'src/services/api_service/feature_gate.dart';
|
||||
export 'src/services/api_service/endpoints/auth_endpoints.dart';
|
||||
export 'src/services/api_service/endpoints/client_endpoints.dart';
|
||||
export 'src/services/api_service/endpoints/core_endpoints.dart';
|
||||
export 'src/services/api_service/endpoints/staff_endpoints.dart';
|
||||
export 'src/services/api_service/core_api_services/file_upload/file_upload_service.dart';
|
||||
export 'src/services/api_service/core_api_services/file_upload/file_upload_response.dart';
|
||||
export 'src/services/api_service/core_api_services/signed_url/signed_url_service.dart';
|
||||
@@ -29,6 +37,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';
|
||||
|
||||
@@ -13,4 +13,10 @@ class AppConfig {
|
||||
static const String coreApiBaseUrl = String.fromEnvironment(
|
||||
'CORE_API_BASE_URL',
|
||||
);
|
||||
|
||||
/// The base URL for the V2 Unified API gateway.
|
||||
static const String v2ApiBaseUrl = String.fromEnvironment(
|
||||
'V2_API_BASE_URL',
|
||||
defaultValue: 'https://krow-api-v2-933560802882.us-central1.run.app',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,14 @@ class CoreModule extends Module {
|
||||
// 2. Register the base API service
|
||||
i.addLazySingleton<BaseApiService>(() => ApiService(i.get<Dio>()));
|
||||
|
||||
// 2b. Register V2SessionService — wires the singleton with ApiService.
|
||||
// Resolved eagerly by SessionListener.initState() after Modular is ready.
|
||||
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>()),
|
||||
|
||||
@@ -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>{
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'feature_gate.dart';
|
||||
|
||||
/// A service that handles HTTP communication using the [Dio] client.
|
||||
///
|
||||
/// This class provides a wrapper around [Dio]'s methods to handle
|
||||
/// response parsing and error handling in a consistent way.
|
||||
/// Integrates [FeatureGate] for scope validation and throws typed domain
|
||||
/// exceptions ([ApiException], [NetworkException], [ServerException]) on
|
||||
/// error responses so repositories never receive silent failures.
|
||||
class ApiService implements BaseApiService {
|
||||
/// Creates an [ApiService] with the given [Dio] instance.
|
||||
ApiService(this._dio);
|
||||
@@ -15,113 +18,164 @@ class ApiService implements BaseApiService {
|
||||
/// Performs a GET request to the specified [endpoint].
|
||||
@override
|
||||
Future<ApiResponse> get(
|
||||
String endpoint, {
|
||||
ApiEndpoint endpoint, {
|
||||
Map<String, dynamic>? params,
|
||||
}) async {
|
||||
FeatureGate.instance.validateAccess(endpoint);
|
||||
try {
|
||||
final Response<dynamic> response = await _dio.get<dynamic>(
|
||||
endpoint,
|
||||
endpoint.path,
|
||||
queryParameters: params,
|
||||
);
|
||||
return _handleResponse(response);
|
||||
} on DioException catch (e) {
|
||||
return _handleError(e);
|
||||
throw _mapDioException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs a POST request to the specified [endpoint].
|
||||
@override
|
||||
Future<ApiResponse> post(
|
||||
String endpoint, {
|
||||
ApiEndpoint endpoint, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? params,
|
||||
}) async {
|
||||
FeatureGate.instance.validateAccess(endpoint);
|
||||
try {
|
||||
final Response<dynamic> response = await _dio.post<dynamic>(
|
||||
endpoint,
|
||||
endpoint.path,
|
||||
data: data,
|
||||
queryParameters: params,
|
||||
);
|
||||
return _handleResponse(response);
|
||||
} on DioException catch (e) {
|
||||
return _handleError(e);
|
||||
throw _mapDioException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs a PUT request to the specified [endpoint].
|
||||
@override
|
||||
Future<ApiResponse> put(
|
||||
String endpoint, {
|
||||
ApiEndpoint endpoint, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? params,
|
||||
}) async {
|
||||
FeatureGate.instance.validateAccess(endpoint);
|
||||
try {
|
||||
final Response<dynamic> response = await _dio.put<dynamic>(
|
||||
endpoint,
|
||||
endpoint.path,
|
||||
data: data,
|
||||
queryParameters: params,
|
||||
);
|
||||
return _handleResponse(response);
|
||||
} on DioException catch (e) {
|
||||
return _handleError(e);
|
||||
throw _mapDioException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs a PATCH request to the specified [endpoint].
|
||||
@override
|
||||
Future<ApiResponse> patch(
|
||||
String endpoint, {
|
||||
ApiEndpoint endpoint, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? params,
|
||||
}) async {
|
||||
FeatureGate.instance.validateAccess(endpoint);
|
||||
try {
|
||||
final Response<dynamic> response = await _dio.patch<dynamic>(
|
||||
endpoint,
|
||||
endpoint.path,
|
||||
data: data,
|
||||
queryParameters: params,
|
||||
);
|
||||
return _handleResponse(response);
|
||||
} on DioException catch (e) {
|
||||
return _handleError(e);
|
||||
throw _mapDioException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs a DELETE request to the specified [endpoint].
|
||||
@override
|
||||
Future<ApiResponse> delete(
|
||||
ApiEndpoint endpoint, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? params,
|
||||
}) async {
|
||||
FeatureGate.instance.validateAccess(endpoint);
|
||||
try {
|
||||
final Response<dynamic> response = await _dio.delete<dynamic>(
|
||||
endpoint.path,
|
||||
data: data,
|
||||
queryParameters: params,
|
||||
);
|
||||
return _handleResponse(response);
|
||||
} on DioException catch (e) {
|
||||
throw _mapDioException(e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extracts [ApiResponse] from a successful [Response].
|
||||
ApiResponse _handleResponse(Response<dynamic> response) {
|
||||
final dynamic body = response.data;
|
||||
final String message = body is Map<String, dynamic>
|
||||
? body['message']?.toString() ?? 'Success'
|
||||
: 'Success';
|
||||
|
||||
return ApiResponse(
|
||||
code: response.statusCode?.toString() ?? '200',
|
||||
message: response.data['message']?.toString() ?? 'Success',
|
||||
data: response.data,
|
||||
message: message,
|
||||
data: body,
|
||||
);
|
||||
}
|
||||
|
||||
/// Extracts [ApiResponse] from a [DioException].
|
||||
ApiResponse _handleError(DioException e) {
|
||||
/// Maps a [DioException] to a typed domain exception.
|
||||
///
|
||||
/// The V2 API error envelope is `{ code, message, details, requestId }`.
|
||||
/// This method parses it and throws the appropriate [AppException] subclass
|
||||
/// so that `BlocErrorHandler` can translate it for the user.
|
||||
AppException _mapDioException(DioException e) {
|
||||
// Network-level failures (no response from server).
|
||||
if (e.type == DioExceptionType.connectionTimeout ||
|
||||
e.type == DioExceptionType.receiveTimeout ||
|
||||
e.type == DioExceptionType.sendTimeout ||
|
||||
e.type == DioExceptionType.connectionError) {
|
||||
return NetworkException(technicalMessage: e.message);
|
||||
}
|
||||
|
||||
final int? statusCode = e.response?.statusCode;
|
||||
|
||||
// Parse V2 error envelope if available.
|
||||
if (e.response?.data is Map<String, dynamic>) {
|
||||
final Map<String, dynamic> body =
|
||||
e.response!.data as Map<String, dynamic>;
|
||||
return ApiResponse(
|
||||
code:
|
||||
body['code']?.toString() ??
|
||||
e.response?.statusCode?.toString() ??
|
||||
'error',
|
||||
message: body['message']?.toString() ?? e.message ?? 'Error occurred',
|
||||
data: body['data'],
|
||||
errors: _parseErrors(body['errors']),
|
||||
|
||||
final String apiCode =
|
||||
body['code']?.toString() ?? statusCode?.toString() ?? 'UNKNOWN';
|
||||
final String apiMessage =
|
||||
body['message']?.toString() ?? e.message ?? 'An error occurred';
|
||||
|
||||
// Map well-known codes to specific exceptions.
|
||||
if (apiCode == 'UNAUTHENTICATED' || statusCode == 401) {
|
||||
return NotAuthenticatedException(technicalMessage: apiMessage);
|
||||
}
|
||||
|
||||
return ApiException(
|
||||
apiCode: apiCode,
|
||||
apiMessage: apiMessage,
|
||||
statusCode: statusCode,
|
||||
details: body['details'],
|
||||
technicalMessage: '$apiCode: $apiMessage',
|
||||
);
|
||||
}
|
||||
return ApiResponse(
|
||||
code: e.response?.statusCode?.toString() ?? 'error',
|
||||
message: e.message ?? 'Unknown error',
|
||||
errors: <String, dynamic>{'exception': e.type.toString()},
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper to parse the errors map from various possible formats.
|
||||
Map<String, dynamic> _parseErrors(dynamic errors) {
|
||||
if (errors is Map) {
|
||||
return Map<String, dynamic>.from(errors);
|
||||
// Server error without a parseable body.
|
||||
if (statusCode != null && statusCode >= 500) {
|
||||
return ServerException(technicalMessage: e.message);
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
|
||||
return UnknownException(technicalMessage: e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import '../../../config/app_config.dart';
|
||||
|
||||
/// Constants for Core API endpoints.
|
||||
class CoreApiEndpoints {
|
||||
CoreApiEndpoints._();
|
||||
|
||||
/// The base URL for the Core API.
|
||||
static const String baseUrl = AppConfig.coreApiBaseUrl;
|
||||
|
||||
/// Upload a file.
|
||||
static const String uploadFile = '$baseUrl/core/upload-file';
|
||||
|
||||
/// Create a signed URL for a file.
|
||||
static const String createSignedUrl = '$baseUrl/core/create-signed-url';
|
||||
|
||||
/// Invoke a Large Language Model.
|
||||
static const String invokeLlm = '$baseUrl/core/invoke-llm';
|
||||
|
||||
/// Root for verification operations.
|
||||
static const String verifications = '$baseUrl/core/verifications';
|
||||
|
||||
/// Get status of a verification job.
|
||||
static String verificationStatus(String id) =>
|
||||
'$baseUrl/core/verifications/$id';
|
||||
|
||||
/// Review a verification decision.
|
||||
static String verificationReview(String id) =>
|
||||
'$baseUrl/core/verifications/$id/review';
|
||||
|
||||
/// Retry a verification job.
|
||||
static String verificationRetry(String id) =>
|
||||
'$baseUrl/core/verifications/$id/retry';
|
||||
|
||||
/// Transcribe audio to text for rapid orders.
|
||||
static const String transcribeRapidOrder =
|
||||
'$baseUrl/core/rapid-orders/transcribe';
|
||||
|
||||
/// Parse text to structured rapid order.
|
||||
static const String parseRapidOrder = '$baseUrl/core/rapid-orders/parse';
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../core_api_endpoints.dart';
|
||||
import '../../endpoints/core_endpoints.dart';
|
||||
import 'file_upload_response.dart';
|
||||
|
||||
/// Service for uploading files to the Core API.
|
||||
@@ -26,7 +26,7 @@ class FileUploadService extends BaseCoreService {
|
||||
if (category != null) 'category': category,
|
||||
});
|
||||
|
||||
return api.post(CoreApiEndpoints.uploadFile, data: formData);
|
||||
return api.post(CoreEndpoints.uploadFile, data: formData);
|
||||
});
|
||||
|
||||
if (res.code.startsWith('2')) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../core_api_endpoints.dart';
|
||||
import '../../endpoints/core_endpoints.dart';
|
||||
import 'llm_response.dart';
|
||||
|
||||
/// Service for invoking Large Language Models (LLM).
|
||||
@@ -19,7 +19,7 @@ class LlmService extends BaseCoreService {
|
||||
}) async {
|
||||
final ApiResponse res = await action(() async {
|
||||
return api.post(
|
||||
CoreApiEndpoints.invokeLlm,
|
||||
CoreEndpoints.invokeLlm,
|
||||
data: <String, dynamic>{
|
||||
'prompt': prompt,
|
||||
if (responseJsonSchema != null)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../core_api_endpoints.dart';
|
||||
import '../../endpoints/core_endpoints.dart';
|
||||
import 'rapid_order_response.dart';
|
||||
|
||||
/// Service for handling RAPID order operations (Transcription and Parsing).
|
||||
@@ -19,7 +19,7 @@ class RapidOrderService extends BaseCoreService {
|
||||
}) async {
|
||||
final ApiResponse res = await action(() async {
|
||||
return api.post(
|
||||
CoreApiEndpoints.transcribeRapidOrder,
|
||||
CoreEndpoints.transcribeRapidOrder,
|
||||
data: <String, dynamic>{
|
||||
'audioFileUri': audioFileUri,
|
||||
'locale': locale,
|
||||
@@ -51,7 +51,7 @@ class RapidOrderService extends BaseCoreService {
|
||||
}) async {
|
||||
final ApiResponse res = await action(() async {
|
||||
return api.post(
|
||||
CoreApiEndpoints.parseRapidOrder,
|
||||
CoreEndpoints.parseRapidOrder,
|
||||
data: <String, dynamic>{
|
||||
'text': text,
|
||||
'locale': locale,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../core_api_endpoints.dart';
|
||||
import '../../endpoints/core_endpoints.dart';
|
||||
import 'signed_url_response.dart';
|
||||
|
||||
/// Service for creating signed URLs for Cloud Storage objects.
|
||||
@@ -17,7 +17,7 @@ class SignedUrlService extends BaseCoreService {
|
||||
}) async {
|
||||
final ApiResponse res = await action(() async {
|
||||
return api.post(
|
||||
CoreApiEndpoints.createSignedUrl,
|
||||
CoreEndpoints.createSignedUrl,
|
||||
data: <String, dynamic>{
|
||||
'fileUri': fileUri,
|
||||
'expiresInSeconds': expiresInSeconds,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../core_api_endpoints.dart';
|
||||
import '../../endpoints/core_endpoints.dart';
|
||||
import 'verification_response.dart';
|
||||
|
||||
/// Service for handling async verification jobs.
|
||||
@@ -22,7 +22,7 @@ class VerificationService extends BaseCoreService {
|
||||
}) async {
|
||||
final ApiResponse res = await action(() async {
|
||||
return api.post(
|
||||
CoreApiEndpoints.verifications,
|
||||
CoreEndpoints.verifications,
|
||||
data: <String, dynamic>{
|
||||
'type': type,
|
||||
'subjectType': subjectType,
|
||||
@@ -44,7 +44,7 @@ class VerificationService extends BaseCoreService {
|
||||
/// Polls the status of a specific verification.
|
||||
Future<VerificationResponse> getStatus(String verificationId) async {
|
||||
final ApiResponse res = await action(() async {
|
||||
return api.get(CoreApiEndpoints.verificationStatus(verificationId));
|
||||
return api.get(CoreEndpoints.verificationStatus(verificationId));
|
||||
});
|
||||
|
||||
if (res.code.startsWith('2')) {
|
||||
@@ -65,7 +65,7 @@ class VerificationService extends BaseCoreService {
|
||||
}) async {
|
||||
final ApiResponse res = await action(() async {
|
||||
return api.post(
|
||||
CoreApiEndpoints.verificationReview(verificationId),
|
||||
CoreEndpoints.verificationReview(verificationId),
|
||||
data: <String, dynamic>{
|
||||
'decision': decision,
|
||||
if (note != null) 'note': note,
|
||||
@@ -84,7 +84,7 @@ class VerificationService extends BaseCoreService {
|
||||
/// Retries a verification job that failed or needs re-processing.
|
||||
Future<VerificationResponse> retryVerification(String verificationId) async {
|
||||
final ApiResponse res = await action(() async {
|
||||
return api.post(CoreApiEndpoints.verificationRetry(verificationId));
|
||||
return api.post(CoreEndpoints.verificationRetry(verificationId));
|
||||
});
|
||||
|
||||
if (res.code.startsWith('2')) {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:krow_core/src/config/app_config.dart';
|
||||
import 'package:krow_core/src/services/api_service/inspectors/auth_interceptor.dart';
|
||||
import 'package:krow_core/src/services/api_service/inspectors/idempotency_interceptor.dart';
|
||||
|
||||
/// A custom Dio client for the KROW project that includes basic configuration
|
||||
/// and an [AuthInterceptor].
|
||||
/// A custom Dio client for the KROW project that includes basic configuration,
|
||||
/// [AuthInterceptor], and [IdempotencyInterceptor].
|
||||
///
|
||||
/// Sets [AppConfig.v2ApiBaseUrl] as the base URL so that endpoint paths only
|
||||
/// need to be relative (e.g. '/staff/dashboard').
|
||||
class DioClient extends DioMixin implements Dio {
|
||||
DioClient([BaseOptions? baseOptions]) {
|
||||
options =
|
||||
baseOptions ??
|
||||
BaseOptions(
|
||||
baseUrl: AppConfig.v2ApiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
);
|
||||
@@ -18,10 +24,11 @@ class DioClient extends DioMixin implements Dio {
|
||||
// Add interceptors
|
||||
interceptors.addAll(<Interceptor>[
|
||||
AuthInterceptor(),
|
||||
IdempotencyInterceptor(),
|
||||
LogInterceptor(
|
||||
requestBody: true,
|
||||
responseBody: true,
|
||||
), // Added for better debugging
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:krow_domain/krow_domain.dart' show ApiEndpoint;
|
||||
|
||||
/// Authentication endpoints for both staff and client apps.
|
||||
abstract final class AuthEndpoints {
|
||||
/// Client email/password sign-in.
|
||||
static const ApiEndpoint clientSignIn =
|
||||
ApiEndpoint('/auth/client/sign-in');
|
||||
|
||||
/// Client business registration.
|
||||
static const ApiEndpoint clientSignUp =
|
||||
ApiEndpoint('/auth/client/sign-up');
|
||||
|
||||
/// Client sign-out.
|
||||
static const ApiEndpoint clientSignOut =
|
||||
ApiEndpoint('/auth/client/sign-out');
|
||||
|
||||
/// Start staff phone verification (SMS).
|
||||
static const ApiEndpoint staffPhoneStart =
|
||||
ApiEndpoint('/auth/staff/phone/start');
|
||||
|
||||
/// Complete staff phone verification.
|
||||
static const ApiEndpoint staffPhoneVerify =
|
||||
ApiEndpoint('/auth/staff/phone/verify');
|
||||
|
||||
/// Generic sign-out.
|
||||
static const ApiEndpoint signOut = ApiEndpoint('/auth/sign-out');
|
||||
|
||||
/// Staff-specific sign-out.
|
||||
static const ApiEndpoint staffSignOut =
|
||||
ApiEndpoint('/auth/staff/sign-out');
|
||||
|
||||
/// Get current session data.
|
||||
static const ApiEndpoint session = ApiEndpoint('/auth/session');
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'package:krow_domain/krow_domain.dart' show ApiEndpoint;
|
||||
|
||||
/// Client-specific API endpoints (read and write).
|
||||
abstract final class ClientEndpoints {
|
||||
// ── Read ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Client session data.
|
||||
static const ApiEndpoint session = ApiEndpoint('/client/session');
|
||||
|
||||
/// Client dashboard.
|
||||
static const ApiEndpoint dashboard = ApiEndpoint('/client/dashboard');
|
||||
|
||||
/// Client reorders.
|
||||
static const ApiEndpoint reorders = ApiEndpoint('/client/reorders');
|
||||
|
||||
/// Billing accounts.
|
||||
static const ApiEndpoint billingAccounts =
|
||||
ApiEndpoint('/client/billing/accounts');
|
||||
|
||||
/// Pending invoices.
|
||||
static const ApiEndpoint billingInvoicesPending =
|
||||
ApiEndpoint('/client/billing/invoices/pending');
|
||||
|
||||
/// Invoice history.
|
||||
static const ApiEndpoint billingInvoicesHistory =
|
||||
ApiEndpoint('/client/billing/invoices/history');
|
||||
|
||||
/// Current bill.
|
||||
static const ApiEndpoint billingCurrentBill =
|
||||
ApiEndpoint('/client/billing/current-bill');
|
||||
|
||||
/// Savings data.
|
||||
static const ApiEndpoint billingSavings =
|
||||
ApiEndpoint('/client/billing/savings');
|
||||
|
||||
/// Spend breakdown.
|
||||
static const ApiEndpoint billingSpendBreakdown =
|
||||
ApiEndpoint('/client/billing/spend-breakdown');
|
||||
|
||||
/// Coverage overview.
|
||||
static const ApiEndpoint coverage = ApiEndpoint('/client/coverage');
|
||||
|
||||
/// Coverage stats.
|
||||
static const ApiEndpoint coverageStats =
|
||||
ApiEndpoint('/client/coverage/stats');
|
||||
|
||||
/// Core team.
|
||||
static const ApiEndpoint coverageCoreTeam =
|
||||
ApiEndpoint('/client/coverage/core-team');
|
||||
|
||||
/// Hubs list.
|
||||
static const ApiEndpoint hubs = ApiEndpoint('/client/hubs');
|
||||
|
||||
/// Cost centers.
|
||||
static const ApiEndpoint costCenters =
|
||||
ApiEndpoint('/client/cost-centers');
|
||||
|
||||
/// Vendors.
|
||||
static const ApiEndpoint vendors = ApiEndpoint('/client/vendors');
|
||||
|
||||
/// Vendor roles by ID.
|
||||
static ApiEndpoint vendorRoles(String vendorId) =>
|
||||
ApiEndpoint('/client/vendors/$vendorId/roles');
|
||||
|
||||
/// Hub managers by ID.
|
||||
static ApiEndpoint hubManagers(String hubId) =>
|
||||
ApiEndpoint('/client/hubs/$hubId/managers');
|
||||
|
||||
/// Team members.
|
||||
static const ApiEndpoint teamMembers =
|
||||
ApiEndpoint('/client/team-members');
|
||||
|
||||
/// View orders.
|
||||
static const ApiEndpoint ordersView =
|
||||
ApiEndpoint('/client/orders/view');
|
||||
|
||||
/// Order reorder preview.
|
||||
static ApiEndpoint orderReorderPreview(String orderId) =>
|
||||
ApiEndpoint('/client/orders/$orderId/reorder-preview');
|
||||
|
||||
/// Reports summary.
|
||||
static const ApiEndpoint reportsSummary =
|
||||
ApiEndpoint('/client/reports/summary');
|
||||
|
||||
/// Daily ops report.
|
||||
static const ApiEndpoint reportsDailyOps =
|
||||
ApiEndpoint('/client/reports/daily-ops');
|
||||
|
||||
/// Spend report.
|
||||
static const ApiEndpoint reportsSpend =
|
||||
ApiEndpoint('/client/reports/spend');
|
||||
|
||||
/// Coverage report.
|
||||
static const ApiEndpoint reportsCoverage =
|
||||
ApiEndpoint('/client/reports/coverage');
|
||||
|
||||
/// Forecast report.
|
||||
static const ApiEndpoint reportsForecast =
|
||||
ApiEndpoint('/client/reports/forecast');
|
||||
|
||||
/// Performance report.
|
||||
static const ApiEndpoint reportsPerformance =
|
||||
ApiEndpoint('/client/reports/performance');
|
||||
|
||||
/// No-show report.
|
||||
static const ApiEndpoint reportsNoShow =
|
||||
ApiEndpoint('/client/reports/no-show');
|
||||
|
||||
// ── Write ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Create one-time order.
|
||||
static const ApiEndpoint ordersOneTime =
|
||||
ApiEndpoint('/client/orders/one-time');
|
||||
|
||||
/// Create recurring order.
|
||||
static const ApiEndpoint ordersRecurring =
|
||||
ApiEndpoint('/client/orders/recurring');
|
||||
|
||||
/// Create permanent order.
|
||||
static const ApiEndpoint ordersPermanent =
|
||||
ApiEndpoint('/client/orders/permanent');
|
||||
|
||||
/// Edit order by ID.
|
||||
static ApiEndpoint orderEdit(String orderId) =>
|
||||
ApiEndpoint('/client/orders/$orderId/edit');
|
||||
|
||||
/// Cancel order by ID.
|
||||
static ApiEndpoint orderCancel(String orderId) =>
|
||||
ApiEndpoint('/client/orders/$orderId/cancel');
|
||||
|
||||
/// Create hub (same path as list hubs).
|
||||
static const ApiEndpoint hubCreate = ApiEndpoint('/client/hubs');
|
||||
|
||||
/// Update hub by ID.
|
||||
static ApiEndpoint hubUpdate(String hubId) =>
|
||||
ApiEndpoint('/client/hubs/$hubId');
|
||||
|
||||
/// Delete hub by ID.
|
||||
static ApiEndpoint hubDelete(String hubId) =>
|
||||
ApiEndpoint('/client/hubs/$hubId');
|
||||
|
||||
/// Assign NFC to hub.
|
||||
static ApiEndpoint hubAssignNfc(String hubId) =>
|
||||
ApiEndpoint('/client/hubs/$hubId/assign-nfc');
|
||||
|
||||
/// Assign managers to hub.
|
||||
static ApiEndpoint hubAssignManagers(String hubId) =>
|
||||
ApiEndpoint('/client/hubs/$hubId/managers');
|
||||
|
||||
/// Approve invoice.
|
||||
static ApiEndpoint invoiceApprove(String invoiceId) =>
|
||||
ApiEndpoint('/client/billing/invoices/$invoiceId/approve');
|
||||
|
||||
/// Dispute invoice.
|
||||
static ApiEndpoint invoiceDispute(String invoiceId) =>
|
||||
ApiEndpoint('/client/billing/invoices/$invoiceId/dispute');
|
||||
|
||||
/// Submit coverage review.
|
||||
static const ApiEndpoint coverageReviews =
|
||||
ApiEndpoint('/client/coverage/reviews');
|
||||
|
||||
/// Cancel late worker assignment.
|
||||
static ApiEndpoint coverageCancelLateWorker(String assignmentId) =>
|
||||
ApiEndpoint('/client/coverage/late-workers/$assignmentId/cancel');
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:krow_domain/krow_domain.dart' show ApiEndpoint;
|
||||
|
||||
/// Core infrastructure endpoints (upload, signed URLs, LLM, verifications,
|
||||
/// rapid orders).
|
||||
abstract final class CoreEndpoints {
|
||||
/// Upload a file.
|
||||
static const ApiEndpoint uploadFile =
|
||||
ApiEndpoint('/core/upload-file');
|
||||
|
||||
/// Create a signed URL for a file.
|
||||
static const ApiEndpoint createSignedUrl =
|
||||
ApiEndpoint('/core/create-signed-url');
|
||||
|
||||
/// Invoke a Large Language Model.
|
||||
static const ApiEndpoint invokeLlm = ApiEndpoint('/core/invoke-llm');
|
||||
|
||||
/// Root for verification operations.
|
||||
static const ApiEndpoint verifications =
|
||||
ApiEndpoint('/core/verifications');
|
||||
|
||||
/// Get status of a verification job.
|
||||
static ApiEndpoint verificationStatus(String id) =>
|
||||
ApiEndpoint('/core/verifications/$id');
|
||||
|
||||
/// Review a verification decision.
|
||||
static ApiEndpoint verificationReview(String id) =>
|
||||
ApiEndpoint('/core/verifications/$id/review');
|
||||
|
||||
/// Retry a verification job.
|
||||
static ApiEndpoint verificationRetry(String id) =>
|
||||
ApiEndpoint('/core/verifications/$id/retry');
|
||||
|
||||
/// Transcribe audio to text for rapid orders.
|
||||
static const ApiEndpoint transcribeRapidOrder =
|
||||
ApiEndpoint('/core/rapid-orders/transcribe');
|
||||
|
||||
/// Parse text to structured rapid order.
|
||||
static const ApiEndpoint parseRapidOrder =
|
||||
ApiEndpoint('/core/rapid-orders/parse');
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import 'package:krow_domain/krow_domain.dart' show ApiEndpoint;
|
||||
|
||||
/// Staff-specific API endpoints (read and write).
|
||||
abstract final class StaffEndpoints {
|
||||
// ── Read ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Staff session data.
|
||||
static const ApiEndpoint session = ApiEndpoint('/staff/session');
|
||||
|
||||
/// Staff dashboard overview.
|
||||
static const ApiEndpoint dashboard = ApiEndpoint('/staff/dashboard');
|
||||
|
||||
/// Staff profile completion status.
|
||||
static const ApiEndpoint profileCompletion =
|
||||
ApiEndpoint('/staff/profile-completion');
|
||||
|
||||
/// Staff availability schedule.
|
||||
static const ApiEndpoint availability = ApiEndpoint('/staff/availability');
|
||||
|
||||
/// Today's shifts for clock-in.
|
||||
static const ApiEndpoint clockInShiftsToday =
|
||||
ApiEndpoint('/staff/clock-in/shifts/today');
|
||||
|
||||
/// Current clock-in status.
|
||||
static const ApiEndpoint clockInStatus =
|
||||
ApiEndpoint('/staff/clock-in/status');
|
||||
|
||||
/// Payments summary.
|
||||
static const ApiEndpoint paymentsSummary =
|
||||
ApiEndpoint('/staff/payments/summary');
|
||||
|
||||
/// Payments history.
|
||||
static const ApiEndpoint paymentsHistory =
|
||||
ApiEndpoint('/staff/payments/history');
|
||||
|
||||
/// Payments chart data.
|
||||
static const ApiEndpoint paymentsChart =
|
||||
ApiEndpoint('/staff/payments/chart');
|
||||
|
||||
/// Assigned shifts.
|
||||
static const ApiEndpoint shiftsAssigned =
|
||||
ApiEndpoint('/staff/shifts/assigned');
|
||||
|
||||
/// Open shifts available to apply.
|
||||
static const ApiEndpoint shiftsOpen = ApiEndpoint('/staff/shifts/open');
|
||||
|
||||
/// Pending shift assignments.
|
||||
static const ApiEndpoint shiftsPending =
|
||||
ApiEndpoint('/staff/shifts/pending');
|
||||
|
||||
/// Cancelled shifts.
|
||||
static const ApiEndpoint shiftsCancelled =
|
||||
ApiEndpoint('/staff/shifts/cancelled');
|
||||
|
||||
/// Completed shifts.
|
||||
static const ApiEndpoint shiftsCompleted =
|
||||
ApiEndpoint('/staff/shifts/completed');
|
||||
|
||||
/// Shift details by ID.
|
||||
static ApiEndpoint shiftDetails(String shiftId) =>
|
||||
ApiEndpoint('/staff/shifts/$shiftId');
|
||||
|
||||
/// Staff profile sections overview.
|
||||
static const ApiEndpoint profileSections =
|
||||
ApiEndpoint('/staff/profile/sections');
|
||||
|
||||
/// Personal info.
|
||||
static const ApiEndpoint personalInfo =
|
||||
ApiEndpoint('/staff/profile/personal-info');
|
||||
|
||||
/// Industries/experience.
|
||||
static const ApiEndpoint industries =
|
||||
ApiEndpoint('/staff/profile/industries');
|
||||
|
||||
/// Skills.
|
||||
static const ApiEndpoint skills = ApiEndpoint('/staff/profile/skills');
|
||||
|
||||
/// Save/update experience (industries + skills).
|
||||
static const ApiEndpoint experience =
|
||||
ApiEndpoint('/staff/profile/experience');
|
||||
|
||||
/// Documents.
|
||||
static const ApiEndpoint documents =
|
||||
ApiEndpoint('/staff/profile/documents');
|
||||
|
||||
/// Attire items.
|
||||
static const ApiEndpoint attire = ApiEndpoint('/staff/profile/attire');
|
||||
|
||||
/// Tax forms.
|
||||
static const ApiEndpoint taxForms =
|
||||
ApiEndpoint('/staff/profile/tax-forms');
|
||||
|
||||
/// Emergency contacts.
|
||||
static const ApiEndpoint emergencyContacts =
|
||||
ApiEndpoint('/staff/profile/emergency-contacts');
|
||||
|
||||
/// Certificates.
|
||||
static const ApiEndpoint certificates =
|
||||
ApiEndpoint('/staff/profile/certificates');
|
||||
|
||||
/// Bank accounts.
|
||||
static const ApiEndpoint bankAccounts =
|
||||
ApiEndpoint('/staff/profile/bank-accounts');
|
||||
|
||||
/// Benefits.
|
||||
static const ApiEndpoint benefits = ApiEndpoint('/staff/profile/benefits');
|
||||
|
||||
/// Time card.
|
||||
static const ApiEndpoint timeCard =
|
||||
ApiEndpoint('/staff/profile/time-card');
|
||||
|
||||
/// Privacy settings.
|
||||
static const ApiEndpoint privacy = ApiEndpoint('/staff/profile/privacy');
|
||||
|
||||
/// FAQs.
|
||||
static const ApiEndpoint faqs = ApiEndpoint('/staff/faqs');
|
||||
|
||||
/// FAQs search.
|
||||
static const ApiEndpoint faqsSearch = ApiEndpoint('/staff/faqs/search');
|
||||
|
||||
// ── Write ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Staff profile setup.
|
||||
static const ApiEndpoint profileSetup =
|
||||
ApiEndpoint('/staff/profile/setup');
|
||||
|
||||
/// Clock in.
|
||||
static const ApiEndpoint clockIn = ApiEndpoint('/staff/clock-in');
|
||||
|
||||
/// Clock out.
|
||||
static const ApiEndpoint clockOut = ApiEndpoint('/staff/clock-out');
|
||||
|
||||
/// Quick-set availability.
|
||||
static const ApiEndpoint availabilityQuickSet =
|
||||
ApiEndpoint('/staff/availability/quick-set');
|
||||
|
||||
/// Apply for a shift.
|
||||
static ApiEndpoint shiftApply(String shiftId) =>
|
||||
ApiEndpoint('/staff/shifts/$shiftId/apply');
|
||||
|
||||
/// Accept a shift.
|
||||
static ApiEndpoint shiftAccept(String shiftId) =>
|
||||
ApiEndpoint('/staff/shifts/$shiftId/accept');
|
||||
|
||||
/// Decline a shift.
|
||||
static ApiEndpoint shiftDecline(String shiftId) =>
|
||||
ApiEndpoint('/staff/shifts/$shiftId/decline');
|
||||
|
||||
/// Request a shift swap.
|
||||
static ApiEndpoint shiftRequestSwap(String shiftId) =>
|
||||
ApiEndpoint('/staff/shifts/$shiftId/request-swap');
|
||||
|
||||
/// Update emergency contact by ID.
|
||||
static ApiEndpoint emergencyContactUpdate(String contactId) =>
|
||||
ApiEndpoint('/staff/profile/emergency-contacts/$contactId');
|
||||
|
||||
/// Update tax form by type.
|
||||
static ApiEndpoint taxFormUpdate(String formType) =>
|
||||
ApiEndpoint('/staff/profile/tax-forms/$formType');
|
||||
|
||||
/// Submit tax form by type.
|
||||
static ApiEndpoint taxFormSubmit(String formType) =>
|
||||
ApiEndpoint('/staff/profile/tax-forms/$formType/submit');
|
||||
|
||||
/// Upload staff profile photo.
|
||||
static const ApiEndpoint profilePhoto =
|
||||
ApiEndpoint('/staff/profile/photo');
|
||||
|
||||
/// Upload document by ID.
|
||||
static ApiEndpoint documentUpload(String documentId) =>
|
||||
ApiEndpoint('/staff/profile/documents/$documentId/upload');
|
||||
|
||||
/// Upload attire by ID.
|
||||
static ApiEndpoint attireUpload(String documentId) =>
|
||||
ApiEndpoint('/staff/profile/attire/$documentId/upload');
|
||||
|
||||
/// Delete certificate by ID.
|
||||
static ApiEndpoint certificateDelete(String certificateId) =>
|
||||
ApiEndpoint('/staff/profile/certificates/$certificateId');
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Client-side feature gate that checks user scopes against endpoint
|
||||
/// requirements before allowing an API call.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// FeatureGate.instance.validateAccess(StaffEndpoints.dashboard);
|
||||
/// ```
|
||||
///
|
||||
/// When an endpoint's [ApiEndpoint.requiredScopes] is empty, access is always
|
||||
/// granted. When scopes are defined, the gate verifies that the user has ALL
|
||||
/// required scopes. Throws [InsufficientScopeException] if any are missing.
|
||||
class FeatureGate {
|
||||
FeatureGate._();
|
||||
|
||||
/// The global singleton instance.
|
||||
static final FeatureGate instance = FeatureGate._();
|
||||
|
||||
/// The scopes the current user has.
|
||||
List<String> _userScopes = const <String>[];
|
||||
|
||||
/// Updates the user's scopes (call after sign-in or session hydration).
|
||||
void setUserScopes(List<String> scopes) {
|
||||
_userScopes = List<String>.unmodifiable(scopes);
|
||||
debugPrint('[FeatureGate] User scopes updated: $_userScopes');
|
||||
}
|
||||
|
||||
/// Clears the user's scopes (call on sign-out).
|
||||
void clearScopes() {
|
||||
_userScopes = const <String>[];
|
||||
debugPrint('[FeatureGate] User scopes cleared');
|
||||
}
|
||||
|
||||
/// The current user's scopes (read-only).
|
||||
List<String> get userScopes => _userScopes;
|
||||
|
||||
/// Returns `true` if the user has all scopes required by [endpoint].
|
||||
bool hasAccess(ApiEndpoint endpoint) {
|
||||
if (endpoint.requiredScopes.isEmpty) return true;
|
||||
return endpoint.requiredScopes.every(
|
||||
(String scope) => _userScopes.contains(scope),
|
||||
);
|
||||
}
|
||||
|
||||
/// Validates that the user can access [endpoint].
|
||||
///
|
||||
/// No-op when the endpoint has no required scopes (ungated).
|
||||
/// Throws [InsufficientScopeException] when scopes are missing.
|
||||
void validateAccess(ApiEndpoint endpoint) {
|
||||
if (endpoint.requiredScopes.isEmpty) return;
|
||||
|
||||
final List<String> missingScopes = endpoint.requiredScopes
|
||||
.where((String scope) => !_userScopes.contains(scope))
|
||||
.toList();
|
||||
|
||||
if (missingScopes.isNotEmpty) {
|
||||
throw InsufficientScopeException(
|
||||
requiredScopes: endpoint.requiredScopes,
|
||||
userScopes: _userScopes,
|
||||
technicalMessage:
|
||||
'Endpoint "${endpoint.path}" requires scopes '
|
||||
'${endpoint.requiredScopes} but user has $_userScopes. '
|
||||
'Missing: $missingScopes',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,79 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:krow_core/src/services/api_service/endpoints/auth_endpoints.dart';
|
||||
|
||||
/// An interceptor that adds the Firebase Auth ID token to the Authorization header.
|
||||
/// An interceptor that adds the Firebase Auth ID token to the Authorization
|
||||
/// header and retries once on 401 with a force-refreshed token.
|
||||
///
|
||||
/// Skips unauthenticated auth endpoints (sign-in, sign-up, phone/start) since
|
||||
/// the user has no Firebase session yet. Sign-out, session, and phone/verify
|
||||
/// endpoints DO require the token.
|
||||
class AuthInterceptor extends Interceptor {
|
||||
/// Auth paths that must NOT receive a Bearer token (no session exists yet).
|
||||
static final List<String> _unauthenticatedPaths = <String>[
|
||||
AuthEndpoints.clientSignIn.path,
|
||||
AuthEndpoints.clientSignUp.path,
|
||||
AuthEndpoints.staffPhoneStart.path,
|
||||
];
|
||||
|
||||
/// Tracks whether a 401 retry is in progress to prevent infinite loops.
|
||||
bool _isRetrying = false;
|
||||
|
||||
@override
|
||||
Future<void> onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
final User? user = FirebaseAuth.instance.currentUser;
|
||||
if (user != null) {
|
||||
try {
|
||||
// Skip token injection for endpoints that don't require authentication.
|
||||
final bool skipAuth = _unauthenticatedPaths.any(
|
||||
(String path) => options.path.contains(path),
|
||||
);
|
||||
|
||||
if (!skipAuth) {
|
||||
final User? user = FirebaseAuth.instance.currentUser;
|
||||
if (user != null) {
|
||||
final String? token = await user.getIdToken();
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
return handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
// Retry once with a force-refreshed token on 401 Unauthorized.
|
||||
if (err.response?.statusCode == 401 && !_isRetrying) {
|
||||
final bool skipAuth = _unauthenticatedPaths.any(
|
||||
(String path) => err.requestOptions.path.contains(path),
|
||||
);
|
||||
|
||||
if (!skipAuth) {
|
||||
final User? user = FirebaseAuth.instance.currentUser;
|
||||
if (user != null) {
|
||||
_isRetrying = true;
|
||||
try {
|
||||
final String? freshToken = await user.getIdToken(true);
|
||||
if (freshToken != null) {
|
||||
// Retry the original request with the refreshed token.
|
||||
err.requestOptions.headers['Authorization'] =
|
||||
'Bearer $freshToken';
|
||||
final Response<dynamic> response =
|
||||
await Dio().fetch<dynamic>(err.requestOptions);
|
||||
return handler.resolve(response);
|
||||
}
|
||||
} catch (_) {
|
||||
// Force-refresh or retry failed — fall through to original error.
|
||||
} finally {
|
||||
_isRetrying = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// A Dio interceptor that adds an `Idempotency-Key` header to write requests.
|
||||
///
|
||||
/// The V2 API requires an idempotency key for all POST, PUT, and DELETE
|
||||
/// requests to prevent duplicate operations. A unique UUID v4 is generated
|
||||
/// per request automatically.
|
||||
class IdempotencyInterceptor extends Interceptor {
|
||||
/// The UUID generator instance.
|
||||
static const Uuid _uuid = Uuid();
|
||||
|
||||
@override
|
||||
void onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) {
|
||||
final String method = options.method.toUpperCase();
|
||||
if (method == 'POST' || method == 'PUT' || method == 'DELETE') {
|
||||
options.headers['Idempotency-Key'] = _uuid.v4();
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Mixin to handle API layer errors and map them to domain exceptions.
|
||||
///
|
||||
/// Use this in repository implementations to wrap [ApiService] calls.
|
||||
/// It catches [DioException], [SocketException], etc., and throws
|
||||
/// the appropriate [AppException] subclass.
|
||||
mixin ApiErrorHandler {
|
||||
/// 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(
|
||||
'ApiErrorHandler: Request timed out after ${timeout.inSeconds}s',
|
||||
);
|
||||
throw ServiceUnavailableException(
|
||||
technicalMessage: 'Request timed out after ${timeout.inSeconds}s',
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw _mapDioException(e);
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(
|
||||
technicalMessage: 'SocketException: ${e.message}',
|
||||
);
|
||||
} catch (e) {
|
||||
// If it's already an AppException, rethrow it.
|
||||
if (e is AppException) rethrow;
|
||||
|
||||
final String errorStr = e.toString().toLowerCase();
|
||||
if (_isNetworkRelated(errorStr)) {
|
||||
debugPrint('ApiErrorHandler: Network-related error: $e');
|
||||
throw NetworkException(technicalMessage: e.toString());
|
||||
}
|
||||
|
||||
debugPrint('ApiErrorHandler: Unhandled exception caught: $e');
|
||||
throw UnknownException(technicalMessage: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a [DioException] to the appropriate [AppException].
|
||||
AppException _mapDioException(DioException e) {
|
||||
switch (e.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
debugPrint('ApiErrorHandler: Dio timeout: ${e.type}');
|
||||
return ServiceUnavailableException(
|
||||
technicalMessage: 'Dio ${e.type}: ${e.message}',
|
||||
);
|
||||
|
||||
case DioExceptionType.connectionError:
|
||||
debugPrint('ApiErrorHandler: Connection error: ${e.message}');
|
||||
return NetworkException(
|
||||
technicalMessage: 'Connection error: ${e.message}',
|
||||
);
|
||||
|
||||
case DioExceptionType.badResponse:
|
||||
final int? statusCode = e.response?.statusCode;
|
||||
final String body = e.response?.data?.toString() ?? '';
|
||||
debugPrint(
|
||||
'ApiErrorHandler: Bad response $statusCode: $body',
|
||||
);
|
||||
|
||||
if (statusCode == 401 || statusCode == 403) {
|
||||
return NotAuthenticatedException(
|
||||
technicalMessage: 'HTTP $statusCode: $body',
|
||||
);
|
||||
}
|
||||
if (statusCode == 404) {
|
||||
return ServerException(
|
||||
technicalMessage: 'HTTP 404: Not found — $body',
|
||||
);
|
||||
}
|
||||
if (statusCode == 429) {
|
||||
return ServiceUnavailableException(
|
||||
technicalMessage: 'Rate limited (429): $body',
|
||||
);
|
||||
}
|
||||
if (statusCode != null && statusCode >= 500) {
|
||||
return ServiceUnavailableException(
|
||||
technicalMessage: 'HTTP $statusCode: $body',
|
||||
);
|
||||
}
|
||||
return ServerException(
|
||||
technicalMessage: 'HTTP $statusCode: $body',
|
||||
);
|
||||
|
||||
case DioExceptionType.cancel:
|
||||
return UnknownException(
|
||||
technicalMessage: 'Request cancelled',
|
||||
);
|
||||
|
||||
case DioExceptionType.badCertificate:
|
||||
return NetworkException(
|
||||
technicalMessage: 'Bad certificate: ${e.message}',
|
||||
);
|
||||
|
||||
case DioExceptionType.unknown:
|
||||
if (e.error is SocketException) {
|
||||
return NetworkException(
|
||||
technicalMessage: 'Socket error: ${e.error}',
|
||||
);
|
||||
}
|
||||
return UnknownException(
|
||||
technicalMessage: 'Unknown Dio error: ${e.message}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if an error string is network-related.
|
||||
bool _isNetworkRelated(String errorStr) {
|
||||
return 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('terminated') ||
|
||||
errorStr.contains('connectexception');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' show UserRole;
|
||||
|
||||
/// Enum representing the current session state.
|
||||
enum SessionStateType { loading, authenticated, unauthenticated, error }
|
||||
@@ -41,7 +42,12 @@ class SessionState {
|
||||
'SessionState(type: $type, userId: $userId, error: $errorMessage)';
|
||||
}
|
||||
|
||||
/// Mixin for handling Firebase Auth session management, token refresh, and state emissions.
|
||||
/// Mixin for handling Firebase Auth session management, token refresh,
|
||||
/// and state emissions.
|
||||
///
|
||||
/// Implementors must provide [auth] and [fetchUserRole]. The role fetch
|
||||
/// should call `GET /auth/session` via [ApiService] instead of querying
|
||||
/// Data Connect directly.
|
||||
mixin SessionHandlerMixin {
|
||||
/// Stream controller for session state changes.
|
||||
final StreamController<SessionState> _sessionStateController =
|
||||
@@ -53,17 +59,14 @@ mixin SessionHandlerMixin {
|
||||
/// 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;
|
||||
}
|
||||
|
||||
@@ -82,17 +85,17 @@ mixin SessionHandlerMixin {
|
||||
/// Firebase Auth instance (to be provided by implementing class).
|
||||
firebase_auth.FirebaseAuth get auth;
|
||||
|
||||
/// List of allowed roles for this app (to be set during initialization).
|
||||
List<String> _allowedRoles = <String>[];
|
||||
/// List of allowed roles for this app (set during initialization).
|
||||
List<UserRole> _allowedRoles = <UserRole>[];
|
||||
|
||||
/// Initialize the auth state listener (call once on app startup).
|
||||
void initializeAuthListener({List<String> allowedRoles = const <String>[]}) {
|
||||
void initializeAuthListener({
|
||||
List<UserRole> allowedRoles = const <UserRole>[],
|
||||
}) {
|
||||
_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) {
|
||||
@@ -108,13 +111,12 @@ mixin SessionHandlerMixin {
|
||||
}
|
||||
|
||||
/// 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,
|
||||
List<UserRole> allowedRoles,
|
||||
) async {
|
||||
try {
|
||||
final String? userRole = await fetchUserRole(userId);
|
||||
final UserRole? userRole = await fetchUserRole(userId);
|
||||
return userRole != null && allowedRoles.contains(userRole);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to validate user role: $e');
|
||||
@@ -122,26 +124,50 @@ mixin SessionHandlerMixin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches user role from Data Connect.
|
||||
/// To be implemented by concrete class.
|
||||
Future<String?> fetchUserRole(String userId);
|
||||
/// Fetches the user role from the backend by calling `GET /auth/session`
|
||||
/// and deriving the [UserRole] from the response context.
|
||||
Future<UserRole?> 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;
|
||||
/// Handle user sign-in event.
|
||||
Future<void> _handleSignIn(firebase_auth.User user) async {
|
||||
try {
|
||||
_emitSessionState(SessionState.loading());
|
||||
|
||||
// No user = not authenticated, skip check
|
||||
if (user == null) return;
|
||||
if (_allowedRoles.isNotEmpty) {
|
||||
final UserRole? userRole = await fetchUserRole(user.uid);
|
||||
|
||||
// Optimization: Skip if we just checked within the last 2 seconds
|
||||
if (userRole == null) {
|
||||
_emitSessionState(SessionState.unauthenticated());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_allowedRoles.contains(userRole)) {
|
||||
await auth.signOut();
|
||||
_emitSessionState(SessionState.unauthenticated());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Proactively refresh the token if it expires soon.
|
||||
await _ensureSessionValid(user);
|
||||
|
||||
_emitSessionState(SessionState.authenticated(userId: user.uid));
|
||||
} catch (e) {
|
||||
_emitSessionState(SessionState.error(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures the Firebase auth token is valid and refreshes if it expires
|
||||
/// within [_refreshThreshold]. Retries up to 3 times with exponential
|
||||
/// backoff before emitting an error state.
|
||||
Future<void> _ensureSessionValid(firebase_auth.User user) async {
|
||||
final DateTime now = DateTime.now();
|
||||
if (_lastTokenRefreshTime != null) {
|
||||
final Duration timeSinceLastCheck = now.difference(
|
||||
_lastTokenRefreshTime!,
|
||||
);
|
||||
if (timeSinceLastCheck < _minRefreshCheckInterval) {
|
||||
return; // Skip redundant check
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,35 +176,25 @@ mixin SessionHandlerMixin {
|
||||
|
||||
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 firebase_auth.IdTokenResult idToken =
|
||||
await user.getIdTokenResult();
|
||||
final DateTime? expiryTime = idToken.expirationTime;
|
||||
|
||||
if (expiryTime == null) {
|
||||
return; // Token info unavailable, proceed anyway
|
||||
}
|
||||
if (expiryTime == null) return;
|
||||
|
||||
// Calculate time until expiry
|
||||
final Duration timeUntilExpiry = expiryTime.difference(now);
|
||||
|
||||
// If token expires within 5 minutes, refresh it
|
||||
if (timeUntilExpiry <= _refreshThreshold) {
|
||||
await user.getIdTokenResult();
|
||||
await user.getIdTokenResult(true);
|
||||
}
|
||||
|
||||
// Update last refresh check timestamp
|
||||
_lastTokenRefreshTime = now;
|
||||
return; // Success, exit retry loop
|
||||
return;
|
||||
} catch (e) {
|
||||
retryCount++;
|
||||
debugPrint(
|
||||
'Token validation error (attempt $retryCount/$maxRetries): $e',
|
||||
);
|
||||
|
||||
// If we've exhausted retries, emit error
|
||||
if (retryCount >= maxRetries) {
|
||||
_emitSessionState(
|
||||
SessionState.error(
|
||||
@@ -188,9 +204,8 @@ mixin SessionHandlerMixin {
|
||||
return;
|
||||
}
|
||||
|
||||
// Exponential backoff: 1s, 2s, 4s
|
||||
final Duration backoffDuration = Duration(
|
||||
seconds: 1 << (retryCount - 1), // 2^(retryCount-1)
|
||||
seconds: 1 << (retryCount - 1),
|
||||
);
|
||||
debugPrint(
|
||||
'Retrying token validation in ${backoffDuration.inSeconds}s',
|
||||
@@ -200,50 +215,6 @@ mixin SessionHandlerMixin {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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/endpoints/auth_endpoints.dart';
|
||||
import '../api_service/feature_gate.dart';
|
||||
import '../api_service/mixins/session_handler_mixin.dart';
|
||||
import '../device/background_task/background_task_service.dart';
|
||||
import 'client_session_store.dart';
|
||||
import 'staff_session_store.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 [UserRole] derived from the session context, or `null` if
|
||||
/// the call fails or the user has no role.
|
||||
@override
|
||||
Future<UserRole?> fetchUserRole(String userId) async {
|
||||
try {
|
||||
final BaseApiService? api = _apiService;
|
||||
if (api == null) {
|
||||
debugPrint(
|
||||
'[V2SessionService] ApiService not injected; '
|
||||
'cannot fetch user role.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
final ApiResponse response = await api.get(AuthEndpoints.session);
|
||||
|
||||
if (response.data is Map<String, dynamic>) {
|
||||
final Map<String, dynamic> data =
|
||||
response.data as Map<String, dynamic>;
|
||||
|
||||
// Hydrate session stores from the session endpoint response.
|
||||
// Per V2 auth doc, GET /auth/session is used for app startup hydration.
|
||||
_hydrateSessionStores(data);
|
||||
|
||||
return UserRole.fromSessionData(data);
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('[V2SessionService] Error fetching user role: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Hydrates session stores from a `GET /auth/session` response.
|
||||
///
|
||||
/// The session endpoint returns `{ user, tenant, business, vendor, staff }`
|
||||
/// which maps to both [ClientSession] and [StaffSession] entities.
|
||||
void _hydrateSessionStores(Map<String, dynamic> data) {
|
||||
try {
|
||||
// Hydrate staff session if staff context is present.
|
||||
if (data['staff'] is Map<String, dynamic>) {
|
||||
final StaffSession staffSession = StaffSession.fromJson(data);
|
||||
StaffSessionStore.instance.setSession(staffSession);
|
||||
}
|
||||
|
||||
// Hydrate client session if business context is present.
|
||||
if (data['business'] is Map<String, dynamic>) {
|
||||
final ClientSession clientSession = ClientSession.fromJson(data);
|
||||
ClientSessionStore.instance.setSession(clientSession);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[V2SessionService] Error hydrating session stores: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(AuthEndpoints.signOut);
|
||||
} catch (e) {
|
||||
debugPrint('[V2SessionService] Server sign-out failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
await auth.signOut();
|
||||
} catch (e) {
|
||||
debugPrint('[V2SessionService] Error signing out: $e');
|
||||
rethrow;
|
||||
} finally {
|
||||
// Cancel all background tasks (geofence tracking, etc.).
|
||||
try {
|
||||
await const BackgroundTaskService().cancelAll();
|
||||
} catch (e) {
|
||||
debugPrint('[V2SessionService] Failed to cancel background tasks: $e');
|
||||
}
|
||||
|
||||
StaffSessionStore.instance.clear();
|
||||
ClientSessionStore.instance.clear();
|
||||
FeatureGate.instance.clearScopes();
|
||||
handleSignOut();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,3 +32,4 @@ dependencies:
|
||||
flutter_local_notifications: ^21.0.0
|
||||
shared_preferences: ^2.5.4
|
||||
workmanager: ^0.9.0+3
|
||||
uuid: ^4.5.1
|
||||
|
||||
@@ -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",
|
||||
@@ -820,6 +823,8 @@
|
||||
"custom_skills_title": "Custom Skills:",
|
||||
"custom_skill_hint": "Add custom skill...",
|
||||
"save_button": "Save & Continue",
|
||||
"save_success": "Experience saved successfully",
|
||||
"save_error": "An error occurred",
|
||||
"industries": {
|
||||
"hospitality": "Hospitality",
|
||||
"food_service": "Food Service",
|
||||
@@ -827,6 +832,8 @@
|
||||
"events": "Events",
|
||||
"retail": "Retail",
|
||||
"healthcare": "Healthcare",
|
||||
"catering": "Catering",
|
||||
"cafe": "Cafe",
|
||||
"other": "Other"
|
||||
},
|
||||
"skills": {
|
||||
|
||||
@@ -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",
|
||||
@@ -815,6 +818,8 @@
|
||||
"custom_skills_title": "Habilidades personalizadas:",
|
||||
"custom_skill_hint": "A\u00f1adir habilidad...",
|
||||
"save_button": "Guardar y continuar",
|
||||
"save_success": "Experiencia guardada exitosamente",
|
||||
"save_error": "Ocurrió un error",
|
||||
"industries": {
|
||||
"hospitality": "Hoteler\u00eda",
|
||||
"food_service": "Servicio de alimentos",
|
||||
@@ -822,6 +827,8 @@
|
||||
"events": "Eventos",
|
||||
"retail": "Venta al por menor",
|
||||
"healthcare": "Cuidado de la salud",
|
||||
"catering": "Catering",
|
||||
"cafe": "Cafetería",
|
||||
"other": "Otro"
|
||||
},
|
||||
"skills": {
|
||||
|
||||
@@ -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';
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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._();
|
||||
}
|
||||
@@ -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._();
|
||||
}
|
||||
@@ -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
|
||||
@@ -245,7 +245,7 @@ class UiColors {
|
||||
static const Color buttonPrimaryStill = primary;
|
||||
|
||||
/// Primary button hover (#082EB2)
|
||||
static const Color buttonPrimaryHover = Color(0xFF082EB2);
|
||||
static const Color buttonPrimaryHover = Color.fromARGB(255, 8, 46, 178);
|
||||
|
||||
/// Primary button inactive (#F1F3F5)
|
||||
static const Color buttonPrimaryInactive = secondary;
|
||||
|
||||
@@ -368,7 +368,6 @@ class UiTypography {
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 12,
|
||||
height: 1.5,
|
||||
letterSpacing: -0.1,
|
||||
color: UiColors.textPrimary,
|
||||
);
|
||||
|
||||
|
||||
@@ -67,39 +67,45 @@ class UiNoticeBanner extends StatelessWidget {
|
||||
color: backgroundColor ?? UiColors.primary.withValues(alpha: 0.08),
|
||||
borderRadius: borderRadius ?? UiConstants.radiusLg,
|
||||
),
|
||||
child: Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
if (leading != null) ...<Widget>[
|
||||
leading!,
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
] else if (icon != null) ...<Widget>[
|
||||
Icon(icon, color: iconColor ?? UiColors.primary, size: 24),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
if (leading != null) ...<Widget>[
|
||||
leading!,
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
] else if (icon != null) ...<Widget>[
|
||||
Icon(icon, color: iconColor ?? UiColors.primary, size: 24),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.body2b.copyWith(color: titleColor),
|
||||
),
|
||||
if (description != null) ...<Widget>[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
description!,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: descriptionColor,
|
||||
),
|
||||
style: UiTypography.body2b.copyWith(
|
||||
color: titleColor ?? UiColors.primary,
|
||||
),
|
||||
],
|
||||
if (action != null) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
action!,
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
if (description != null) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
description!,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
color: descriptionColor ?? UiColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (action != null) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
action!,
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -6,7 +6,26 @@
|
||||
/// Note: Repository Interfaces are now located in their respective Feature packages.
|
||||
library;
|
||||
|
||||
// Enums (shared status/type enums aligned with V2 CHECK constraints)
|
||||
export 'src/entities/enums/account_type.dart';
|
||||
export 'src/entities/enums/application_status.dart';
|
||||
export 'src/entities/enums/assignment_status.dart';
|
||||
export 'src/entities/enums/attendance_status_type.dart';
|
||||
export 'src/entities/enums/availability_status.dart';
|
||||
export 'src/entities/enums/benefit_status.dart';
|
||||
export 'src/entities/enums/business_status.dart';
|
||||
export 'src/entities/enums/invoice_status.dart';
|
||||
export 'src/entities/enums/onboarding_status.dart';
|
||||
export 'src/entities/enums/order_type.dart';
|
||||
export 'src/entities/enums/payment_status.dart';
|
||||
export 'src/entities/enums/shift_status.dart';
|
||||
export 'src/entities/enums/staff_industry.dart';
|
||||
export 'src/entities/enums/staff_skill.dart';
|
||||
export 'src/entities/enums/staff_status.dart';
|
||||
export 'src/entities/enums/user_role.dart';
|
||||
|
||||
// Core
|
||||
export 'src/core/services/api_services/api_endpoint.dart';
|
||||
export 'src/core/services/api_services/api_response.dart';
|
||||
export 'src/core/services/api_services/base_api_service.dart';
|
||||
export 'src/core/services/api_services/base_core_service.dart';
|
||||
@@ -22,124 +41,90 @@ export 'src/core/models/device_location.dart';
|
||||
// Users & Membership
|
||||
export 'src/entities/users/user.dart';
|
||||
export 'src/entities/users/staff.dart';
|
||||
export 'src/entities/users/membership.dart';
|
||||
export 'src/entities/users/biz_member.dart';
|
||||
export 'src/entities/users/hub_member.dart';
|
||||
export 'src/entities/users/staff_session.dart';
|
||||
export 'src/entities/users/client_session.dart';
|
||||
|
||||
// Business & Organization
|
||||
export 'src/entities/business/business.dart';
|
||||
export 'src/entities/business/business_setting.dart';
|
||||
export 'src/entities/business/hub.dart';
|
||||
export 'src/entities/business/hub_department.dart';
|
||||
export 'src/entities/business/vendor.dart';
|
||||
export 'src/entities/business/cost_center.dart';
|
||||
|
||||
// Events & Assignments
|
||||
export 'src/entities/events/event.dart';
|
||||
export 'src/entities/events/event_shift.dart';
|
||||
export 'src/entities/events/event_shift_position.dart';
|
||||
export 'src/entities/events/assignment.dart';
|
||||
export 'src/entities/events/work_session.dart';
|
||||
export 'src/entities/business/vendor_role.dart';
|
||||
export 'src/entities/business/hub_manager.dart';
|
||||
export 'src/entities/business/team_member.dart';
|
||||
|
||||
// Shifts
|
||||
export 'src/entities/shifts/shift.dart';
|
||||
export 'src/adapters/shifts/shift_adapter.dart';
|
||||
export 'src/entities/shifts/break/break.dart';
|
||||
export 'src/adapters/shifts/break/break_adapter.dart';
|
||||
export 'src/entities/shifts/today_shift.dart';
|
||||
export 'src/entities/shifts/assigned_shift.dart';
|
||||
export 'src/entities/shifts/open_shift.dart';
|
||||
export 'src/entities/shifts/pending_assignment.dart';
|
||||
export 'src/entities/shifts/cancelled_shift.dart';
|
||||
export 'src/entities/shifts/completed_shift.dart';
|
||||
export 'src/entities/shifts/shift_detail.dart';
|
||||
|
||||
// Orders & Requests
|
||||
export 'src/entities/orders/one_time_order.dart';
|
||||
export 'src/entities/orders/one_time_order_position.dart';
|
||||
export 'src/entities/orders/recurring_order.dart';
|
||||
export 'src/entities/orders/recurring_order_position.dart';
|
||||
export 'src/entities/orders/permanent_order.dart';
|
||||
export 'src/entities/orders/permanent_order_position.dart';
|
||||
export 'src/entities/orders/order_type.dart';
|
||||
// Orders
|
||||
export 'src/entities/orders/order_item.dart';
|
||||
export 'src/entities/orders/reorder_data.dart';
|
||||
|
||||
// Skills & Certs
|
||||
export 'src/entities/skills/skill.dart';
|
||||
export 'src/entities/skills/skill_category.dart';
|
||||
export 'src/entities/skills/staff_skill.dart';
|
||||
export 'src/entities/skills/certificate.dart';
|
||||
export 'src/entities/skills/skill_kit.dart';
|
||||
export 'src/entities/orders/assigned_worker_summary.dart';
|
||||
export 'src/entities/orders/order_preview.dart';
|
||||
export 'src/entities/orders/recent_order.dart';
|
||||
|
||||
// Financial & Payroll
|
||||
export 'src/entities/benefits/benefit.dart';
|
||||
export 'src/entities/financial/invoice.dart';
|
||||
export 'src/entities/financial/time_card.dart';
|
||||
export 'src/entities/financial/invoice_item.dart';
|
||||
export 'src/entities/financial/invoice_decline.dart';
|
||||
export 'src/entities/financial/staff_payment.dart';
|
||||
export 'src/entities/financial/billing_account.dart';
|
||||
export 'src/entities/financial/current_bill.dart';
|
||||
export 'src/entities/financial/savings.dart';
|
||||
export 'src/entities/financial/spend_item.dart';
|
||||
export 'src/entities/financial/bank_account.dart';
|
||||
export 'src/entities/financial/payment_summary.dart';
|
||||
export 'src/entities/financial/billing_period.dart';
|
||||
export 'src/entities/financial/bank_account/bank_account.dart';
|
||||
export 'src/entities/financial/bank_account/business_bank_account.dart';
|
||||
export 'src/entities/financial/bank_account/staff_bank_account.dart';
|
||||
export 'src/adapters/financial/bank_account/bank_account_adapter.dart';
|
||||
export 'src/entities/financial/staff_payment.dart';
|
||||
export 'src/entities/financial/payment_chart_point.dart';
|
||||
export 'src/entities/financial/time_card.dart';
|
||||
|
||||
// Profile
|
||||
export 'src/entities/profile/staff_document.dart';
|
||||
export 'src/entities/profile/document_verification_status.dart';
|
||||
export 'src/entities/profile/staff_certificate.dart';
|
||||
export 'src/entities/profile/compliance_type.dart';
|
||||
export 'src/entities/profile/staff_certificate_status.dart';
|
||||
export 'src/entities/profile/staff_certificate_validation_status.dart';
|
||||
export 'src/entities/profile/attire_item.dart';
|
||||
export 'src/entities/profile/attire_verification_status.dart';
|
||||
export 'src/entities/profile/relationship_type.dart';
|
||||
export 'src/entities/profile/industry.dart';
|
||||
export 'src/entities/profile/tax_form.dart';
|
||||
|
||||
// Ratings & Penalties
|
||||
export 'src/entities/ratings/staff_rating.dart';
|
||||
export 'src/entities/ratings/penalty_log.dart';
|
||||
export 'src/entities/ratings/business_staff_preference.dart';
|
||||
|
||||
// Staff Profile
|
||||
export 'src/entities/profile/staff_personal_info.dart';
|
||||
export 'src/entities/profile/profile_section_status.dart';
|
||||
export 'src/entities/profile/profile_completion.dart';
|
||||
export 'src/entities/profile/profile_document.dart';
|
||||
export 'src/entities/profile/certificate.dart';
|
||||
export 'src/entities/profile/emergency_contact.dart';
|
||||
export 'src/entities/profile/tax_form.dart';
|
||||
export 'src/entities/profile/privacy_settings.dart';
|
||||
export 'src/entities/profile/attire_checklist.dart';
|
||||
export 'src/entities/profile/accessibility.dart';
|
||||
export 'src/entities/profile/schedule.dart';
|
||||
|
||||
// Support & Config
|
||||
export 'src/entities/support/addon.dart';
|
||||
export 'src/entities/support/tag.dart';
|
||||
export 'src/entities/support/media.dart';
|
||||
export 'src/entities/support/working_area.dart';
|
||||
// Ratings
|
||||
export 'src/entities/ratings/staff_rating.dart';
|
||||
|
||||
// Home
|
||||
export 'src/entities/home/home_dashboard_data.dart';
|
||||
export 'src/entities/home/reorder_item.dart';
|
||||
export 'src/entities/home/client_dashboard.dart';
|
||||
export 'src/entities/home/spending_summary.dart';
|
||||
export 'src/entities/home/coverage_metrics.dart';
|
||||
export 'src/entities/home/live_activity_metrics.dart';
|
||||
export 'src/entities/home/staff_dashboard.dart';
|
||||
|
||||
// Availability
|
||||
export 'src/adapters/availability/availability_adapter.dart';
|
||||
// Clock-In & Availability
|
||||
export 'src/entities/clock_in/attendance_status.dart';
|
||||
export 'src/adapters/clock_in/clock_in_adapter.dart';
|
||||
export 'src/entities/availability/availability_slot.dart';
|
||||
export 'src/entities/availability/day_availability.dart';
|
||||
export 'src/entities/availability/availability_day.dart';
|
||||
export 'src/entities/availability/time_slot.dart';
|
||||
|
||||
// Coverage
|
||||
export 'src/entities/coverage_domain/coverage_shift.dart';
|
||||
export 'src/entities/coverage_domain/coverage_worker.dart';
|
||||
export 'src/entities/coverage_domain/shift_with_workers.dart';
|
||||
export 'src/entities/coverage_domain/assigned_worker.dart';
|
||||
export 'src/entities/coverage_domain/time_range.dart';
|
||||
export 'src/entities/coverage_domain/coverage_stats.dart';
|
||||
export 'src/entities/coverage_domain/core_team_member.dart';
|
||||
|
||||
// Adapters
|
||||
export 'src/adapters/profile/emergency_contact_adapter.dart';
|
||||
export 'src/adapters/profile/experience_adapter.dart';
|
||||
export 'src/entities/profile/experience_skill.dart';
|
||||
export 'src/adapters/profile/bank_account_adapter.dart';
|
||||
export 'src/adapters/profile/tax_form_adapter.dart';
|
||||
export 'src/adapters/financial/payment_adapter.dart';
|
||||
// Reports
|
||||
export 'src/entities/reports/report_summary.dart';
|
||||
export 'src/entities/reports/daily_ops_report.dart';
|
||||
export 'src/entities/reports/spend_data_point.dart';
|
||||
export 'src/entities/reports/coverage_report.dart';
|
||||
export 'src/entities/reports/forecast_report.dart';
|
||||
export 'src/entities/reports/performance_report.dart';
|
||||
export 'src/entities/reports/no_show_report.dart';
|
||||
|
||||
// Exceptions
|
||||
export 'src/exceptions/app_exception.dart';
|
||||
|
||||
// Reports
|
||||
export 'src/entities/reports/daily_ops_report.dart';
|
||||
export 'src/entities/reports/spend_report.dart';
|
||||
export 'src/entities/reports/coverage_report.dart';
|
||||
export 'src/entities/reports/forecast_report.dart';
|
||||
export 'src/entities/reports/no_show_report.dart';
|
||||
export 'src/entities/reports/performance_report.dart';
|
||||
export 'src/entities/reports/reports_summary.dart';
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import '../../entities/availability/availability_slot.dart';
|
||||
|
||||
/// Adapter for [AvailabilitySlot] domain entity.
|
||||
class AvailabilityAdapter {
|
||||
static const Map<String, Map<String, String>> _slotDefinitions = <String, Map<String, String>>{
|
||||
'MORNING': <String, String>{
|
||||
'id': 'morning',
|
||||
'label': 'Morning',
|
||||
'timeRange': '4:00 AM - 12:00 PM',
|
||||
},
|
||||
'AFTERNOON': <String, String>{
|
||||
'id': 'afternoon',
|
||||
'label': 'Afternoon',
|
||||
'timeRange': '12:00 PM - 6:00 PM',
|
||||
},
|
||||
'EVENING': <String, String>{
|
||||
'id': 'evening',
|
||||
'label': 'Evening',
|
||||
'timeRange': '6:00 PM - 12:00 AM',
|
||||
},
|
||||
};
|
||||
|
||||
/// Converts a backend slot name (e.g. 'MORNING') to a Domain [AvailabilitySlot].
|
||||
static AvailabilitySlot fromPrimitive(String slotName, {bool isAvailable = false}) {
|
||||
final Map<String, String> def = _slotDefinitions[slotName.toUpperCase()] ?? _slotDefinitions['MORNING']!;
|
||||
return AvailabilitySlot(
|
||||
id: def['id']!,
|
||||
label: def['label']!,
|
||||
timeRange: def['timeRange']!,
|
||||
isAvailable: isAvailable,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import '../../entities/clock_in/attendance_status.dart';
|
||||
|
||||
/// Adapter for Clock In related data.
|
||||
class ClockInAdapter {
|
||||
|
||||
/// Converts primitive attendance data to [AttendanceStatus].
|
||||
static AttendanceStatus toAttendanceStatus({
|
||||
required String status,
|
||||
DateTime? checkInTime,
|
||||
DateTime? checkOutTime,
|
||||
String? activeShiftId,
|
||||
String? activeApplicationId,
|
||||
}) {
|
||||
final bool isCheckedIn = status == 'CHECKED_IN' || status == 'LATE'; // Assuming LATE is also checked in?
|
||||
|
||||
// Statuses that imply active attendance: CHECKED_IN, LATE.
|
||||
// Statuses that imply completed: CHECKED_OUT.
|
||||
|
||||
return AttendanceStatus(
|
||||
isCheckedIn: isCheckedIn,
|
||||
checkInTime: checkInTime,
|
||||
checkOutTime: checkOutTime,
|
||||
activeShiftId: activeShiftId,
|
||||
activeApplicationId: activeApplicationId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import '../../../entities/financial/bank_account/business_bank_account.dart';
|
||||
|
||||
/// Adapter for [BusinessBankAccount] to map data layer values to domain entity.
|
||||
class BusinessBankAccountAdapter {
|
||||
/// Maps primitive values to [BusinessBankAccount].
|
||||
static BusinessBankAccount fromPrimitives({
|
||||
required String id,
|
||||
required String bank,
|
||||
required String last4,
|
||||
required bool isPrimary,
|
||||
DateTime? expiryTime,
|
||||
}) {
|
||||
return BusinessBankAccount(
|
||||
id: id,
|
||||
bankName: bank,
|
||||
last4: last4,
|
||||
isPrimary: isPrimary,
|
||||
expiryTime: expiryTime,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import '../../entities/financial/staff_payment.dart';
|
||||
|
||||
/// Adapter for Payment related data.
|
||||
class PaymentAdapter {
|
||||
|
||||
/// Converts string status to [PaymentStatus].
|
||||
static PaymentStatus toPaymentStatus(String status) {
|
||||
switch (status) {
|
||||
case 'PAID':
|
||||
return PaymentStatus.paid;
|
||||
case 'PENDING':
|
||||
return PaymentStatus.pending;
|
||||
case 'FAILED':
|
||||
return PaymentStatus.failed;
|
||||
default:
|
||||
return PaymentStatus.unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import '../../entities/financial/time_card.dart';
|
||||
|
||||
/// Adapter for [TimeCard] to map data layer values to domain entity.
|
||||
class TimeCardAdapter {
|
||||
/// Maps primitive values to [TimeCard].
|
||||
static TimeCard fromPrimitives({
|
||||
required String id,
|
||||
required String shiftTitle,
|
||||
required String clientName,
|
||||
required DateTime date,
|
||||
required String startTime,
|
||||
required String endTime,
|
||||
required double totalHours,
|
||||
required double hourlyRate,
|
||||
required double totalPay,
|
||||
required String status,
|
||||
String? location,
|
||||
}) {
|
||||
return TimeCard(
|
||||
id: id,
|
||||
shiftTitle: shiftTitle,
|
||||
clientName: clientName,
|
||||
date: date,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
totalHours: totalHours,
|
||||
hourlyRate: hourlyRate,
|
||||
totalPay: totalPay,
|
||||
status: _stringToStatus(status),
|
||||
location: location,
|
||||
);
|
||||
}
|
||||
|
||||
static TimeCardStatus _stringToStatus(String status) {
|
||||
switch (status.toUpperCase()) {
|
||||
case 'CHECKED_OUT':
|
||||
case 'COMPLETED':
|
||||
return TimeCardStatus.approved; // Assuming completed = approved for now
|
||||
case 'PAID':
|
||||
return TimeCardStatus.paid; // If this status exists
|
||||
case 'DISPUTED':
|
||||
return TimeCardStatus.disputed;
|
||||
case 'CHECKED_IN':
|
||||
case 'CONFIRMED':
|
||||
default:
|
||||
return TimeCardStatus.pending;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import '../../entities/financial/bank_account/staff_bank_account.dart';
|
||||
|
||||
/// Adapter for [StaffBankAccount] to map data layer values to domain entity.
|
||||
class BankAccountAdapter {
|
||||
/// Maps primitive values to [StaffBankAccount].
|
||||
static StaffBankAccount fromPrimitives({
|
||||
required String id,
|
||||
required String userId,
|
||||
required String bankName,
|
||||
required String? type,
|
||||
String? accountNumber,
|
||||
String? last4,
|
||||
String? sortCode,
|
||||
bool? isPrimary,
|
||||
}) {
|
||||
return StaffBankAccount(
|
||||
id: id,
|
||||
userId: userId,
|
||||
bankName: bankName,
|
||||
accountNumber: accountNumber ?? '',
|
||||
accountName: '', // Not provided by backend
|
||||
last4: last4,
|
||||
sortCode: sortCode,
|
||||
type: _stringToType(type),
|
||||
isPrimary: isPrimary ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
static StaffBankAccountType _stringToType(String? value) {
|
||||
if (value == null) return StaffBankAccountType.checking;
|
||||
try {
|
||||
// Assuming backend enum names match or are uppercase
|
||||
return StaffBankAccountType.values.firstWhere(
|
||||
(StaffBankAccountType e) => e.name.toLowerCase() == value.toLowerCase(),
|
||||
orElse: () => StaffBankAccountType.other,
|
||||
);
|
||||
} catch (_) {
|
||||
return StaffBankAccountType.other;
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts domain type to string for backend.
|
||||
static String typeToString(StaffBankAccountType type) {
|
||||
switch (type) {
|
||||
case StaffBankAccountType.checking:
|
||||
return 'CHECKING';
|
||||
case StaffBankAccountType.savings:
|
||||
return 'SAVINGS';
|
||||
default:
|
||||
return 'CHECKING';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import '../../entities/profile/emergency_contact.dart';
|
||||
|
||||
/// Adapter for [EmergencyContact] to map data layer values to domain entity.
|
||||
class EmergencyContactAdapter {
|
||||
/// Maps primitive values to [EmergencyContact].
|
||||
static EmergencyContact fromPrimitives({
|
||||
required String id,
|
||||
required String name,
|
||||
required String phone,
|
||||
String? relationship,
|
||||
}) {
|
||||
return EmergencyContact(
|
||||
id: id,
|
||||
name: name,
|
||||
phone: phone,
|
||||
relationship: EmergencyContact.stringToRelationshipType(relationship),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/// Adapter for Experience data (skills/industries) to map data layer values to domain models.
|
||||
class ExperienceAdapter {
|
||||
/// Converts a dynamic list (from backend AnyValue) to List<String>.
|
||||
///
|
||||
/// Handles nulls and converts elements to Strings.
|
||||
static List<String> fromDynamicList(dynamic data) {
|
||||
if (data == null) return <String>[];
|
||||
|
||||
if (data is List) {
|
||||
return data
|
||||
.where((dynamic e) => e != null)
|
||||
.map((dynamic e) => e.toString())
|
||||
.toList();
|
||||
}
|
||||
|
||||
return <String>[];
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import '../../entities/profile/tax_form.dart';
|
||||
|
||||
/// Adapter for [TaxForm] to map data layer values to domain entity.
|
||||
class TaxFormAdapter {
|
||||
/// Maps primitive values to [TaxForm].
|
||||
static TaxForm fromPrimitives({
|
||||
required String id,
|
||||
required String type,
|
||||
required String title,
|
||||
String? subtitle,
|
||||
String? description,
|
||||
required String status,
|
||||
String? staffId,
|
||||
dynamic formData,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
final TaxFormType formType = _stringToType(type);
|
||||
final TaxFormStatus formStatus = _stringToStatus(status);
|
||||
final Map<String, dynamic> formDetails =
|
||||
formData is Map ? Map<String, dynamic>.from(formData) : <String, dynamic>{};
|
||||
|
||||
if (formType == TaxFormType.i9) {
|
||||
return I9TaxForm(
|
||||
id: id,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
description: description,
|
||||
status: formStatus,
|
||||
staffId: staffId,
|
||||
formData: formDetails,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
} else {
|
||||
return W4TaxForm(
|
||||
id: id,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
description: description,
|
||||
status: formStatus,
|
||||
staffId: staffId,
|
||||
formData: formDetails,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static TaxFormType _stringToType(String? value) {
|
||||
if (value == null) return TaxFormType.i9;
|
||||
try {
|
||||
return TaxFormType.values.firstWhere(
|
||||
(TaxFormType e) => e.name.toLowerCase() == value.toLowerCase(),
|
||||
orElse: () => TaxFormType.i9,
|
||||
);
|
||||
} catch (_) {
|
||||
return TaxFormType.i9;
|
||||
}
|
||||
}
|
||||
|
||||
static TaxFormStatus _stringToStatus(String? value) {
|
||||
if (value == null) return TaxFormStatus.notStarted;
|
||||
try {
|
||||
final String normalizedValue = value.replaceAll('_', '').toLowerCase();
|
||||
// map DRAFT to inProgress
|
||||
if (normalizedValue == 'draft') return TaxFormStatus.inProgress;
|
||||
|
||||
return TaxFormStatus.values.firstWhere(
|
||||
(TaxFormStatus e) {
|
||||
// Handle differences like not_started vs notStarted if any,
|
||||
// but standardizing to lowercase is a good start.
|
||||
// The enum names are camelCase in Dart, but might be SNAKE_CASE from backend.
|
||||
final String normalizedEnum = e.name.toLowerCase();
|
||||
return normalizedValue == normalizedEnum;
|
||||
},
|
||||
orElse: () => TaxFormStatus.notStarted,
|
||||
);
|
||||
} catch (_) {
|
||||
return TaxFormStatus.notStarted;
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts domain [TaxFormType] to string for backend.
|
||||
static String typeToString(TaxFormType type) {
|
||||
return type.name.toUpperCase();
|
||||
}
|
||||
|
||||
/// Converts domain [TaxFormStatus] to string for backend.
|
||||
static String statusToString(TaxFormStatus status) {
|
||||
switch (status) {
|
||||
case TaxFormStatus.notStarted:
|
||||
return 'NOT_STARTED';
|
||||
case TaxFormStatus.inProgress:
|
||||
return 'DRAFT';
|
||||
case TaxFormStatus.submitted:
|
||||
return 'SUBMITTED';
|
||||
case TaxFormStatus.approved:
|
||||
return 'APPROVED';
|
||||
case TaxFormStatus.rejected:
|
||||
return 'REJECTED';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import '../../../entities/shifts/break/break.dart';
|
||||
|
||||
/// Adapter for Break related data.
|
||||
class BreakAdapter {
|
||||
/// Maps break data to a Break entity.
|
||||
///
|
||||
/// [isPaid] whether the break is paid.
|
||||
/// [breakTime] the string representation of the break duration (e.g., 'MIN_10', 'MIN_30').
|
||||
static Break fromData({
|
||||
required bool isPaid,
|
||||
required String? breakTime,
|
||||
}) {
|
||||
return Break(
|
||||
isBreakPaid: isPaid,
|
||||
duration: _parseDuration(breakTime),
|
||||
);
|
||||
}
|
||||
|
||||
static BreakDuration _parseDuration(String? breakTime) {
|
||||
if (breakTime == null) return BreakDuration.none;
|
||||
|
||||
switch (breakTime.toUpperCase()) {
|
||||
case 'MIN_10':
|
||||
return BreakDuration.ten;
|
||||
case 'MIN_15':
|
||||
return BreakDuration.fifteen;
|
||||
case 'MIN_20':
|
||||
return BreakDuration.twenty;
|
||||
case 'MIN_30':
|
||||
return BreakDuration.thirty;
|
||||
case 'MIN_45':
|
||||
return BreakDuration.fortyFive;
|
||||
case 'MIN_60':
|
||||
return BreakDuration.sixty;
|
||||
default:
|
||||
return BreakDuration.none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../entities/shifts/shift.dart';
|
||||
|
||||
/// Adapter for Shift related data.
|
||||
class ShiftAdapter {
|
||||
/// Maps application data to a Shift entity.
|
||||
///
|
||||
/// This method handles the common mapping logic used across different
|
||||
/// repositories when converting application data from Data Connect to
|
||||
/// domain Shift entities.
|
||||
static Shift fromApplicationData({
|
||||
required String shiftId,
|
||||
required String roleId,
|
||||
required String roleName,
|
||||
required String businessName,
|
||||
String? companyLogoUrl,
|
||||
required double costPerHour,
|
||||
String? shiftLocation,
|
||||
required String teamHubName,
|
||||
DateTime? shiftDate,
|
||||
DateTime? startTime,
|
||||
DateTime? endTime,
|
||||
DateTime? createdAt,
|
||||
required String status,
|
||||
String? description,
|
||||
int? durationDays,
|
||||
required int count,
|
||||
int? assigned,
|
||||
String? eventName,
|
||||
bool hasApplied = false,
|
||||
}) {
|
||||
final String orderName = (eventName ?? '').trim().isNotEmpty
|
||||
? eventName!
|
||||
: businessName;
|
||||
final String title = '$roleName - $orderName';
|
||||
|
||||
return Shift(
|
||||
id: shiftId,
|
||||
roleId: roleId,
|
||||
title: title,
|
||||
clientName: businessName,
|
||||
logoUrl: companyLogoUrl,
|
||||
hourlyRate: costPerHour,
|
||||
location: shiftLocation ?? '',
|
||||
locationAddress: teamHubName,
|
||||
date: shiftDate?.toIso8601String() ?? '',
|
||||
startTime: startTime != null ? DateFormat('HH:mm').format(startTime) : '',
|
||||
endTime: endTime != null ? DateFormat('HH:mm').format(endTime) : '',
|
||||
createdDate: createdAt?.toIso8601String() ?? '',
|
||||
status: status,
|
||||
description: description,
|
||||
durationDays: durationDays,
|
||||
requiredSlots: count,
|
||||
filledSlots: assigned ?? 0,
|
||||
hasApplied: hasApplied,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/// Represents an API endpoint with its path and required scopes for future
|
||||
/// feature gating.
|
||||
class ApiEndpoint {
|
||||
/// Creates an [ApiEndpoint] with the given [path] and optional
|
||||
/// [requiredScopes].
|
||||
const ApiEndpoint(this.path, {this.requiredScopes = const <String>[]});
|
||||
|
||||
/// The relative URL path (e.g. '/auth/client/sign-in').
|
||||
final String path;
|
||||
|
||||
/// Scopes required to access this endpoint. Empty means no gate.
|
||||
final List<String> requiredScopes;
|
||||
|
||||
@override
|
||||
String toString() => path;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ class ApiResponse {
|
||||
this.errors = const <String, dynamic>{},
|
||||
});
|
||||
|
||||
/// The response code (e.g., '200', '404', or custom error code).
|
||||
/// The response code (e.g., '200', '404', or V2 error code like 'VALIDATION_ERROR').
|
||||
final String code;
|
||||
|
||||
/// A descriptive message about the response.
|
||||
@@ -19,4 +19,13 @@ class ApiResponse {
|
||||
|
||||
/// A map of field-specific error messages, if any.
|
||||
final Map<String, dynamic> errors;
|
||||
|
||||
/// Whether the response indicates success (HTTP 2xx).
|
||||
bool get isSuccess {
|
||||
final int? statusCode = int.tryParse(code);
|
||||
return statusCode != null && statusCode >= 200 && statusCode < 300;
|
||||
}
|
||||
|
||||
/// Whether the response indicates failure.
|
||||
bool get isFailure => !isSuccess;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
import 'api_endpoint.dart';
|
||||
import 'api_response.dart';
|
||||
|
||||
/// Abstract base class for API services.
|
||||
///
|
||||
/// This defines the contract for making HTTP requests.
|
||||
/// Methods accept [ApiEndpoint] which carries the path and required scopes.
|
||||
/// Implementations should validate scopes via [FeatureGate] before executing.
|
||||
abstract class BaseApiService {
|
||||
/// Performs a GET request to the specified [endpoint].
|
||||
Future<ApiResponse> get(String endpoint, {Map<String, dynamic>? params});
|
||||
Future<ApiResponse> get(ApiEndpoint endpoint, {Map<String, dynamic>? params});
|
||||
|
||||
/// Performs a POST request to the specified [endpoint].
|
||||
Future<ApiResponse> post(
|
||||
String endpoint, {
|
||||
ApiEndpoint endpoint, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? params,
|
||||
});
|
||||
|
||||
/// Performs a PUT request to the specified [endpoint].
|
||||
Future<ApiResponse> put(
|
||||
String endpoint, {
|
||||
ApiEndpoint endpoint, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? params,
|
||||
});
|
||||
|
||||
/// Performs a PATCH request to the specified [endpoint].
|
||||
Future<ApiResponse> patch(
|
||||
String endpoint, {
|
||||
ApiEndpoint endpoint, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? params,
|
||||
});
|
||||
|
||||
/// Performs a DELETE request to the specified [endpoint].
|
||||
Future<ApiResponse> delete(
|
||||
ApiEndpoint endpoint, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? params,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../../../exceptions/app_exception.dart';
|
||||
import 'api_response.dart';
|
||||
import 'base_api_service.dart';
|
||||
|
||||
@@ -14,10 +15,13 @@ abstract class BaseCoreService {
|
||||
|
||||
/// Standardized wrapper to execute API actions.
|
||||
///
|
||||
/// This handles generic error normalization for unexpected non-HTTP errors.
|
||||
/// Rethrows [AppException] subclasses (domain errors) directly.
|
||||
/// Wraps unexpected non-HTTP errors into an error [ApiResponse].
|
||||
Future<ApiResponse> action(Future<ApiResponse> Function() execution) async {
|
||||
try {
|
||||
return await execution();
|
||||
} on AppException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
return ApiResponse(
|
||||
code: 'CORE_INTERNAL_ERROR',
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import 'package:krow_domain/src/entities/availability/time_slot.dart';
|
||||
import 'package:krow_domain/src/entities/enums/availability_status.dart';
|
||||
|
||||
/// Availability for a single calendar date.
|
||||
///
|
||||
/// Returned by `GET /staff/availability`. The backend generates one entry
|
||||
/// per date in the requested range by projecting the staff member's
|
||||
/// recurring weekly availability pattern.
|
||||
class AvailabilityDay extends Equatable {
|
||||
/// Creates an [AvailabilityDay].
|
||||
const AvailabilityDay({
|
||||
required this.date,
|
||||
required this.dayOfWeek,
|
||||
required this.availabilityStatus,
|
||||
this.slots = const <TimeSlot>[],
|
||||
});
|
||||
|
||||
/// Deserialises from the V2 API JSON response.
|
||||
factory AvailabilityDay.fromJson(Map<String, dynamic> json) {
|
||||
final dynamic rawSlots = json['slots'];
|
||||
final List<TimeSlot> parsedSlots = rawSlots is List<dynamic>
|
||||
? rawSlots
|
||||
.map((dynamic e) =>
|
||||
TimeSlot.fromJson(e as Map<String, dynamic>))
|
||||
.toList()
|
||||
: <TimeSlot>[];
|
||||
|
||||
return AvailabilityDay(
|
||||
date: json['date'] as String,
|
||||
dayOfWeek: json['dayOfWeek'] as int,
|
||||
availabilityStatus:
|
||||
AvailabilityStatus.fromJson(json['availabilityStatus'] as String?),
|
||||
slots: parsedSlots,
|
||||
);
|
||||
}
|
||||
|
||||
/// ISO date string (`YYYY-MM-DD`).
|
||||
final String date;
|
||||
|
||||
/// Day of week (0 = Sunday, 6 = Saturday).
|
||||
final int dayOfWeek;
|
||||
|
||||
/// Availability status for this day.
|
||||
final AvailabilityStatus availabilityStatus;
|
||||
|
||||
/// Time slots when the worker is available (relevant for `PARTIAL`).
|
||||
final List<TimeSlot> slots;
|
||||
|
||||
/// Whether the worker has any availability on this day.
|
||||
bool get isAvailable => availabilityStatus != AvailabilityStatus.unavailable;
|
||||
|
||||
/// Creates a copy with the given fields replaced.
|
||||
AvailabilityDay copyWith({
|
||||
String? date,
|
||||
int? dayOfWeek,
|
||||
AvailabilityStatus? availabilityStatus,
|
||||
List<TimeSlot>? slots,
|
||||
}) {
|
||||
return AvailabilityDay(
|
||||
date: date ?? this.date,
|
||||
dayOfWeek: dayOfWeek ?? this.dayOfWeek,
|
||||
availabilityStatus: availabilityStatus ?? this.availabilityStatus,
|
||||
slots: slots ?? this.slots,
|
||||
);
|
||||
}
|
||||
|
||||
/// Serialises to JSON.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'date': date,
|
||||
'dayOfWeek': dayOfWeek,
|
||||
'availabilityStatus': availabilityStatus.toJson(),
|
||||
'slots': slots.map((TimeSlot s) => s.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
date,
|
||||
dayOfWeek,
|
||||
availabilityStatus,
|
||||
slots,
|
||||
];
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Represents a specific time slot within a day (e.g., Morning, Afternoon, Evening).
|
||||
class AvailabilitySlot extends Equatable {
|
||||
|
||||
const AvailabilitySlot({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.timeRange,
|
||||
this.isAvailable = true,
|
||||
});
|
||||
final String id;
|
||||
final String label;
|
||||
final String timeRange;
|
||||
final bool isAvailable;
|
||||
|
||||
AvailabilitySlot copyWith({
|
||||
String? id,
|
||||
String? label,
|
||||
String? timeRange,
|
||||
bool? isAvailable,
|
||||
}) {
|
||||
return AvailabilitySlot(
|
||||
id: id ?? this.id,
|
||||
label: label ?? this.label,
|
||||
timeRange: timeRange ?? this.timeRange,
|
||||
isAvailable: isAvailable ?? this.isAvailable,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[id, label, timeRange, isAvailable];
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import 'availability_slot.dart';
|
||||
|
||||
/// Represents availability configuration for a specific date.
|
||||
class DayAvailability extends Equatable {
|
||||
|
||||
const DayAvailability({
|
||||
required this.date,
|
||||
this.isAvailable = false,
|
||||
this.slots = const <AvailabilitySlot>[],
|
||||
});
|
||||
final DateTime date;
|
||||
final bool isAvailable;
|
||||
final List<AvailabilitySlot> slots;
|
||||
|
||||
DayAvailability copyWith({
|
||||
DateTime? date,
|
||||
bool? isAvailable,
|
||||
List<AvailabilitySlot>? slots,
|
||||
}) {
|
||||
return DayAvailability(
|
||||
date: date ?? this.date,
|
||||
isAvailable: isAvailable ?? this.isAvailable,
|
||||
slots: slots ?? this.slots,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[date, isAvailable, slots];
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// A time range within a day of availability.
|
||||
///
|
||||
/// Embedded inside [AvailabilityDay.slots]. Times are stored as `HH:MM`
|
||||
/// strings because the backend stores them in a JSONB array and they
|
||||
/// are timezone-agnostic display values.
|
||||
class TimeSlot extends Equatable {
|
||||
/// Creates a [TimeSlot].
|
||||
const TimeSlot({
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
});
|
||||
|
||||
/// 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['start'] as String? ??
|
||||
json['startTime'] as String? ??
|
||||
'00:00',
|
||||
endTime:
|
||||
json['end'] as String? ?? json['endTime'] as String? ?? '00:00',
|
||||
);
|
||||
}
|
||||
|
||||
/// Start time in `HH:MM` format.
|
||||
final String startTime;
|
||||
|
||||
/// End time in `HH:MM` format.
|
||||
final String endTime;
|
||||
|
||||
/// Serialises to JSON matching the V2 API contract.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'start': startTime,
|
||||
'end': endTime,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[startTime, endTime];
|
||||
}
|
||||
@@ -1,26 +1,73 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Represents a staff member's benefit balance.
|
||||
import 'package:krow_domain/src/entities/enums/benefit_status.dart';
|
||||
|
||||
/// A benefit accrued by a staff member (e.g. sick leave, vacation).
|
||||
///
|
||||
/// Returned by `GET /staff/profile/benefits`.
|
||||
class Benefit extends Equatable {
|
||||
/// Creates a [Benefit].
|
||||
/// Creates a [Benefit] instance.
|
||||
const Benefit({
|
||||
required this.benefitId,
|
||||
required this.benefitType,
|
||||
required this.title,
|
||||
required this.entitlementHours,
|
||||
required this.usedHours,
|
||||
required this.status,
|
||||
required this.trackedHours,
|
||||
required this.targetHours,
|
||||
});
|
||||
|
||||
/// The title of the benefit (e.g., Sick Leave, Holiday, Vacation).
|
||||
/// Deserialises a [Benefit] from a V2 API JSON map.
|
||||
factory Benefit.fromJson(Map<String, dynamic> json) {
|
||||
return Benefit(
|
||||
benefitId: json['benefitId'] as String,
|
||||
benefitType: json['benefitType'] as String,
|
||||
title: json['title'] as String,
|
||||
status: BenefitStatus.fromJson(json['status'] as String?),
|
||||
trackedHours: (json['trackedHours'] as num).toInt(),
|
||||
targetHours: (json['targetHours'] as num).toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Unique identifier.
|
||||
final String benefitId;
|
||||
|
||||
/// Type code (e.g. SICK_LEAVE, VACATION).
|
||||
final String benefitType;
|
||||
|
||||
/// Human-readable title.
|
||||
final String title;
|
||||
|
||||
/// The total entitlement in hours.
|
||||
final double entitlementHours;
|
||||
/// Current benefit status.
|
||||
final BenefitStatus status;
|
||||
|
||||
/// The hours used so far.
|
||||
final double usedHours;
|
||||
/// Hours tracked so far.
|
||||
final int trackedHours;
|
||||
|
||||
/// The hours remaining.
|
||||
double get remainingHours => entitlementHours - usedHours;
|
||||
/// Target hours to accrue.
|
||||
final int targetHours;
|
||||
|
||||
/// Remaining hours to reach the target.
|
||||
int get remainingHours => targetHours - trackedHours;
|
||||
|
||||
/// Serialises this [Benefit] to a JSON map.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'benefitId': benefitId,
|
||||
'benefitType': benefitType,
|
||||
'title': title,
|
||||
'status': status.toJson(),
|
||||
'trackedHours': trackedHours,
|
||||
'targetHours': targetHours,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [title, entitlementHours, usedHours];
|
||||
List<Object?> get props => <Object?>[
|
||||
benefitId,
|
||||
benefitType,
|
||||
title,
|
||||
status,
|
||||
trackedHours,
|
||||
targetHours,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Represents a legal or service contract.
|
||||
///
|
||||
/// Can be between a business and the platform, or a business and staff.
|
||||
class BizContract extends Equatable {
|
||||
|
||||
const BizContract({
|
||||
required this.id,
|
||||
required this.businessId,
|
||||
required this.name,
|
||||
required this.startDate,
|
||||
this.endDate,
|
||||
required this.contentUrl,
|
||||
});
|
||||
/// Unique identifier.
|
||||
final String id;
|
||||
|
||||
/// The [Business] party to the contract.
|
||||
final String businessId;
|
||||
|
||||
/// Descriptive name of the contract.
|
||||
final String name;
|
||||
|
||||
/// Valid from date.
|
||||
final DateTime startDate;
|
||||
|
||||
/// Valid until date (null if indefinite).
|
||||
final DateTime? endDate;
|
||||
|
||||
/// URL to the document content (PDF/HTML).
|
||||
final String contentUrl;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[id, businessId, name, startDate, endDate, contentUrl];
|
||||
}
|
||||
@@ -1,47 +1,111 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// The operating status of a [Business].
|
||||
enum BusinessStatus {
|
||||
/// Business created but not yet approved.
|
||||
pending,
|
||||
import 'package:krow_domain/src/entities/enums/business_status.dart';
|
||||
|
||||
/// Fully active and operational.
|
||||
active,
|
||||
|
||||
/// Temporarily suspended (e.g. for non-payment).
|
||||
suspended,
|
||||
|
||||
/// Permanently inactive.
|
||||
inactive,
|
||||
}
|
||||
|
||||
/// Represents a Client Company / Business.
|
||||
/// A client company registered on the platform.
|
||||
///
|
||||
/// This is the top-level organizational entity in the system.
|
||||
/// Maps to the V2 `businesses` table.
|
||||
class Business extends Equatable {
|
||||
|
||||
/// Creates a [Business] instance.
|
||||
const Business({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.registrationNumber,
|
||||
required this.tenantId,
|
||||
required this.slug,
|
||||
required this.businessName,
|
||||
required this.status,
|
||||
this.avatar,
|
||||
this.contactName,
|
||||
this.contactEmail,
|
||||
this.contactPhone,
|
||||
this.metadata = const <String, dynamic>{},
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
/// Unique identifier for the business.
|
||||
|
||||
/// Deserialises a [Business] from a V2 API JSON map.
|
||||
factory Business.fromJson(Map<String, dynamic> json) {
|
||||
return Business(
|
||||
id: json['id'] as String,
|
||||
tenantId: json['tenantId'] as String,
|
||||
slug: json['slug'] as String,
|
||||
businessName: json['businessName'] as String,
|
||||
status: BusinessStatus.fromJson(json['status'] as String?),
|
||||
contactName: json['contactName'] as String?,
|
||||
contactEmail: json['contactEmail'] as String?,
|
||||
contactPhone: json['contactPhone'] as String?,
|
||||
metadata: json['metadata'] is Map
|
||||
? Map<String, dynamic>.from(json['metadata'] as Map<dynamic, dynamic>)
|
||||
: const <String, dynamic>{},
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'] as String)
|
||||
: null,
|
||||
updatedAt: json['updatedAt'] != null
|
||||
? DateTime.parse(json['updatedAt'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Unique identifier.
|
||||
final String id;
|
||||
|
||||
/// Tenant this business belongs to.
|
||||
final String tenantId;
|
||||
|
||||
/// URL-safe slug.
|
||||
final String slug;
|
||||
|
||||
/// Display name of the business.
|
||||
final String name;
|
||||
final String businessName;
|
||||
|
||||
/// Legal registration or tax number.
|
||||
final String registrationNumber;
|
||||
|
||||
/// Current operating status.
|
||||
/// Current account status.
|
||||
final BusinessStatus status;
|
||||
|
||||
/// URL to the business logo.
|
||||
final String? avatar;
|
||||
/// Primary contact name.
|
||||
final String? contactName;
|
||||
|
||||
/// Primary contact email.
|
||||
final String? contactEmail;
|
||||
|
||||
/// Primary contact phone.
|
||||
final String? contactPhone;
|
||||
|
||||
/// Flexible metadata bag.
|
||||
final Map<String, dynamic> metadata;
|
||||
|
||||
/// When the record was created.
|
||||
final DateTime? createdAt;
|
||||
|
||||
/// When the record was last updated.
|
||||
final DateTime? updatedAt;
|
||||
|
||||
/// Serialises this [Business] to a JSON map.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'id': id,
|
||||
'tenantId': tenantId,
|
||||
'slug': slug,
|
||||
'businessName': businessName,
|
||||
'status': status.toJson(),
|
||||
'contactName': contactName,
|
||||
'contactEmail': contactEmail,
|
||||
'contactPhone': contactPhone,
|
||||
'metadata': metadata,
|
||||
'createdAt': createdAt?.toIso8601String(),
|
||||
'updatedAt': updatedAt?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[id, name, registrationNumber, status, avatar];
|
||||
}
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
tenantId,
|
||||
slug,
|
||||
businessName,
|
||||
status,
|
||||
contactName,
|
||||
contactEmail,
|
||||
contactPhone,
|
||||
metadata,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Represents payroll and operational configuration for a [Business].
|
||||
class BusinessSetting extends Equatable {
|
||||
|
||||
const BusinessSetting({
|
||||
required this.id,
|
||||
required this.businessId,
|
||||
required this.prefix,
|
||||
required this.overtimeEnabled,
|
||||
this.clockInRequirement,
|
||||
this.clockOutRequirement,
|
||||
});
|
||||
/// Unique identifier for the settings record.
|
||||
final String id;
|
||||
|
||||
/// The [Business] these settings apply to.
|
||||
final String businessId;
|
||||
|
||||
/// Prefix for generated invoices (e.g., "INV-").
|
||||
final String prefix;
|
||||
|
||||
/// Whether overtime calculations are applied.
|
||||
final bool overtimeEnabled;
|
||||
|
||||
/// Requirement method for clocking in (e.g. "qr_code", "geo_fence").
|
||||
final String? clockInRequirement;
|
||||
|
||||
/// Requirement method for clocking out.
|
||||
final String? clockOutRequirement;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
businessId,
|
||||
prefix,
|
||||
overtimeEnabled,
|
||||
clockInRequirement,
|
||||
clockOutRequirement,
|
||||
];
|
||||
}
|
||||
@@ -1,22 +1,37 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Represents a financial cost center used for billing and tracking.
|
||||
/// A financial cost center used for billing and tracking.
|
||||
///
|
||||
/// Returned by `GET /client/cost-centers`.
|
||||
class CostCenter extends Equatable {
|
||||
/// Creates a [CostCenter] instance.
|
||||
const CostCenter({
|
||||
required this.id,
|
||||
required this.costCenterId,
|
||||
required this.name,
|
||||
this.code,
|
||||
});
|
||||
|
||||
/// Unique identifier.
|
||||
final String id;
|
||||
/// Deserialises a [CostCenter] from a V2 API JSON map.
|
||||
factory CostCenter.fromJson(Map<String, dynamic> json) {
|
||||
return CostCenter(
|
||||
costCenterId: json['costCenterId'] as String,
|
||||
name: json['name'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
/// Display name of the cost center.
|
||||
/// Unique identifier.
|
||||
final String costCenterId;
|
||||
|
||||
/// Display name.
|
||||
final String name;
|
||||
|
||||
/// Optional alphanumeric code associated with this cost center.
|
||||
final String? code;
|
||||
/// Serialises this [CostCenter] to a JSON map.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'costCenterId': costCenterId,
|
||||
'name': name,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[id, name, code];
|
||||
List<Object?> get props => <Object?>[costCenterId, name];
|
||||
}
|
||||
|
||||
@@ -1,51 +1,107 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import 'cost_center.dart';
|
||||
|
||||
/// The status of a [Hub].
|
||||
enum HubStatus {
|
||||
/// Fully operational.
|
||||
active,
|
||||
|
||||
/// Closed or inactive.
|
||||
inactive,
|
||||
|
||||
/// Not yet ready for operations.
|
||||
underConstruction,
|
||||
}
|
||||
|
||||
/// Represents a branch location or operational unit within a [Business].
|
||||
/// A physical clock-point location (hub) belonging to a business.
|
||||
///
|
||||
/// Maps to the V2 `clock_points` table; returned by `GET /client/hubs`.
|
||||
class Hub extends Equatable {
|
||||
/// Creates a [Hub] instance.
|
||||
const Hub({
|
||||
required this.id,
|
||||
required this.businessId,
|
||||
required this.hubId,
|
||||
required this.name,
|
||||
required this.address,
|
||||
this.fullAddress,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.nfcTagId,
|
||||
required this.status,
|
||||
this.costCenter,
|
||||
this.city,
|
||||
this.state,
|
||||
this.zipCode,
|
||||
this.costCenterId,
|
||||
this.costCenterName,
|
||||
});
|
||||
/// Unique identifier.
|
||||
final String id;
|
||||
|
||||
/// The parent [Business].
|
||||
final String businessId;
|
||||
/// Deserialises a [Hub] from a V2 API JSON map.
|
||||
factory Hub.fromJson(Map<String, dynamic> json) {
|
||||
return Hub(
|
||||
hubId: json['hubId'] as String,
|
||||
name: json['name'] as String,
|
||||
fullAddress: json['fullAddress'] as String?,
|
||||
latitude: json['latitude'] != null
|
||||
? double.parse(json['latitude'].toString())
|
||||
: null,
|
||||
longitude: json['longitude'] != null
|
||||
? double.parse(json['longitude'].toString())
|
||||
: null,
|
||||
nfcTagId: json['nfcTagId'] as String?,
|
||||
city: json['city'] as String?,
|
||||
state: json['state'] as String?,
|
||||
zipCode: json['zipCode'] as String?,
|
||||
costCenterId: json['costCenterId'] as String?,
|
||||
costCenterName: json['costCenterName'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Display name of the hub (e.g. "Downtown Branch").
|
||||
/// Unique identifier (clock_point id).
|
||||
final String hubId;
|
||||
|
||||
/// Display label for the hub.
|
||||
final String name;
|
||||
|
||||
/// Physical address of this hub.
|
||||
final String address;
|
||||
/// Full street address.
|
||||
final String? fullAddress;
|
||||
|
||||
/// Unique identifier of the NFC tag assigned to this hub.
|
||||
/// GPS latitude.
|
||||
final double? latitude;
|
||||
|
||||
/// GPS longitude.
|
||||
final double? longitude;
|
||||
|
||||
/// NFC tag UID assigned to this hub.
|
||||
final String? nfcTagId;
|
||||
|
||||
/// Operational status.
|
||||
final HubStatus status;
|
||||
/// City from metadata.
|
||||
final String? city;
|
||||
|
||||
/// Assigned cost center for this hub.
|
||||
final CostCenter? costCenter;
|
||||
/// State from metadata.
|
||||
final String? state;
|
||||
|
||||
/// Zip code from metadata.
|
||||
final String? zipCode;
|
||||
|
||||
/// Associated cost center ID.
|
||||
final String? costCenterId;
|
||||
|
||||
/// Associated cost center name.
|
||||
final String? costCenterName;
|
||||
|
||||
/// Serialises this [Hub] to a JSON map.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'hubId': hubId,
|
||||
'name': name,
|
||||
'fullAddress': fullAddress,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'nfcTagId': nfcTagId,
|
||||
'city': city,
|
||||
'state': state,
|
||||
'zipCode': zipCode,
|
||||
'costCenterId': costCenterId,
|
||||
'costCenterName': costCenterName,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[id, businessId, name, address, nfcTagId, status, costCenter];
|
||||
List<Object?> get props => <Object?>[
|
||||
hubId,
|
||||
name,
|
||||
fullAddress,
|
||||
latitude,
|
||||
longitude,
|
||||
nfcTagId,
|
||||
city,
|
||||
state,
|
||||
zipCode,
|
||||
costCenterId,
|
||||
costCenterName,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Represents a department within a [Hub].
|
||||
///
|
||||
/// Used for more granular organization of staff and events (e.g. "Kitchen", "Service").
|
||||
class HubDepartment extends Equatable {
|
||||
|
||||
const HubDepartment({
|
||||
required this.id,
|
||||
required this.hubId,
|
||||
required this.name,
|
||||
});
|
||||
/// Unique identifier.
|
||||
final String id;
|
||||
|
||||
/// The [Hub] this department belongs to.
|
||||
final String hubId;
|
||||
|
||||
/// Name of the department.
|
||||
final String name;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[id, hubId, name];
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// A manager assigned to a hub (clock point).
|
||||
///
|
||||
/// Returned by `GET /client/hubs/:id/managers`.
|
||||
class HubManager extends Equatable {
|
||||
/// Creates a [HubManager] instance.
|
||||
const HubManager({
|
||||
required this.managerAssignmentId,
|
||||
required this.businessMembershipId,
|
||||
required this.managerId,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
/// Deserialises a [HubManager] from a V2 API JSON map.
|
||||
factory HubManager.fromJson(Map<String, dynamic> json) {
|
||||
return HubManager(
|
||||
managerAssignmentId: json['managerAssignmentId'] as String,
|
||||
businessMembershipId: json['businessMembershipId'] as String,
|
||||
managerId: json['managerId'] as String,
|
||||
name: json['name'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
/// Primary key of the hub_managers row.
|
||||
final String managerAssignmentId;
|
||||
|
||||
/// Business membership ID of the manager.
|
||||
final String businessMembershipId;
|
||||
|
||||
/// User ID of the manager.
|
||||
final String managerId;
|
||||
|
||||
/// Display name of the manager.
|
||||
final String name;
|
||||
|
||||
/// Serialises this [HubManager] to a JSON map.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'managerAssignmentId': managerAssignmentId,
|
||||
'businessMembershipId': businessMembershipId,
|
||||
'managerId': managerId,
|
||||
'name': name,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
managerAssignmentId,
|
||||
businessMembershipId,
|
||||
managerId,
|
||||
name,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// A member of a business team (business membership + user).
|
||||
///
|
||||
/// Returned by `GET /client/team-members`.
|
||||
class TeamMember extends Equatable {
|
||||
/// Creates a [TeamMember] instance.
|
||||
const TeamMember({
|
||||
required this.businessMembershipId,
|
||||
required this.userId,
|
||||
required this.name,
|
||||
this.email,
|
||||
this.role,
|
||||
});
|
||||
|
||||
/// Deserialises a [TeamMember] from a V2 API JSON map.
|
||||
factory TeamMember.fromJson(Map<String, dynamic> json) {
|
||||
return TeamMember(
|
||||
businessMembershipId: json['businessMembershipId'] as String,
|
||||
userId: json['userId'] as String,
|
||||
name: json['name'] as String,
|
||||
email: json['email'] as String?,
|
||||
role: json['role'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Business membership primary key.
|
||||
final String businessMembershipId;
|
||||
|
||||
/// User ID.
|
||||
final String userId;
|
||||
|
||||
/// Display name.
|
||||
final String name;
|
||||
|
||||
/// Email address.
|
||||
final String? email;
|
||||
|
||||
/// Business role (owner, manager, member, viewer).
|
||||
final String? role;
|
||||
|
||||
/// Serialises this [TeamMember] to a JSON map.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'businessMembershipId': businessMembershipId,
|
||||
'userId': userId,
|
||||
'name': name,
|
||||
'email': email,
|
||||
'role': role,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
businessMembershipId,
|
||||
userId,
|
||||
name,
|
||||
email,
|
||||
role,
|
||||
];
|
||||
}
|
||||
@@ -1,15 +1,86 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Represents a staffing vendor.
|
||||
import 'package:krow_domain/src/entities/enums/business_status.dart';
|
||||
|
||||
/// A staffing vendor that supplies workers to businesses.
|
||||
///
|
||||
/// Maps to the V2 `vendors` table.
|
||||
class Vendor extends Equatable {
|
||||
const Vendor({required this.id, required this.name, required this.rates});
|
||||
/// Creates a [Vendor] instance.
|
||||
const Vendor({
|
||||
required this.id,
|
||||
required this.tenantId,
|
||||
required this.slug,
|
||||
required this.companyName,
|
||||
required this.status,
|
||||
this.contactName,
|
||||
this.contactEmail,
|
||||
this.contactPhone,
|
||||
});
|
||||
|
||||
/// Deserialises a [Vendor] from a V2 API JSON map.
|
||||
factory Vendor.fromJson(Map<String, dynamic> json) {
|
||||
return Vendor(
|
||||
id: json['id'] as String? ?? json['vendorId'] as String,
|
||||
tenantId: json['tenantId'] as String? ?? '',
|
||||
slug: json['slug'] as String? ?? '',
|
||||
companyName: json['companyName'] as String? ??
|
||||
json['vendorName'] as String? ??
|
||||
'',
|
||||
status: BusinessStatus.fromJson(json['status'] as String?),
|
||||
contactName: json['contactName'] as String?,
|
||||
contactEmail: json['contactEmail'] as String?,
|
||||
contactPhone: json['contactPhone'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Unique identifier.
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
/// A map of role names to hourly rates.
|
||||
final Map<String, double> rates;
|
||||
/// Tenant this vendor belongs to.
|
||||
final String tenantId;
|
||||
|
||||
/// URL-safe slug.
|
||||
final String slug;
|
||||
|
||||
/// Display name of the vendor company.
|
||||
final String companyName;
|
||||
|
||||
/// Current account status.
|
||||
final BusinessStatus status;
|
||||
|
||||
/// Primary contact name.
|
||||
final String? contactName;
|
||||
|
||||
/// Primary contact email.
|
||||
final String? contactEmail;
|
||||
|
||||
/// Primary contact phone.
|
||||
final String? contactPhone;
|
||||
|
||||
/// Serialises this [Vendor] to a JSON map.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'id': id,
|
||||
'tenantId': tenantId,
|
||||
'slug': slug,
|
||||
'companyName': companyName,
|
||||
'status': status.toJson(),
|
||||
'contactName': contactName,
|
||||
'contactEmail': contactEmail,
|
||||
'contactPhone': contactPhone,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[id, name, rates];
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
tenantId,
|
||||
slug,
|
||||
companyName,
|
||||
status,
|
||||
contactName,
|
||||
contactEmail,
|
||||
contactPhone,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// A role available through a vendor with its billing rate.
|
||||
///
|
||||
/// Returned by `GET /client/vendors/:id/roles`.
|
||||
class VendorRole extends Equatable {
|
||||
/// Creates a [VendorRole] instance.
|
||||
const VendorRole({
|
||||
required this.roleId,
|
||||
required this.roleCode,
|
||||
required this.roleName,
|
||||
required this.hourlyRateCents,
|
||||
});
|
||||
|
||||
/// Deserialises a [VendorRole] from a V2 API JSON map.
|
||||
factory VendorRole.fromJson(Map<String, dynamic> json) {
|
||||
return VendorRole(
|
||||
roleId: json['roleId'] as String,
|
||||
roleCode: json['roleCode'] as String,
|
||||
roleName: json['roleName'] as String,
|
||||
hourlyRateCents: (json['hourlyRateCents'] as num).toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Unique identifier from the roles catalog.
|
||||
final String roleId;
|
||||
|
||||
/// Short code for the role (e.g. BARISTA).
|
||||
final String roleCode;
|
||||
|
||||
/// Human-readable role name.
|
||||
final String roleName;
|
||||
|
||||
/// Billing rate in cents per hour.
|
||||
final int hourlyRateCents;
|
||||
|
||||
/// Serialises this [VendorRole] to a JSON map.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'roleId': roleId,
|
||||
'roleCode': roleCode,
|
||||
'roleName': roleName,
|
||||
'hourlyRateCents': hourlyRateCents,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[roleId, roleCode, roleName, hourlyRateCents];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user