Merge 592-migrate-frontend-applications-to-v2-backend-and-database into feature/session-persistence-new

This commit is contained in:
2026-03-18 12:51:23 +05:30
660 changed files with 18935 additions and 21383 deletions

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'firebase_options.dart';
import 'src/widgets/session_listener.dart';
@@ -31,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(),

View File

@@ -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();

View File

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

View File

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

View File

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

View File

@@ -12,12 +12,6 @@
@import file_picker;
#endif
#if __has_include(<firebase_app_check/FLTFirebaseAppCheckPlugin.h>)
#import <firebase_app_check/FLTFirebaseAppCheckPlugin.h>
#else
@import firebase_app_check;
#endif
#if __has_include(<firebase_auth/FLTFirebaseAuthPlugin.h>)
#import <firebase_auth/FLTFirebaseAuthPlugin.h>
#else
@@ -42,12 +36,6 @@
@import geolocator_apple;
#endif
#if __has_include(<google_maps_flutter_ios/FLTGoogleMapsPlugin.h>)
#import <google_maps_flutter_ios/FLTGoogleMapsPlugin.h>
#else
@import google_maps_flutter_ios;
#endif
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
#import <image_picker_ios/FLTImagePickerPlugin.h>
#else
@@ -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"]];

View File

@@ -6,7 +6,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krowwithus_staff/firebase_options.dart';
import 'package:staff_authentication/staff_authentication.dart'
as staff_authentication;
@@ -29,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(),

View File

@@ -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();

View File

@@ -7,7 +7,6 @@ import Foundation
import file_picker
import file_selector_macos
import firebase_app_check
import firebase_auth
import firebase_core
import flutter_local_notifications
@@ -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"))

View File

@@ -28,8 +28,6 @@ dependencies:
path: ../../packages/features/staff/staff_main
krow_core:
path: ../../packages/core
krow_data_connect:
path: ../../packages/data_connect
cupertino_icons: ^1.0.8
flutter_modular: ^6.3.0
firebase_core: ^4.4.0

View File

@@ -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';

View File

@@ -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',
);
}

View File

@@ -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>()),

View File

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

View File

@@ -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);
}
}

View File

@@ -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';
}

View File

@@ -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')) {

View File

@@ -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)

View File

@@ -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,

View File

@@ -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,

View File

@@ -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')) {

View File

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

View File

@@ -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');
}

View File

@@ -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');
}

View File

@@ -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');
}

View File

@@ -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');
}

View File

@@ -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',
);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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');
}
}

View File

@@ -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());

View File

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

View File

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

View File

@@ -0,0 +1,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();
}
}
}

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,86 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
/// Mixin to handle Data Layer errors and map them to Domain Failures.
///
/// Use this in Repositories to wrap remote calls.
/// It catches [SocketException], [FirebaseException], etc., and throws [AppException].
mixin DataErrorHandler {
/// Executes a Future and maps low-level exceptions to [AppException].
///
/// [timeout] defaults to 30 seconds.
Future<T> executeProtected<T>(
Future<T> Function() action, {
Duration timeout = const Duration(seconds: 30),
}) async {
try {
return await action().timeout(timeout);
} on TimeoutException {
debugPrint(
'DataErrorHandler: Request timed out after ${timeout.inSeconds}s',
);
throw ServiceUnavailableException(
technicalMessage: 'Request timed out after ${timeout.inSeconds}s',
);
} on SocketException catch (e) {
throw NetworkException(technicalMessage: 'SocketException: ${e.message}');
} on FirebaseException catch (e) {
final String code = e.code.toLowerCase();
final String msg = (e.message ?? '').toLowerCase();
if (code == 'unavailable' ||
code == 'network-request-failed' ||
msg.contains('offline') ||
msg.contains('network') ||
msg.contains('connection failed')) {
debugPrint(
'DataErrorHandler: Firebase network error: ${e.code} - ${e.message}',
);
throw NetworkException(
technicalMessage: 'Firebase ${e.code}: ${e.message}',
);
}
if (code == 'deadline-exceeded') {
debugPrint(
'DataErrorHandler: Firebase timeout error: ${e.code} - ${e.message}',
);
throw ServiceUnavailableException(
technicalMessage: 'Firebase ${e.code}: ${e.message}',
);
}
debugPrint('DataErrorHandler: Firebase error: ${e.code} - ${e.message}');
// Fallback for other Firebase errors
throw ServerException(
technicalMessage: 'Firebase ${e.code}: ${e.message}',
);
} catch (e) {
final String errorStr = e.toString().toLowerCase();
if (errorStr.contains('socketexception') ||
errorStr.contains('network') ||
errorStr.contains('offline') ||
errorStr.contains('connection failed') ||
errorStr.contains('unavailable') ||
errorStr.contains('handshake') ||
errorStr.contains('clientexception') ||
errorStr.contains('failed host lookup') ||
errorStr.contains('connection error') ||
errorStr.contains('grpc error') ||
errorStr.contains('terminated') ||
errorStr.contains('connectexception')) {
debugPrint('DataErrorHandler: Network-related error: $e');
throw NetworkException(technicalMessage: e.toString());
}
// If it's already an AppException, rethrow it
if (e is AppException) rethrow;
// Debugging: Log unexpected errors
debugPrint('DataErrorHandler: Unhandled exception caught: $e');
throw UnknownException(technicalMessage: e.toString());
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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;

View File

@@ -368,7 +368,6 @@ class UiTypography {
fontWeight: FontWeight.w400,
fontSize: 12,
height: 1.5,
letterSpacing: -0.1,
color: UiColors.textPrimary,
);

View File

@@ -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!,
],
],
),
],
),

View File

@@ -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';

View File

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

View File

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

View File

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

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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';
}
}
}

View File

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

View File

@@ -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>[];
}
}

View File

@@ -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';
}
}
}

View File

@@ -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;
}
}
}

View File

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

View File

@@ -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;
}

View File

@@ -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;
}

View File

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

View File

@@ -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',

View File

@@ -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,
];
}

View File

@@ -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];
}

View File

@@ -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];
}

View File

@@ -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];
}

View File

@@ -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,
];
}

View File

@@ -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];
}

View File

@@ -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,
];
}

View File

@@ -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,
];
}

View File

@@ -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];
}

View File

@@ -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,
];
}

View File

@@ -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];
}

View File

@@ -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,
];
}

View File

@@ -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,
];
}

View File

@@ -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,
];
}

View File

@@ -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