feat: Migrate staff profile features from Data Connect to V2 REST API
- Removed data_connect package from mobile pubspec.yaml. - Added documentation for V2 profile migration status and QA findings. - Implemented new session management with ClientSessionStore and StaffSessionStore. - Created V2SessionService for handling user sessions via the V2 API. - Developed use cases for cancelling late worker assignments and submitting worker reviews. - Added arguments and use cases for payment chart retrieval and profile completion checks. - Implemented repository interfaces and their implementations for staff main and profile features. - Ensured proper error handling and validation in use cases.
This commit is contained in:
@@ -20,11 +20,6 @@ public final class GeneratedPluginRegistrant {
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin firebase_app_check, io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.auth.FlutterFirebaseAuthPlugin());
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -12,12 +12,6 @@
|
||||
@import file_picker;
|
||||
#endif
|
||||
|
||||
#if __has_include(<firebase_app_check/FLTFirebaseAppCheckPlugin.h>)
|
||||
#import <firebase_app_check/FLTFirebaseAppCheckPlugin.h>
|
||||
#else
|
||||
@import firebase_app_check;
|
||||
#endif
|
||||
|
||||
#if __has_include(<firebase_auth/FLTFirebaseAuthPlugin.h>)
|
||||
#import <firebase_auth/FLTFirebaseAuthPlugin.h>
|
||||
#else
|
||||
@@ -82,7 +76,6 @@
|
||||
|
||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
||||
[FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]];
|
||||
[FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]];
|
||||
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
|
||||
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
|
||||
[FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]];
|
||||
|
||||
@@ -14,7 +14,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
|
||||
import 'firebase_options.dart';
|
||||
import 'src/widgets/session_listener.dart';
|
||||
@@ -31,8 +30,9 @@ void main() async {
|
||||
logStateChanges: false, // Set to true for verbose debugging
|
||||
);
|
||||
|
||||
// Initialize session listener for Firebase Auth state changes
|
||||
DataConnectService.instance.initializeAuthListener(
|
||||
// Initialize V2 session listener for Firebase Auth state changes.
|
||||
// Role validation calls GET /auth/session via the V2 API.
|
||||
V2SessionService.instance.initializeAuthListener(
|
||||
allowedRoles: <String>[
|
||||
'CLIENT',
|
||||
'BUSINESS',
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
|
||||
/// A widget that listens to session state changes and handles global reactions.
|
||||
///
|
||||
@@ -32,7 +31,7 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
}
|
||||
|
||||
void _setupSessionListener() {
|
||||
_sessionSubscription = DataConnectService.instance.onSessionStateChanged
|
||||
_sessionSubscription = V2SessionService.instance.onSessionStateChanged
|
||||
.listen((SessionState state) {
|
||||
_handleSessionChange(state);
|
||||
});
|
||||
@@ -134,7 +133,7 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Modular.to.popSafe();;
|
||||
Modular.to.popSafe();
|
||||
_proceedToLogin();
|
||||
},
|
||||
child: const Text('Log Out'),
|
||||
@@ -147,8 +146,9 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
|
||||
/// Navigate to login screen and clear navigation stack.
|
||||
void _proceedToLogin() {
|
||||
// Clear service caches on sign-out
|
||||
DataConnectService.instance.handleSignOut();
|
||||
// Clear session stores on sign-out
|
||||
V2SessionService.instance.handleSignOut();
|
||||
ClientSessionStore.instance.clear();
|
||||
|
||||
// Navigate to authentication
|
||||
Modular.to.toClientGetStartedPage();
|
||||
|
||||
@@ -7,7 +7,6 @@ import Foundation
|
||||
|
||||
import file_picker
|
||||
import file_selector_macos
|
||||
import firebase_app_check
|
||||
import firebase_auth
|
||||
import firebase_core
|
||||
import flutter_local_notifications
|
||||
@@ -20,7 +19,6 @@ import url_launcher_macos
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
|
||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
|
||||
@@ -41,7 +41,6 @@ dependencies:
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
firebase_core: ^4.4.0
|
||||
krow_data_connect: ^0.0.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -20,11 +20,6 @@ public final class GeneratedPluginRegistrant {
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin firebase_app_check, io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.auth.FlutterFirebaseAuthPlugin());
|
||||
} catch (Exception e) {
|
||||
@@ -50,11 +45,6 @@ public final class GeneratedPluginRegistrant {
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin geolocator_android, com.baseflow.geolocator.GeolocatorPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.googlemaps.GoogleMapsPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin google_maps_flutter_android, io.flutter.plugins.googlemaps.GoogleMapsPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin());
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -12,12 +12,6 @@
|
||||
@import file_picker;
|
||||
#endif
|
||||
|
||||
#if __has_include(<firebase_app_check/FLTFirebaseAppCheckPlugin.h>)
|
||||
#import <firebase_app_check/FLTFirebaseAppCheckPlugin.h>
|
||||
#else
|
||||
@import firebase_app_check;
|
||||
#endif
|
||||
|
||||
#if __has_include(<firebase_auth/FLTFirebaseAuthPlugin.h>)
|
||||
#import <firebase_auth/FLTFirebaseAuthPlugin.h>
|
||||
#else
|
||||
@@ -42,12 +36,6 @@
|
||||
@import geolocator_apple;
|
||||
#endif
|
||||
|
||||
#if __has_include(<google_maps_flutter_ios/FLTGoogleMapsPlugin.h>)
|
||||
#import <google_maps_flutter_ios/FLTGoogleMapsPlugin.h>
|
||||
#else
|
||||
@import google_maps_flutter_ios;
|
||||
#endif
|
||||
|
||||
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
|
||||
#import <image_picker_ios/FLTImagePickerPlugin.h>
|
||||
#else
|
||||
@@ -88,12 +76,10 @@
|
||||
|
||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
||||
[FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]];
|
||||
[FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]];
|
||||
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
|
||||
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
|
||||
[FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]];
|
||||
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
|
||||
[FLTGoogleMapsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleMapsPlugin"]];
|
||||
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
||||
[FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
|
||||
[RecordIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"RecordIosPlugin"]];
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krowwithus_staff/firebase_options.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart'
|
||||
as staff_authentication;
|
||||
@@ -29,8 +28,9 @@ void main() async {
|
||||
logStateChanges: false, // Set to true for verbose debugging
|
||||
);
|
||||
|
||||
// Initialize session listener for Firebase Auth state changes
|
||||
DataConnectService.instance.initializeAuthListener(
|
||||
// Initialize V2 session listener for Firebase Auth state changes.
|
||||
// Role validation calls GET /auth/session via the V2 API.
|
||||
V2SessionService.instance.initializeAuthListener(
|
||||
allowedRoles: <String>[
|
||||
'STAFF',
|
||||
'BOTH',
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
|
||||
/// A widget that listens to session state changes and handles global reactions.
|
||||
///
|
||||
@@ -32,7 +31,7 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
}
|
||||
|
||||
void _setupSessionListener() {
|
||||
_sessionSubscription = DataConnectService.instance.onSessionStateChanged
|
||||
_sessionSubscription = V2SessionService.instance.onSessionStateChanged
|
||||
.listen((SessionState state) {
|
||||
_handleSessionChange(state);
|
||||
});
|
||||
@@ -65,6 +64,19 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
_sessionExpiredDialogShown = false;
|
||||
debugPrint('[SessionListener] Authenticated: ${state.userId}');
|
||||
|
||||
// Don't auto-navigate while the auth flow is active — the auth
|
||||
// BLoC handles its own navigation (e.g. profile-setup for new users).
|
||||
final String currentPath = Modular.to.path;
|
||||
if (currentPath.contains('/phone-verification') ||
|
||||
currentPath.contains('/profile-setup') ||
|
||||
currentPath.contains('/get-started')) {
|
||||
debugPrint(
|
||||
'[SessionListener] Skipping home navigation — auth flow active '
|
||||
'(path: $currentPath)',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Navigate to the main app
|
||||
Modular.to.toStaffHome();
|
||||
break;
|
||||
@@ -104,7 +116,7 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Modular.to.popSafe();;
|
||||
Modular.to.popSafe();
|
||||
_proceedToLogin();
|
||||
},
|
||||
child: const Text('Log In'),
|
||||
@@ -134,7 +146,7 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Modular.to.popSafe();;
|
||||
Modular.to.popSafe();
|
||||
_proceedToLogin();
|
||||
},
|
||||
child: const Text('Log Out'),
|
||||
@@ -147,8 +159,9 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
|
||||
/// Navigate to login screen and clear navigation stack.
|
||||
void _proceedToLogin() {
|
||||
// Clear service caches on sign-out
|
||||
DataConnectService.instance.handleSignOut();
|
||||
// Clear session stores on sign-out
|
||||
V2SessionService.instance.handleSignOut();
|
||||
StaffSessionStore.instance.clear();
|
||||
|
||||
// Navigate to authentication
|
||||
Modular.to.toGetStartedPage();
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,6 +34,11 @@ export 'src/services/api_service/core_api_services/verification/verification_res
|
||||
export 'src/services/api_service/core_api_services/rapid_order/rapid_order_service.dart';
|
||||
export 'src/services/api_service/core_api_services/rapid_order/rapid_order_response.dart';
|
||||
|
||||
// Session Management
|
||||
export 'src/services/session/client_session_store.dart';
|
||||
export 'src/services/session/staff_session_store.dart';
|
||||
export 'src/services/session/v2_session_service.dart';
|
||||
|
||||
// Device Services
|
||||
export 'src/services/device/camera/camera_service.dart';
|
||||
export 'src/services/device/gallery/gallery_service.dart';
|
||||
|
||||
@@ -18,6 +18,15 @@ class CoreModule extends Module {
|
||||
// 2. Register the base API service
|
||||
i.addLazySingleton<BaseApiService>(() => ApiService(i.get<Dio>()));
|
||||
|
||||
// 2b. Wire the V2 session service with the API service.
|
||||
// This uses a post-registration callback so the singleton gets
|
||||
// its dependency as soon as the injector resolves BaseApiService.
|
||||
i.addLazySingleton<V2SessionService>(() {
|
||||
final V2SessionService service = V2SessionService.instance;
|
||||
service.setApiService(i.get<BaseApiService>());
|
||||
return service;
|
||||
});
|
||||
|
||||
// 3. Register Core API Services (Orchestrators)
|
||||
i.addLazySingleton<FileUploadService>(
|
||||
() => FileUploadService(i.get<BaseApiService>()),
|
||||
|
||||
@@ -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>{
|
||||
|
||||
@@ -158,7 +158,7 @@ mixin SessionHandlerMixin {
|
||||
|
||||
final Duration timeUntilExpiry = expiryTime.difference(now);
|
||||
if (timeUntilExpiry <= _refreshThreshold) {
|
||||
await user.getIdTokenResult();
|
||||
await user.getIdTokenResult(true);
|
||||
}
|
||||
|
||||
_lastTokenRefreshTime = now;
|
||||
@@ -212,9 +212,9 @@ mixin SessionHandlerMixin {
|
||||
final firebase_auth.IdTokenResult idToken =
|
||||
await user.getIdTokenResult();
|
||||
if (idToken.expirationTime != null &&
|
||||
DateTime.now().difference(idToken.expirationTime!) <
|
||||
idToken.expirationTime!.difference(DateTime.now()) <
|
||||
const Duration(minutes: 5)) {
|
||||
await user.getIdTokenResult();
|
||||
await user.getIdTokenResult(true);
|
||||
}
|
||||
|
||||
_emitSessionState(SessionState.authenticated(userId: user.uid));
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:krow_domain/krow_domain.dart' show ClientSession;
|
||||
|
||||
/// Singleton store for the authenticated client's session context.
|
||||
///
|
||||
/// Holds a [ClientSession] (V2 domain entity) populated after sign-in via the
|
||||
/// V2 session API. Features read from this store to access business context
|
||||
/// without re-fetching from the backend.
|
||||
class ClientSessionStore {
|
||||
ClientSessionStore._();
|
||||
|
||||
/// The global singleton instance.
|
||||
static final ClientSessionStore instance = ClientSessionStore._();
|
||||
|
||||
ClientSession? _session;
|
||||
|
||||
/// The current client session, or `null` if not authenticated.
|
||||
ClientSession? get session => _session;
|
||||
|
||||
/// Replaces the current session with [session].
|
||||
void setSession(ClientSession session) {
|
||||
_session = session;
|
||||
}
|
||||
|
||||
/// Clears the stored session (e.g. on sign-out).
|
||||
void clear() {
|
||||
_session = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:krow_domain/krow_domain.dart' show StaffSession;
|
||||
|
||||
/// Singleton store for the authenticated staff member's session context.
|
||||
///
|
||||
/// Holds a [StaffSession] (V2 domain entity) populated after sign-in via the
|
||||
/// V2 session API. Features read from this store to access staff/tenant context
|
||||
/// without re-fetching from the backend.
|
||||
class StaffSessionStore {
|
||||
StaffSessionStore._();
|
||||
|
||||
/// The global singleton instance.
|
||||
static final StaffSessionStore instance = StaffSessionStore._();
|
||||
|
||||
StaffSession? _session;
|
||||
|
||||
/// The current staff session, or `null` if not authenticated.
|
||||
StaffSession? get session => _session;
|
||||
|
||||
/// Replaces the current session with [session].
|
||||
void setSession(StaffSession session) {
|
||||
_session = session;
|
||||
}
|
||||
|
||||
/// Clears the stored session (e.g. on sign-out).
|
||||
void clear() {
|
||||
_session = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../api_service/api_service.dart';
|
||||
import '../api_service/core_api_services/v2_api_endpoints.dart';
|
||||
import '../api_service/mixins/session_handler_mixin.dart';
|
||||
|
||||
/// A singleton service that manages user session state via the V2 REST API.
|
||||
///
|
||||
/// Replaces `DataConnectService` for auth-state listening, role validation,
|
||||
/// and session-state broadcasting. Uses [SessionHandlerMixin] for token
|
||||
/// refresh and retry logic.
|
||||
class V2SessionService with SessionHandlerMixin {
|
||||
V2SessionService._();
|
||||
|
||||
/// The global singleton instance.
|
||||
static final V2SessionService instance = V2SessionService._();
|
||||
|
||||
/// Optional [BaseApiService] reference set during DI initialisation.
|
||||
///
|
||||
/// When `null` the service falls back to a raw Dio call so that
|
||||
/// `initializeAuthListener` can work before the Modular injector is ready.
|
||||
BaseApiService? _apiService;
|
||||
|
||||
/// Injects the [BaseApiService] dependency.
|
||||
///
|
||||
/// Call once from `CoreModule.exportedBinds` after registering [ApiService].
|
||||
void setApiService(BaseApiService apiService) {
|
||||
_apiService = apiService;
|
||||
}
|
||||
|
||||
@override
|
||||
firebase_auth.FirebaseAuth get auth => firebase_auth.FirebaseAuth.instance;
|
||||
|
||||
/// Fetches the user role by calling `GET /auth/session`.
|
||||
///
|
||||
/// Returns the role string (e.g. `STAFF`, `BUSINESS`, `BOTH`) or `null` if
|
||||
/// the call fails or the user has no role.
|
||||
@override
|
||||
Future<String?> fetchUserRole(String userId) async {
|
||||
try {
|
||||
// Wait for ApiService to be injected (happens after CoreModule.exportedBinds).
|
||||
// On cold start, initializeAuthListener fires before DI is ready.
|
||||
if (_apiService == null) {
|
||||
debugPrint(
|
||||
'[V2SessionService] ApiService not yet injected; '
|
||||
'waiting for DI initialization...',
|
||||
);
|
||||
for (int i = 0; i < 10; i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
if (_apiService != null) break;
|
||||
}
|
||||
}
|
||||
|
||||
final BaseApiService? api = _apiService;
|
||||
if (api == null) {
|
||||
debugPrint(
|
||||
'[V2SessionService] ApiService still null after waiting 2 s; '
|
||||
'cannot fetch user role.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
final ApiResponse response = await api.get(V2ApiEndpoints.session);
|
||||
|
||||
if (response.data is Map<String, dynamic>) {
|
||||
final Map<String, dynamic> data =
|
||||
response.data as Map<String, dynamic>;
|
||||
final String? role = data['role'] as String?;
|
||||
return role;
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('[V2SessionService] Error fetching user role: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Signs out the current user from Firebase Auth and clears local state.
|
||||
Future<void> signOut() async {
|
||||
try {
|
||||
// Revoke server-side session token.
|
||||
final BaseApiService? api = _apiService;
|
||||
if (api != null) {
|
||||
try {
|
||||
await api.post(V2ApiEndpoints.signOut);
|
||||
} catch (e) {
|
||||
debugPrint('[V2SessionService] Server sign-out failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
await auth.signOut();
|
||||
} catch (e) {
|
||||
debugPrint('[V2SessionService] Error signing out: $e');
|
||||
rethrow;
|
||||
} finally {
|
||||
handleSignOut();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/// The Data Connect layer.
|
||||
///
|
||||
/// This package provides mock implementations of domain repository interfaces
|
||||
/// for development and testing purposes.
|
||||
///
|
||||
/// They will implement interfaces defined in feature packages once those are created.
|
||||
library;
|
||||
|
||||
export 'src/data_connect_module.dart';
|
||||
export 'src/session/client_session_store.dart';
|
||||
|
||||
// Export the generated Data Connect SDK
|
||||
export 'src/dataconnect_generated/generated.dart';
|
||||
export 'src/services/data_connect_service.dart';
|
||||
export 'src/services/mixins/session_handler_mixin.dart';
|
||||
|
||||
export 'src/session/staff_session_store.dart';
|
||||
export 'src/services/mixins/data_error_handler.dart';
|
||||
|
||||
// Export Staff Connector repositories and use cases
|
||||
export 'src/connectors/staff/domain/repositories/staff_connector_repository.dart';
|
||||
export 'src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart';
|
||||
export 'src/connectors/staff/domain/usecases/get_personal_info_completion_usecase.dart';
|
||||
export 'src/connectors/staff/domain/usecases/get_emergency_contacts_completion_usecase.dart';
|
||||
export 'src/connectors/staff/domain/usecases/get_experience_completion_usecase.dart';
|
||||
export 'src/connectors/staff/domain/usecases/get_tax_forms_completion_usecase.dart';
|
||||
export 'src/connectors/staff/domain/usecases/get_attire_options_completion_usecase.dart';
|
||||
export 'src/connectors/staff/domain/usecases/get_staff_documents_completion_usecase.dart';
|
||||
export 'src/connectors/staff/domain/usecases/get_staff_certificates_completion_usecase.dart';
|
||||
export 'src/connectors/staff/domain/usecases/get_staff_profile_usecase.dart';
|
||||
export 'src/connectors/staff/domain/usecases/sign_out_staff_usecase.dart';
|
||||
export 'src/connectors/staff/data/repositories/staff_connector_repository_impl.dart';
|
||||
|
||||
// Export Reports Connector
|
||||
export 'src/connectors/reports/domain/repositories/reports_connector_repository.dart';
|
||||
export 'src/connectors/reports/data/repositories/reports_connector_repository_impl.dart';
|
||||
|
||||
// Export Shifts Connector
|
||||
export 'src/connectors/shifts/domain/repositories/shifts_connector_repository.dart';
|
||||
export 'src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart';
|
||||
|
||||
// Export Hubs Connector
|
||||
export 'src/connectors/hubs/domain/repositories/hubs_connector_repository.dart';
|
||||
export 'src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart';
|
||||
|
||||
// Export Billing Connector
|
||||
export 'src/connectors/billing/domain/repositories/billing_connector_repository.dart';
|
||||
export 'src/connectors/billing/data/repositories/billing_connector_repository_impl.dart';
|
||||
|
||||
// Export Coverage Connector
|
||||
export 'src/connectors/coverage/domain/repositories/coverage_connector_repository.dart';
|
||||
export 'src/connectors/coverage/data/repositories/coverage_connector_repository_impl.dart';
|
||||
@@ -1,373 +0,0 @@
|
||||
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
|
||||
import 'package:firebase_data_connect/src/core/ref.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/billing_connector_repository.dart';
|
||||
|
||||
/// Implementation of [BillingConnectorRepository].
|
||||
class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
|
||||
BillingConnectorRepositoryImpl({dc.DataConnectService? service})
|
||||
: _service = service ?? dc.DataConnectService.instance;
|
||||
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
@override
|
||||
Future<List<BusinessBankAccount>> getBankAccounts({
|
||||
required String businessId,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final QueryResult<
|
||||
dc.GetAccountsByOwnerIdData,
|
||||
dc.GetAccountsByOwnerIdVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.getAccountsByOwnerId(ownerId: businessId)
|
||||
.execute();
|
||||
|
||||
return result.data.accounts.map(_mapBankAccount).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<double> getCurrentBillAmount({required String businessId}) async {
|
||||
return _service.run(() async {
|
||||
final QueryResult<
|
||||
dc.ListInvoicesByBusinessIdData,
|
||||
dc.ListInvoicesByBusinessIdVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.listInvoicesByBusinessId(businessId: businessId)
|
||||
.execute();
|
||||
|
||||
return result.data.invoices
|
||||
.map(_mapInvoice)
|
||||
.where((Invoice i) => i.status == InvoiceStatus.open)
|
||||
.fold<double>(
|
||||
0.0,
|
||||
(double sum, Invoice item) => sum + item.totalAmount,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Invoice>> getInvoiceHistory({required String businessId}) async {
|
||||
return _service.run(() async {
|
||||
final QueryResult<
|
||||
dc.ListInvoicesByBusinessIdData,
|
||||
dc.ListInvoicesByBusinessIdVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.listInvoicesByBusinessId(businessId: businessId)
|
||||
.limit(20)
|
||||
.execute();
|
||||
|
||||
return result.data.invoices
|
||||
.map(_mapInvoice)
|
||||
.where((Invoice i) => i.status == InvoiceStatus.paid)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Invoice>> getPendingInvoices({required String businessId}) async {
|
||||
return _service.run(() async {
|
||||
final QueryResult<
|
||||
dc.ListInvoicesByBusinessIdData,
|
||||
dc.ListInvoicesByBusinessIdVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.listInvoicesByBusinessId(businessId: businessId)
|
||||
.execute();
|
||||
|
||||
return result.data.invoices
|
||||
.map(_mapInvoice)
|
||||
.where(
|
||||
(Invoice i) =>
|
||||
i.status != InvoiceStatus.paid &&
|
||||
i.status != InvoiceStatus.disputed &&
|
||||
i.status != InvoiceStatus.open,
|
||||
)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<InvoiceItem>> getSpendingBreakdown({
|
||||
required String businessId,
|
||||
required BillingPeriod period,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime start;
|
||||
final DateTime end;
|
||||
|
||||
if (period == BillingPeriod.week) {
|
||||
final int daysFromMonday = now.weekday - DateTime.monday;
|
||||
final DateTime monday = DateTime(
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
).subtract(Duration(days: daysFromMonday));
|
||||
start = monday;
|
||||
end = monday.add(
|
||||
const Duration(days: 6, hours: 23, minutes: 59, seconds: 59),
|
||||
);
|
||||
} else {
|
||||
start = DateTime(now.year, now.month, 1);
|
||||
end = DateTime(now.year, now.month + 1, 0, 23, 59, 59);
|
||||
}
|
||||
|
||||
final QueryResult<
|
||||
dc.ListShiftRolesByBusinessAndDatesSummaryData,
|
||||
dc.ListShiftRolesByBusinessAndDatesSummaryVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.listShiftRolesByBusinessAndDatesSummary(
|
||||
businessId: businessId,
|
||||
start: _service.toTimestamp(start),
|
||||
end: _service.toTimestamp(end),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final List<dc.ListShiftRolesByBusinessAndDatesSummaryShiftRoles>
|
||||
shiftRoles = result.data.shiftRoles;
|
||||
if (shiftRoles.isEmpty) return <InvoiceItem>[];
|
||||
|
||||
final Map<String, _RoleSummary> summary = <String, _RoleSummary>{};
|
||||
for (final dc.ListShiftRolesByBusinessAndDatesSummaryShiftRoles role
|
||||
in shiftRoles) {
|
||||
final String roleId = role.roleId;
|
||||
final String roleName = role.role.name;
|
||||
final double hours = role.hours ?? 0.0;
|
||||
final double totalValue = role.totalValue ?? 0.0;
|
||||
|
||||
final _RoleSummary? existing = summary[roleId];
|
||||
if (existing == null) {
|
||||
summary[roleId] = _RoleSummary(
|
||||
roleId: roleId,
|
||||
roleName: roleName,
|
||||
totalHours: hours,
|
||||
totalValue: totalValue,
|
||||
);
|
||||
} else {
|
||||
summary[roleId] = existing.copyWith(
|
||||
totalHours: existing.totalHours + hours,
|
||||
totalValue: existing.totalValue + totalValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return summary.values
|
||||
.map(
|
||||
(_RoleSummary item) => InvoiceItem(
|
||||
id: item.roleId,
|
||||
invoiceId: item.roleId,
|
||||
staffId: item.roleName,
|
||||
workHours: item.totalHours,
|
||||
rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0,
|
||||
amount: item.totalValue,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> approveInvoice({required String id}) async {
|
||||
return _service.run(() async {
|
||||
await _service.connector
|
||||
.updateInvoice(id: id)
|
||||
.status(dc.InvoiceStatus.APPROVED)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disputeInvoice({
|
||||
required String id,
|
||||
required String reason,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
await _service.connector
|
||||
.updateInvoice(id: id)
|
||||
.status(dc.InvoiceStatus.DISPUTED)
|
||||
.disputeReason(reason)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
// --- MAPPERS ---
|
||||
|
||||
Invoice _mapInvoice(dynamic invoice) {
|
||||
List<InvoiceWorker> workers = <InvoiceWorker>[];
|
||||
|
||||
// Try to get workers from denormalized 'roles' field first
|
||||
final List<dynamic> rolesData = invoice.roles is List
|
||||
? invoice.roles
|
||||
: <dynamic>[];
|
||||
if (rolesData.isNotEmpty) {
|
||||
workers = rolesData.map((dynamic r) {
|
||||
final Map<String, dynamic> role = r as Map<String, dynamic>;
|
||||
|
||||
// Handle various possible key naming conventions in the JSON data
|
||||
final String name =
|
||||
role['name'] ?? role['staffName'] ?? role['fullName'] ?? 'Unknown';
|
||||
final String roleTitle =
|
||||
role['role'] ?? role['roleName'] ?? role['title'] ?? 'Staff';
|
||||
final double amount =
|
||||
(role['amount'] as num?)?.toDouble() ??
|
||||
(role['totalValue'] as num?)?.toDouble() ??
|
||||
0.0;
|
||||
final double hours =
|
||||
(role['hours'] as num?)?.toDouble() ??
|
||||
(role['workHours'] as num?)?.toDouble() ??
|
||||
(role['totalHours'] as num?)?.toDouble() ??
|
||||
0.0;
|
||||
final double rate =
|
||||
(role['rate'] as num?)?.toDouble() ??
|
||||
(role['hourlyRate'] as num?)?.toDouble() ??
|
||||
0.0;
|
||||
|
||||
final dynamic checkInVal =
|
||||
role['checkInTime'] ?? role['startTime'] ?? role['check_in_time'];
|
||||
final dynamic checkOutVal =
|
||||
role['checkOutTime'] ?? role['endTime'] ?? role['check_out_time'];
|
||||
|
||||
return InvoiceWorker(
|
||||
name: name,
|
||||
role: roleTitle,
|
||||
amount: amount,
|
||||
hours: hours,
|
||||
rate: rate,
|
||||
checkIn: _service.toDateTime(checkInVal),
|
||||
checkOut: _service.toDateTime(checkOutVal),
|
||||
breakMinutes: role['breakMinutes'] ?? role['break_minutes'] ?? 0,
|
||||
avatarUrl:
|
||||
role['avatarUrl'] ?? role['photoUrl'] ?? role['staffPhoto'],
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
// Fallback: If roles is empty, try to get workers from shift applications
|
||||
else if (invoice.shift != null &&
|
||||
invoice.shift.applications_on_shift != null) {
|
||||
final List<dynamic> apps = invoice.shift.applications_on_shift;
|
||||
workers = apps.map((dynamic app) {
|
||||
final String name = app.staff?.fullName ?? 'Unknown';
|
||||
final String roleTitle = app.shiftRole?.role?.name ?? 'Staff';
|
||||
final double amount =
|
||||
(app.shiftRole?.totalValue as num?)?.toDouble() ?? 0.0;
|
||||
final double hours = (app.shiftRole?.hours as num?)?.toDouble() ?? 0.0;
|
||||
|
||||
// Calculate rate if not explicitly provided
|
||||
double rate = 0.0;
|
||||
if (hours > 0) {
|
||||
rate = amount / hours;
|
||||
}
|
||||
|
||||
// Map break type to minutes
|
||||
int breakMin = 0;
|
||||
final String? breakType = app.shiftRole?.breakType?.toString();
|
||||
if (breakType != null) {
|
||||
if (breakType.contains('10'))
|
||||
breakMin = 10;
|
||||
else if (breakType.contains('15'))
|
||||
breakMin = 15;
|
||||
else if (breakType.contains('30'))
|
||||
breakMin = 30;
|
||||
else if (breakType.contains('45'))
|
||||
breakMin = 45;
|
||||
else if (breakType.contains('60'))
|
||||
breakMin = 60;
|
||||
}
|
||||
|
||||
return InvoiceWorker(
|
||||
name: name,
|
||||
role: roleTitle,
|
||||
amount: amount,
|
||||
hours: hours,
|
||||
rate: rate,
|
||||
checkIn: _service.toDateTime(app.checkInTime),
|
||||
checkOut: _service.toDateTime(app.checkOutTime),
|
||||
breakMinutes: breakMin,
|
||||
avatarUrl: app.staff?.photoUrl,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
return Invoice(
|
||||
id: invoice.id,
|
||||
eventId: invoice.orderId,
|
||||
businessId: invoice.businessId,
|
||||
status: _mapInvoiceStatus(invoice.status.stringValue),
|
||||
totalAmount: invoice.amount,
|
||||
workAmount: invoice.amount,
|
||||
addonsAmount: invoice.otherCharges ?? 0,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
issueDate: _service.toDateTime(invoice.issueDate)!,
|
||||
title: invoice.order?.eventName,
|
||||
clientName: invoice.business?.businessName,
|
||||
locationAddress:
|
||||
invoice.order?.teamHub?.hubName ?? invoice.order?.teamHub?.address,
|
||||
staffCount:
|
||||
invoice.staffCount ?? (workers.isNotEmpty ? workers.length : 0),
|
||||
totalHours: _calculateTotalHours(rolesData),
|
||||
workers: workers,
|
||||
);
|
||||
}
|
||||
|
||||
double _calculateTotalHours(List<dynamic> roles) {
|
||||
return roles.fold<double>(0.0, (sum, role) {
|
||||
final hours = role['hours'] ?? role['workHours'] ?? role['totalHours'];
|
||||
if (hours is num) return sum + hours.toDouble();
|
||||
return sum;
|
||||
});
|
||||
}
|
||||
|
||||
BusinessBankAccount _mapBankAccount(dynamic account) {
|
||||
return BusinessBankAccountAdapter.fromPrimitives(
|
||||
id: account.id,
|
||||
bank: account.bank,
|
||||
last4: account.last4,
|
||||
isPrimary: account.isPrimary ?? false,
|
||||
expiryTime: _service.toDateTime(account.expiryTime),
|
||||
);
|
||||
}
|
||||
|
||||
InvoiceStatus _mapInvoiceStatus(String status) {
|
||||
switch (status) {
|
||||
case 'PAID':
|
||||
return InvoiceStatus.paid;
|
||||
case 'OVERDUE':
|
||||
return InvoiceStatus.overdue;
|
||||
case 'DISPUTED':
|
||||
return InvoiceStatus.disputed;
|
||||
case 'APPROVED':
|
||||
return InvoiceStatus.verified;
|
||||
default:
|
||||
return InvoiceStatus.open;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _RoleSummary {
|
||||
const _RoleSummary({
|
||||
required this.roleId,
|
||||
required this.roleName,
|
||||
required this.totalHours,
|
||||
required this.totalValue,
|
||||
});
|
||||
|
||||
final String roleId;
|
||||
final String roleName;
|
||||
final double totalHours;
|
||||
final double totalValue;
|
||||
|
||||
_RoleSummary copyWith({double? totalHours, double? totalValue}) {
|
||||
return _RoleSummary(
|
||||
roleId: roleId,
|
||||
roleName: roleName,
|
||||
totalHours: totalHours ?? this.totalHours,
|
||||
totalValue: totalValue ?? this.totalValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for billing connector operations.
|
||||
///
|
||||
/// This acts as a buffer layer between the domain repository and the Data Connect SDK.
|
||||
abstract interface class BillingConnectorRepository {
|
||||
/// Fetches bank accounts associated with the business.
|
||||
Future<List<BusinessBankAccount>> getBankAccounts({required String businessId});
|
||||
|
||||
/// Fetches the current bill amount for the period.
|
||||
Future<double> getCurrentBillAmount({required String businessId});
|
||||
|
||||
/// Fetches historically paid invoices.
|
||||
Future<List<Invoice>> getInvoiceHistory({required String businessId});
|
||||
|
||||
/// Fetches pending invoices (Open or Disputed).
|
||||
Future<List<Invoice>> getPendingInvoices({required String businessId});
|
||||
|
||||
/// Fetches the breakdown of spending.
|
||||
Future<List<InvoiceItem>> getSpendingBreakdown({
|
||||
required String businessId,
|
||||
required BillingPeriod period,
|
||||
});
|
||||
|
||||
/// Approves an invoice.
|
||||
Future<void> approveInvoice({required String id});
|
||||
|
||||
/// Disputes an invoice.
|
||||
Future<void> disputeInvoice({required String id, required String reason});
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
|
||||
import 'package:firebase_data_connect/src/core/ref.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/coverage_connector_repository.dart';
|
||||
|
||||
/// Implementation of [CoverageConnectorRepository].
|
||||
class CoverageConnectorRepositoryImpl implements CoverageConnectorRepository {
|
||||
CoverageConnectorRepositoryImpl({
|
||||
dc.DataConnectService? service,
|
||||
}) : _service = service ?? dc.DataConnectService.instance;
|
||||
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
@override
|
||||
Future<List<CoverageShift>> getShiftsForDate({
|
||||
required String businessId,
|
||||
required DateTime date,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final DateTime start = DateTime(date.year, date.month, date.day);
|
||||
final DateTime end = DateTime(date.year, date.month, date.day, 23, 59, 59, 999);
|
||||
|
||||
final QueryResult<dc.ListShiftRolesByBusinessAndDateRangeData, dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult = await _service.connector
|
||||
.listShiftRolesByBusinessAndDateRange(
|
||||
businessId: businessId,
|
||||
start: _service.toTimestamp(start),
|
||||
end: _service.toTimestamp(end),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final QueryResult<dc.ListStaffsApplicationsByBusinessForDayData, dc.ListStaffsApplicationsByBusinessForDayVariables> applicationsResult = await _service.connector
|
||||
.listStaffsApplicationsByBusinessForDay(
|
||||
businessId: businessId,
|
||||
dayStart: _service.toTimestamp(start),
|
||||
dayEnd: _service.toTimestamp(end),
|
||||
)
|
||||
.execute();
|
||||
|
||||
return _mapCoverageShifts(
|
||||
shiftRolesResult.data.shiftRoles,
|
||||
applicationsResult.data.applications,
|
||||
date,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
List<CoverageShift> _mapCoverageShifts(
|
||||
List<dynamic> shiftRoles,
|
||||
List<dynamic> applications,
|
||||
DateTime date,
|
||||
) {
|
||||
if (shiftRoles.isEmpty && applications.isEmpty) return <CoverageShift>[];
|
||||
|
||||
final Map<String, _CoverageGroup> groups = <String, _CoverageGroup>{};
|
||||
|
||||
for (final sr in shiftRoles) {
|
||||
final String key = '${sr.shiftId}:${sr.roleId}';
|
||||
final DateTime? startTime = _service.toDateTime(sr.startTime);
|
||||
|
||||
groups[key] = _CoverageGroup(
|
||||
shiftId: sr.shiftId,
|
||||
roleId: sr.roleId,
|
||||
title: sr.role.name,
|
||||
location: sr.shift.location ?? sr.shift.locationAddress ?? '',
|
||||
startTime: startTime != null ? DateFormat('HH:mm').format(startTime) : '00:00',
|
||||
workersNeeded: sr.count,
|
||||
date: _service.toDateTime(sr.shift.date) ?? date,
|
||||
workers: <CoverageWorker>[],
|
||||
);
|
||||
}
|
||||
|
||||
for (final app in applications) {
|
||||
final String key = '${app.shiftId}:${app.roleId}';
|
||||
if (!groups.containsKey(key)) {
|
||||
final DateTime? startTime = _service.toDateTime(app.shiftRole.startTime);
|
||||
groups[key] = _CoverageGroup(
|
||||
shiftId: app.shiftId,
|
||||
roleId: app.roleId,
|
||||
title: app.shiftRole.role.name,
|
||||
location: app.shiftRole.shift.location ?? app.shiftRole.shift.locationAddress ?? '',
|
||||
startTime: startTime != null ? DateFormat('HH:mm').format(startTime) : '00:00',
|
||||
workersNeeded: app.shiftRole.count,
|
||||
date: _service.toDateTime(app.shiftRole.shift.date) ?? date,
|
||||
workers: <CoverageWorker>[],
|
||||
);
|
||||
}
|
||||
|
||||
final DateTime? checkIn = _service.toDateTime(app.checkInTime);
|
||||
groups[key]!.workers.add(
|
||||
CoverageWorker(
|
||||
name: app.staff.fullName,
|
||||
status: _mapWorkerStatus(app.status.stringValue),
|
||||
checkInTime: checkIn != null ? DateFormat('HH:mm').format(checkIn) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return groups.values
|
||||
.map((_CoverageGroup g) => CoverageShift(
|
||||
id: '${g.shiftId}:${g.roleId}',
|
||||
title: g.title,
|
||||
location: g.location,
|
||||
startTime: g.startTime,
|
||||
workersNeeded: g.workersNeeded,
|
||||
date: g.date,
|
||||
workers: g.workers,
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
CoverageWorkerStatus _mapWorkerStatus(String status) {
|
||||
switch (status) {
|
||||
case 'PENDING':
|
||||
return CoverageWorkerStatus.pending;
|
||||
case 'REJECTED':
|
||||
return CoverageWorkerStatus.rejected;
|
||||
case 'CONFIRMED':
|
||||
return CoverageWorkerStatus.confirmed;
|
||||
case 'CHECKED_IN':
|
||||
return CoverageWorkerStatus.checkedIn;
|
||||
case 'CHECKED_OUT':
|
||||
return CoverageWorkerStatus.checkedOut;
|
||||
case 'LATE':
|
||||
return CoverageWorkerStatus.late;
|
||||
case 'NO_SHOW':
|
||||
return CoverageWorkerStatus.noShow;
|
||||
case 'COMPLETED':
|
||||
return CoverageWorkerStatus.completed;
|
||||
default:
|
||||
return CoverageWorkerStatus.pending;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _CoverageGroup {
|
||||
_CoverageGroup({
|
||||
required this.shiftId,
|
||||
required this.roleId,
|
||||
required this.title,
|
||||
required this.location,
|
||||
required this.startTime,
|
||||
required this.workersNeeded,
|
||||
required this.date,
|
||||
required this.workers,
|
||||
});
|
||||
|
||||
final String shiftId;
|
||||
final String roleId;
|
||||
final String title;
|
||||
final String location;
|
||||
final String startTime;
|
||||
final int workersNeeded;
|
||||
final DateTime date;
|
||||
final List<CoverageWorker> workers;
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for coverage connector operations.
|
||||
///
|
||||
/// This acts as a buffer layer between the domain repository and the Data Connect SDK.
|
||||
abstract interface class CoverageConnectorRepository {
|
||||
/// Fetches coverage data for a specific date and business.
|
||||
Future<List<CoverageShift>> getShiftsForDate({
|
||||
required String businessId,
|
||||
required DateTime date,
|
||||
});
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../domain/repositories/hubs_connector_repository.dart';
|
||||
|
||||
/// Implementation of [HubsConnectorRepository].
|
||||
class HubsConnectorRepositoryImpl implements HubsConnectorRepository {
|
||||
HubsConnectorRepositoryImpl({
|
||||
dc.DataConnectService? service,
|
||||
}) : _service = service ?? dc.DataConnectService.instance;
|
||||
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
@override
|
||||
Future<List<Hub>> getHubs({required String businessId}) async {
|
||||
return _service.run(() async {
|
||||
final String teamId = await _getOrCreateTeamId(businessId);
|
||||
final QueryResult<dc.GetTeamHubsByTeamIdData, dc.GetTeamHubsByTeamIdVariables> response = await _service.connector
|
||||
.getTeamHubsByTeamId(teamId: teamId)
|
||||
.execute();
|
||||
|
||||
final QueryResult<
|
||||
dc.ListTeamHudDepartmentsData,
|
||||
dc.ListTeamHudDepartmentsVariables
|
||||
>
|
||||
deptsResult = await _service.connector.listTeamHudDepartments().execute();
|
||||
final Map<String, dc.ListTeamHudDepartmentsTeamHudDepartments> hubToDept =
|
||||
<String, dc.ListTeamHudDepartmentsTeamHudDepartments>{};
|
||||
for (final dc.ListTeamHudDepartmentsTeamHudDepartments dep
|
||||
in deptsResult.data.teamHudDepartments) {
|
||||
if (dep.costCenter != null &&
|
||||
dep.costCenter!.isNotEmpty &&
|
||||
!hubToDept.containsKey(dep.teamHubId)) {
|
||||
hubToDept[dep.teamHubId] = dep;
|
||||
}
|
||||
}
|
||||
|
||||
return response.data.teamHubs.map((dc.GetTeamHubsByTeamIdTeamHubs h) {
|
||||
final dc.ListTeamHudDepartmentsTeamHudDepartments? dept =
|
||||
hubToDept[h.id];
|
||||
return Hub(
|
||||
id: h.id,
|
||||
businessId: businessId,
|
||||
name: h.hubName,
|
||||
address: h.address,
|
||||
nfcTagId: null,
|
||||
status: h.isActive ? HubStatus.active : HubStatus.inactive,
|
||||
costCenter: dept != null
|
||||
? CostCenter(
|
||||
id: dept.id,
|
||||
name: dept.name,
|
||||
code: dept.costCenter ?? dept.name,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Hub> createHub({
|
||||
required String businessId,
|
||||
required String name,
|
||||
required String address,
|
||||
String? placeId,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? city,
|
||||
String? state,
|
||||
String? street,
|
||||
String? country,
|
||||
String? zipCode,
|
||||
String? costCenterId,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final String teamId = await _getOrCreateTeamId(businessId);
|
||||
final _PlaceAddress? placeAddress = (placeId != null && placeId.isNotEmpty)
|
||||
? await _fetchPlaceAddress(placeId)
|
||||
: null;
|
||||
|
||||
final OperationResult<dc.CreateTeamHubData, dc.CreateTeamHubVariables> result = await _service.connector
|
||||
.createTeamHub(
|
||||
teamId: teamId,
|
||||
hubName: name,
|
||||
address: address,
|
||||
)
|
||||
.placeId(placeId)
|
||||
.latitude(latitude)
|
||||
.longitude(longitude)
|
||||
.city(city ?? placeAddress?.city ?? '')
|
||||
.state(state ?? placeAddress?.state)
|
||||
.street(street ?? placeAddress?.street)
|
||||
.country(country ?? placeAddress?.country)
|
||||
.zipCode(zipCode ?? placeAddress?.zipCode)
|
||||
.execute();
|
||||
|
||||
final String hubId = result.data.teamHub_insert.id;
|
||||
CostCenter? costCenter;
|
||||
if (costCenterId != null && costCenterId.isNotEmpty) {
|
||||
await _service.connector
|
||||
.createTeamHudDepartment(
|
||||
name: costCenterId,
|
||||
teamHubId: hubId,
|
||||
)
|
||||
.costCenter(costCenterId)
|
||||
.execute();
|
||||
costCenter = CostCenter(id: costCenterId, name: costCenterId, code: costCenterId);
|
||||
}
|
||||
|
||||
return Hub(
|
||||
id: hubId,
|
||||
businessId: businessId,
|
||||
name: name,
|
||||
address: address,
|
||||
nfcTagId: null,
|
||||
status: HubStatus.active,
|
||||
costCenter: costCenter,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Hub> updateHub({
|
||||
required String businessId,
|
||||
required String id,
|
||||
String? name,
|
||||
String? address,
|
||||
String? placeId,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? city,
|
||||
String? state,
|
||||
String? street,
|
||||
String? country,
|
||||
String? zipCode,
|
||||
String? costCenterId,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final _PlaceAddress? placeAddress = (placeId != null && placeId.isNotEmpty)
|
||||
? await _fetchPlaceAddress(placeId)
|
||||
: null;
|
||||
|
||||
final dc.UpdateTeamHubVariablesBuilder builder = _service.connector.updateTeamHub(id: id);
|
||||
|
||||
if (name != null) builder.hubName(name);
|
||||
if (address != null) builder.address(address);
|
||||
if (placeId != null) builder.placeId(placeId);
|
||||
if (latitude != null) builder.latitude(latitude);
|
||||
if (longitude != null) builder.longitude(longitude);
|
||||
if (city != null || placeAddress?.city != null) {
|
||||
builder.city(city ?? placeAddress?.city);
|
||||
}
|
||||
if (state != null || placeAddress?.state != null) {
|
||||
builder.state(state ?? placeAddress?.state);
|
||||
}
|
||||
if (street != null || placeAddress?.street != null) {
|
||||
builder.street(street ?? placeAddress?.street);
|
||||
}
|
||||
if (country != null || placeAddress?.country != null) {
|
||||
builder.country(country ?? placeAddress?.country);
|
||||
}
|
||||
if (zipCode != null || placeAddress?.zipCode != null) {
|
||||
builder.zipCode(zipCode ?? placeAddress?.zipCode);
|
||||
}
|
||||
|
||||
await builder.execute();
|
||||
|
||||
CostCenter? costCenter;
|
||||
final QueryResult<
|
||||
dc.ListTeamHudDepartmentsByTeamHubIdData,
|
||||
dc.ListTeamHudDepartmentsByTeamHubIdVariables
|
||||
>
|
||||
deptsResult = await _service.connector
|
||||
.listTeamHudDepartmentsByTeamHubId(teamHubId: id)
|
||||
.execute();
|
||||
final List<dc.ListTeamHudDepartmentsByTeamHubIdTeamHudDepartments> depts =
|
||||
deptsResult.data.teamHudDepartments;
|
||||
|
||||
if (costCenterId == null || costCenterId.isEmpty) {
|
||||
if (depts.isNotEmpty) {
|
||||
await _service.connector
|
||||
.updateTeamHudDepartment(id: depts.first.id)
|
||||
.costCenter(null)
|
||||
.execute();
|
||||
}
|
||||
} else {
|
||||
if (depts.isNotEmpty) {
|
||||
await _service.connector
|
||||
.updateTeamHudDepartment(id: depts.first.id)
|
||||
.costCenter(costCenterId)
|
||||
.execute();
|
||||
costCenter = CostCenter(id: costCenterId, name: costCenterId, code: costCenterId);
|
||||
} else {
|
||||
await _service.connector
|
||||
.createTeamHudDepartment(
|
||||
name: costCenterId,
|
||||
teamHubId: id,
|
||||
)
|
||||
.costCenter(costCenterId)
|
||||
.execute();
|
||||
costCenter = CostCenter(id: costCenterId, name: costCenterId, code: costCenterId);
|
||||
}
|
||||
}
|
||||
|
||||
return Hub(
|
||||
id: id,
|
||||
businessId: businessId,
|
||||
name: name ?? '',
|
||||
address: address ?? '',
|
||||
nfcTagId: null,
|
||||
status: HubStatus.active,
|
||||
costCenter: costCenter,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteHub({required String businessId, required String id}) async {
|
||||
return _service.run(() async {
|
||||
final QueryResult<dc.ListOrdersByBusinessAndTeamHubData, dc.ListOrdersByBusinessAndTeamHubVariables> ordersRes = await _service.connector
|
||||
.listOrdersByBusinessAndTeamHub(businessId: businessId, teamHubId: id)
|
||||
.execute();
|
||||
|
||||
if (ordersRes.data.orders.isNotEmpty) {
|
||||
throw HubHasOrdersException(
|
||||
technicalMessage: 'Hub $id has ${ordersRes.data.orders.length} orders',
|
||||
);
|
||||
}
|
||||
|
||||
await _service.connector.deleteTeamHub(id: id).execute();
|
||||
});
|
||||
}
|
||||
|
||||
// --- HELPERS ---
|
||||
|
||||
Future<String> _getOrCreateTeamId(String businessId) async {
|
||||
final QueryResult<dc.GetTeamsByOwnerIdData, dc.GetTeamsByOwnerIdVariables> teamsRes = await _service.connector
|
||||
.getTeamsByOwnerId(ownerId: businessId)
|
||||
.execute();
|
||||
|
||||
if (teamsRes.data.teams.isNotEmpty) {
|
||||
return teamsRes.data.teams.first.id;
|
||||
}
|
||||
|
||||
// Logic to fetch business details to create a team name if missing
|
||||
// For simplicity, we assume one exists or we create a generic one
|
||||
final OperationResult<dc.CreateTeamData, dc.CreateTeamVariables> createRes = await _service.connector
|
||||
.createTeam(
|
||||
teamName: 'Business Team',
|
||||
ownerId: businessId,
|
||||
ownerName: '',
|
||||
ownerRole: 'OWNER',
|
||||
)
|
||||
.execute();
|
||||
|
||||
return createRes.data.team_insert.id;
|
||||
}
|
||||
|
||||
Future<_PlaceAddress?> _fetchPlaceAddress(String placeId) async {
|
||||
final Uri uri = Uri.https(
|
||||
'maps.googleapis.com',
|
||||
'/maps/api/place/details/json',
|
||||
<String, dynamic>{
|
||||
'place_id': placeId,
|
||||
'fields': 'address_component',
|
||||
'key': AppConfig.googleMapsApiKey,
|
||||
},
|
||||
);
|
||||
try {
|
||||
final http.Response response = await http.get(uri);
|
||||
if (response.statusCode != 200) return null;
|
||||
|
||||
final Map<String, dynamic> payload = json.decode(response.body) as Map<String, dynamic>;
|
||||
if (payload['status'] != 'OK') return null;
|
||||
|
||||
final Map<String, dynamic>? result = payload['result'] as Map<String, dynamic>?;
|
||||
final List<dynamic>? components = result?['address_components'] as List<dynamic>?;
|
||||
if (components == null || components.isEmpty) return null;
|
||||
|
||||
String? streetNumber, route, city, state, country, zipCode;
|
||||
|
||||
for (var entry in components) {
|
||||
final Map<String, dynamic> component = entry as Map<String, dynamic>;
|
||||
final List<dynamic> types = component['types'] as List<dynamic>? ?? <dynamic>[];
|
||||
final String? longName = component['long_name'] as String?;
|
||||
final String? shortName = component['short_name'] as String?;
|
||||
|
||||
if (types.contains('street_number')) {
|
||||
streetNumber = longName;
|
||||
} else if (types.contains('route')) {
|
||||
route = longName;
|
||||
} else if (types.contains('locality')) {
|
||||
city = longName;
|
||||
} else if (types.contains('administrative_area_level_1')) {
|
||||
state = shortName ?? longName;
|
||||
} else if (types.contains('country')) {
|
||||
country = shortName ?? longName;
|
||||
} else if (types.contains('postal_code')) {
|
||||
zipCode = longName;
|
||||
}
|
||||
}
|
||||
|
||||
final String street = <String?>[streetNumber, route]
|
||||
.where((String? v) => v != null && v.isNotEmpty)
|
||||
.join(' ')
|
||||
.trim();
|
||||
|
||||
return _PlaceAddress(
|
||||
street: street.isEmpty ? null : street,
|
||||
city: city,
|
||||
state: state,
|
||||
country: country,
|
||||
zipCode: zipCode,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaceAddress {
|
||||
const _PlaceAddress({
|
||||
this.street,
|
||||
this.city,
|
||||
this.state,
|
||||
this.country,
|
||||
this.zipCode,
|
||||
});
|
||||
|
||||
final String? street;
|
||||
final String? city;
|
||||
final String? state;
|
||||
final String? country;
|
||||
final String? zipCode;
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for hubs connector operations.
|
||||
///
|
||||
/// This acts as a buffer layer between the domain repository and the Data Connect SDK.
|
||||
abstract interface class HubsConnectorRepository {
|
||||
/// Fetches the list of hubs for a business.
|
||||
Future<List<Hub>> getHubs({required String businessId});
|
||||
|
||||
/// Creates a new hub.
|
||||
Future<Hub> createHub({
|
||||
required String businessId,
|
||||
required String name,
|
||||
required String address,
|
||||
String? placeId,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? city,
|
||||
String? state,
|
||||
String? street,
|
||||
String? country,
|
||||
String? zipCode,
|
||||
String? costCenterId,
|
||||
});
|
||||
|
||||
/// Updates an existing hub.
|
||||
Future<Hub> updateHub({
|
||||
required String businessId,
|
||||
required String id,
|
||||
String? name,
|
||||
String? address,
|
||||
String? placeId,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? city,
|
||||
String? state,
|
||||
String? street,
|
||||
String? country,
|
||||
String? zipCode,
|
||||
String? costCenterId,
|
||||
});
|
||||
|
||||
/// Deletes a hub.
|
||||
Future<void> deleteHub({required String businessId, required String id});
|
||||
}
|
||||
@@ -1,537 +0,0 @@
|
||||
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/reports_connector_repository.dart';
|
||||
|
||||
/// Implementation of [ReportsConnectorRepository].
|
||||
///
|
||||
/// Fetches report-related data from the Data Connect backend.
|
||||
class ReportsConnectorRepositoryImpl implements ReportsConnectorRepository {
|
||||
/// Creates a new [ReportsConnectorRepositoryImpl].
|
||||
ReportsConnectorRepositoryImpl({
|
||||
dc.DataConnectService? service,
|
||||
}) : _service = service ?? dc.DataConnectService.instance;
|
||||
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
@override
|
||||
Future<DailyOpsReport> getDailyOpsReport({
|
||||
String? businessId,
|
||||
required DateTime date,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final String id = businessId ?? await _service.getBusinessId();
|
||||
final QueryResult<dc.ListShiftsForDailyOpsByBusinessData, dc.ListShiftsForDailyOpsByBusinessVariables> response = await _service.connector
|
||||
.listShiftsForDailyOpsByBusiness(
|
||||
businessId: id,
|
||||
date: _service.toTimestamp(date),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final List<dc.ListShiftsForDailyOpsByBusinessShifts> shifts = response.data.shifts;
|
||||
|
||||
final int scheduledShifts = shifts.length;
|
||||
int workersConfirmed = 0;
|
||||
int inProgressShifts = 0;
|
||||
int completedShifts = 0;
|
||||
|
||||
final List<DailyOpsShift> dailyOpsShifts = <DailyOpsShift>[];
|
||||
|
||||
for (final dc.ListShiftsForDailyOpsByBusinessShifts shift in shifts) {
|
||||
workersConfirmed += shift.filled ?? 0;
|
||||
final String statusStr = shift.status?.stringValue ?? '';
|
||||
if (statusStr == 'IN_PROGRESS') inProgressShifts++;
|
||||
if (statusStr == 'COMPLETED') completedShifts++;
|
||||
|
||||
dailyOpsShifts.add(DailyOpsShift(
|
||||
id: shift.id,
|
||||
title: shift.title ?? '',
|
||||
location: shift.location ?? '',
|
||||
startTime: shift.startTime?.toDateTime() ?? DateTime.now(),
|
||||
endTime: shift.endTime?.toDateTime() ?? DateTime.now(),
|
||||
workersNeeded: shift.workersNeeded ?? 0,
|
||||
filled: shift.filled ?? 0,
|
||||
status: statusStr,
|
||||
));
|
||||
}
|
||||
|
||||
return DailyOpsReport(
|
||||
scheduledShifts: scheduledShifts,
|
||||
workersConfirmed: workersConfirmed,
|
||||
inProgressShifts: inProgressShifts,
|
||||
completedShifts: completedShifts,
|
||||
shifts: dailyOpsShifts,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SpendReport> getSpendReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final String id = businessId ?? await _service.getBusinessId();
|
||||
final QueryResult<dc.ListInvoicesForSpendByBusinessData, dc.ListInvoicesForSpendByBusinessVariables> response = await _service.connector
|
||||
.listInvoicesForSpendByBusiness(
|
||||
businessId: id,
|
||||
startDate: _service.toTimestamp(startDate),
|
||||
endDate: _service.toTimestamp(endDate),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final List<dc.ListInvoicesForSpendByBusinessInvoices> invoices = response.data.invoices;
|
||||
|
||||
double totalSpend = 0.0;
|
||||
int paidInvoices = 0;
|
||||
int pendingInvoices = 0;
|
||||
int overdueInvoices = 0;
|
||||
|
||||
final List<SpendInvoice> spendInvoices = <SpendInvoice>[];
|
||||
final Map<DateTime, double> dailyAggregates = <DateTime, double>{};
|
||||
final Map<String, double> industryAggregates = <String, double>{};
|
||||
|
||||
for (final dc.ListInvoicesForSpendByBusinessInvoices inv in invoices) {
|
||||
final double amount = (inv.amount ?? 0.0).toDouble();
|
||||
totalSpend += amount;
|
||||
|
||||
final String statusStr = inv.status.stringValue;
|
||||
if (statusStr == 'PAID') {
|
||||
paidInvoices++;
|
||||
} else if (statusStr == 'PENDING') {
|
||||
pendingInvoices++;
|
||||
} else if (statusStr == 'OVERDUE') {
|
||||
overdueInvoices++;
|
||||
}
|
||||
|
||||
final String industry = inv.vendor.serviceSpecialty ?? 'Other';
|
||||
industryAggregates[industry] = (industryAggregates[industry] ?? 0.0) + amount;
|
||||
|
||||
final DateTime issueDateTime = inv.issueDate.toDateTime();
|
||||
spendInvoices.add(SpendInvoice(
|
||||
id: inv.id,
|
||||
invoiceNumber: inv.invoiceNumber ?? '',
|
||||
issueDate: issueDateTime,
|
||||
amount: amount,
|
||||
status: statusStr,
|
||||
vendorName: inv.vendor.companyName ?? 'Unknown',
|
||||
industry: industry,
|
||||
));
|
||||
|
||||
// Chart data aggregation
|
||||
final DateTime date = DateTime(issueDateTime.year, issueDateTime.month, issueDateTime.day);
|
||||
dailyAggregates[date] = (dailyAggregates[date] ?? 0.0) + amount;
|
||||
}
|
||||
|
||||
// Ensure chart data covers all days in range
|
||||
final Map<DateTime, double> completeDailyAggregates = <DateTime, double>{};
|
||||
for (int i = 0; i <= endDate.difference(startDate).inDays; i++) {
|
||||
final DateTime date = startDate.add(Duration(days: i));
|
||||
final DateTime normalizedDate = DateTime(date.year, date.month, date.day);
|
||||
completeDailyAggregates[normalizedDate] =
|
||||
dailyAggregates[normalizedDate] ?? 0.0;
|
||||
}
|
||||
|
||||
final List<SpendChartPoint> chartData = completeDailyAggregates.entries
|
||||
.map((MapEntry<DateTime, double> e) => SpendChartPoint(date: e.key, amount: e.value))
|
||||
.toList()
|
||||
..sort((SpendChartPoint a, SpendChartPoint b) => a.date.compareTo(b.date));
|
||||
|
||||
final List<SpendIndustryCategory> industryBreakdown = industryAggregates.entries
|
||||
.map((MapEntry<String, double> e) => SpendIndustryCategory(
|
||||
name: e.key,
|
||||
amount: e.value,
|
||||
percentage: totalSpend > 0 ? (e.value / totalSpend * 100) : 0,
|
||||
))
|
||||
.toList()
|
||||
..sort((SpendIndustryCategory a, SpendIndustryCategory b) => b.amount.compareTo(a.amount));
|
||||
|
||||
final int daysCount = endDate.difference(startDate).inDays + 1;
|
||||
|
||||
return SpendReport(
|
||||
totalSpend: totalSpend,
|
||||
averageCost: daysCount > 0 ? totalSpend / daysCount : 0,
|
||||
paidInvoices: paidInvoices,
|
||||
pendingInvoices: pendingInvoices,
|
||||
overdueInvoices: overdueInvoices,
|
||||
invoices: spendInvoices,
|
||||
chartData: chartData,
|
||||
industryBreakdown: industryBreakdown,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CoverageReport> getCoverageReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final String id = businessId ?? await _service.getBusinessId();
|
||||
final QueryResult<dc.ListShiftsForCoverageData, dc.ListShiftsForCoverageVariables> response = await _service.connector
|
||||
.listShiftsForCoverage(
|
||||
businessId: id,
|
||||
startDate: _service.toTimestamp(startDate),
|
||||
endDate: _service.toTimestamp(endDate),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final List<dc.ListShiftsForCoverageShifts> shifts = response.data.shifts;
|
||||
|
||||
int totalNeeded = 0;
|
||||
int totalFilled = 0;
|
||||
final Map<DateTime, (int, int)> dailyStats = <DateTime, (int, int)>{};
|
||||
|
||||
for (final dc.ListShiftsForCoverageShifts shift in shifts) {
|
||||
final DateTime shiftDate = shift.date?.toDateTime() ?? DateTime.now();
|
||||
final DateTime date = DateTime(shiftDate.year, shiftDate.month, shiftDate.day);
|
||||
|
||||
final int needed = shift.workersNeeded ?? 0;
|
||||
final int filled = shift.filled ?? 0;
|
||||
|
||||
totalNeeded += needed;
|
||||
totalFilled += filled;
|
||||
|
||||
final (int, int) current = dailyStats[date] ?? (0, 0);
|
||||
dailyStats[date] = (current.$1 + needed, current.$2 + filled);
|
||||
}
|
||||
|
||||
final List<CoverageDay> dailyCoverage = dailyStats.entries.map((MapEntry<DateTime, (int, int)> e) {
|
||||
final int needed = e.value.$1;
|
||||
final int filled = e.value.$2;
|
||||
return CoverageDay(
|
||||
date: e.key,
|
||||
needed: needed,
|
||||
filled: filled,
|
||||
percentage: needed == 0 ? 100.0 : (filled / needed) * 100.0,
|
||||
);
|
||||
}).toList()..sort((CoverageDay a, CoverageDay b) => a.date.compareTo(b.date));
|
||||
|
||||
return CoverageReport(
|
||||
overallCoverage: totalNeeded == 0 ? 100.0 : (totalFilled / totalNeeded) * 100.0,
|
||||
totalNeeded: totalNeeded,
|
||||
totalFilled: totalFilled,
|
||||
dailyCoverage: dailyCoverage,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ForecastReport> getForecastReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final String id = businessId ?? await _service.getBusinessId();
|
||||
final QueryResult<dc.ListShiftsForForecastByBusinessData, dc.ListShiftsForForecastByBusinessVariables> response = await _service.connector
|
||||
.listShiftsForForecastByBusiness(
|
||||
businessId: id,
|
||||
startDate: _service.toTimestamp(startDate),
|
||||
endDate: _service.toTimestamp(endDate),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final List<dc.ListShiftsForForecastByBusinessShifts> shifts = response.data.shifts;
|
||||
|
||||
double projectedSpend = 0.0;
|
||||
int projectedWorkers = 0;
|
||||
double totalHours = 0.0;
|
||||
final Map<DateTime, (double, int)> dailyStats = <DateTime, (double, int)>{};
|
||||
|
||||
// Weekly stats: index -> (cost, count, hours)
|
||||
final Map<int, (double, int, double)> weeklyStats = <int, (double, int, double)>{
|
||||
0: (0.0, 0, 0.0),
|
||||
1: (0.0, 0, 0.0),
|
||||
2: (0.0, 0, 0.0),
|
||||
3: (0.0, 0, 0.0),
|
||||
};
|
||||
|
||||
for (final dc.ListShiftsForForecastByBusinessShifts shift in shifts) {
|
||||
final DateTime shiftDate = shift.date?.toDateTime() ?? DateTime.now();
|
||||
final DateTime date = DateTime(shiftDate.year, shiftDate.month, shiftDate.day);
|
||||
|
||||
final double cost = (shift.cost ?? 0.0).toDouble();
|
||||
final int workers = shift.workersNeeded ?? 0;
|
||||
final double hoursVal = (shift.hours ?? 0).toDouble();
|
||||
final double shiftTotalHours = hoursVal * workers;
|
||||
|
||||
projectedSpend += cost;
|
||||
projectedWorkers += workers;
|
||||
totalHours += shiftTotalHours;
|
||||
|
||||
final (double, int) current = dailyStats[date] ?? (0.0, 0);
|
||||
dailyStats[date] = (current.$1 + cost, current.$2 + workers);
|
||||
|
||||
// Weekly logic
|
||||
final int diffDays = shiftDate.difference(startDate).inDays;
|
||||
if (diffDays >= 0) {
|
||||
final int weekIndex = diffDays ~/ 7;
|
||||
if (weekIndex < 4) {
|
||||
final (double, int, double) wCurrent = weeklyStats[weekIndex]!;
|
||||
weeklyStats[weekIndex] = (
|
||||
wCurrent.$1 + cost,
|
||||
wCurrent.$2 + 1,
|
||||
wCurrent.$3 + shiftTotalHours,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final List<ForecastPoint> chartData = dailyStats.entries.map((MapEntry<DateTime, (double, int)> e) {
|
||||
return ForecastPoint(
|
||||
date: e.key,
|
||||
projectedCost: e.value.$1,
|
||||
workersNeeded: e.value.$2,
|
||||
);
|
||||
}).toList()..sort((ForecastPoint a, ForecastPoint b) => a.date.compareTo(b.date));
|
||||
|
||||
final List<ForecastWeek> weeklyBreakdown = <ForecastWeek>[];
|
||||
for (int i = 0; i < 4; i++) {
|
||||
final (double, int, double) stats = weeklyStats[i]!;
|
||||
weeklyBreakdown.add(ForecastWeek(
|
||||
weekNumber: i + 1,
|
||||
totalCost: stats.$1,
|
||||
shiftsCount: stats.$2,
|
||||
hoursCount: stats.$3,
|
||||
avgCostPerShift: stats.$2 == 0 ? 0.0 : stats.$1 / stats.$2,
|
||||
));
|
||||
}
|
||||
|
||||
final int weeksCount = (endDate.difference(startDate).inDays / 7).ceil();
|
||||
final double avgWeeklySpend = weeksCount > 0 ? projectedSpend / weeksCount : 0.0;
|
||||
|
||||
return ForecastReport(
|
||||
projectedSpend: projectedSpend,
|
||||
projectedWorkers: projectedWorkers,
|
||||
averageLaborCost: projectedWorkers == 0 ? 0.0 : projectedSpend / projectedWorkers,
|
||||
chartData: chartData,
|
||||
totalShifts: shifts.length,
|
||||
totalHours: totalHours,
|
||||
avgWeeklySpend: avgWeeklySpend,
|
||||
weeklyBreakdown: weeklyBreakdown,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PerformanceReport> getPerformanceReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final String id = businessId ?? await _service.getBusinessId();
|
||||
final QueryResult<dc.ListShiftsForPerformanceByBusinessData, dc.ListShiftsForPerformanceByBusinessVariables> response = await _service.connector
|
||||
.listShiftsForPerformanceByBusiness(
|
||||
businessId: id,
|
||||
startDate: _service.toTimestamp(startDate),
|
||||
endDate: _service.toTimestamp(endDate),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final List<dc.ListShiftsForPerformanceByBusinessShifts> shifts = response.data.shifts;
|
||||
|
||||
int totalNeeded = 0;
|
||||
int totalFilled = 0;
|
||||
int completedCount = 0;
|
||||
double totalFillTimeSeconds = 0.0;
|
||||
int filledShiftsWithTime = 0;
|
||||
|
||||
for (final dc.ListShiftsForPerformanceByBusinessShifts shift in shifts) {
|
||||
totalNeeded += shift.workersNeeded ?? 0;
|
||||
totalFilled += shift.filled ?? 0;
|
||||
if ((shift.status?.stringValue ?? '') == 'COMPLETED') {
|
||||
completedCount++;
|
||||
}
|
||||
|
||||
if (shift.filledAt != null && shift.createdAt != null) {
|
||||
final DateTime createdAt = shift.createdAt!.toDateTime();
|
||||
final DateTime filledAt = shift.filledAt!.toDateTime();
|
||||
totalFillTimeSeconds += filledAt.difference(createdAt).inSeconds;
|
||||
filledShiftsWithTime++;
|
||||
}
|
||||
}
|
||||
|
||||
final double fillRate = totalNeeded == 0 ? 100.0 : (totalFilled / totalNeeded) * 100.0;
|
||||
final double completionRate = shifts.isEmpty ? 100.0 : (completedCount / shifts.length) * 100.0;
|
||||
final double avgFillTimeHours = filledShiftsWithTime == 0
|
||||
? 0
|
||||
: (totalFillTimeSeconds / filledShiftsWithTime) / 3600;
|
||||
|
||||
return PerformanceReport(
|
||||
fillRate: fillRate,
|
||||
completionRate: completionRate,
|
||||
onTimeRate: 95.0,
|
||||
avgFillTimeHours: avgFillTimeHours,
|
||||
keyPerformanceIndicators: <PerformanceMetric>[
|
||||
PerformanceMetric(label: 'Fill Rate', value: '${fillRate.toStringAsFixed(1)}%', trend: 0.02),
|
||||
PerformanceMetric(label: 'Completion', value: '${completionRate.toStringAsFixed(1)}%', trend: 0.05),
|
||||
PerformanceMetric(label: 'Avg Fill Time', value: '${avgFillTimeHours.toStringAsFixed(1)}h', trend: -0.1),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<NoShowReport> getNoShowReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final String id = businessId ?? await _service.getBusinessId();
|
||||
|
||||
final QueryResult<dc.ListShiftsForNoShowRangeByBusinessData, dc.ListShiftsForNoShowRangeByBusinessVariables> shiftsResponse = await _service.connector
|
||||
.listShiftsForNoShowRangeByBusiness(
|
||||
businessId: id,
|
||||
startDate: _service.toTimestamp(startDate),
|
||||
endDate: _service.toTimestamp(endDate),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final List<String> shiftIds = shiftsResponse.data.shifts.map((dc.ListShiftsForNoShowRangeByBusinessShifts s) => s.id).toList();
|
||||
if (shiftIds.isEmpty) {
|
||||
return const NoShowReport(totalNoShows: 0, noShowRate: 0, flaggedWorkers: <NoShowWorker>[]);
|
||||
}
|
||||
|
||||
final QueryResult<dc.ListApplicationsForNoShowRangeData, dc.ListApplicationsForNoShowRangeVariables> appsResponse = await _service.connector
|
||||
.listApplicationsForNoShowRange(shiftIds: shiftIds)
|
||||
.execute();
|
||||
|
||||
final List<dc.ListApplicationsForNoShowRangeApplications> apps = appsResponse.data.applications;
|
||||
final List<dc.ListApplicationsForNoShowRangeApplications> noShowApps = apps.where((dc.ListApplicationsForNoShowRangeApplications a) => (a.status.stringValue) == 'NO_SHOW').toList();
|
||||
final List<String> noShowStaffIds = noShowApps.map((dc.ListApplicationsForNoShowRangeApplications a) => a.staffId).toSet().toList();
|
||||
|
||||
if (noShowStaffIds.isEmpty) {
|
||||
return NoShowReport(
|
||||
totalNoShows: noShowApps.length,
|
||||
noShowRate: apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0,
|
||||
flaggedWorkers: <NoShowWorker>[],
|
||||
);
|
||||
}
|
||||
|
||||
final QueryResult<dc.ListStaffForNoShowReportData, dc.ListStaffForNoShowReportVariables> staffResponse = await _service.connector
|
||||
.listStaffForNoShowReport(staffIds: noShowStaffIds)
|
||||
.execute();
|
||||
|
||||
final List<dc.ListStaffForNoShowReportStaffs> staffList = staffResponse.data.staffs;
|
||||
|
||||
final List<NoShowWorker> flaggedWorkers = staffList.map((dc.ListStaffForNoShowReportStaffs s) => NoShowWorker(
|
||||
id: s.id,
|
||||
fullName: s.fullName ?? '',
|
||||
noShowCount: s.noShowCount ?? 0,
|
||||
reliabilityScore: (s.reliabilityScore ?? 0.0).toDouble(),
|
||||
)).toList();
|
||||
|
||||
return NoShowReport(
|
||||
totalNoShows: noShowApps.length,
|
||||
noShowRate: apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0,
|
||||
flaggedWorkers: flaggedWorkers,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ReportsSummary> getReportsSummary({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final String id = businessId ?? await _service.getBusinessId();
|
||||
|
||||
// Use forecast query for hours/cost data
|
||||
final QueryResult<dc.ListShiftsForForecastByBusinessData, dc.ListShiftsForForecastByBusinessVariables> shiftsResponse = await _service.connector
|
||||
.listShiftsForForecastByBusiness(
|
||||
businessId: id,
|
||||
startDate: _service.toTimestamp(startDate),
|
||||
endDate: _service.toTimestamp(endDate),
|
||||
)
|
||||
.execute();
|
||||
|
||||
// Use performance query for avgFillTime (has filledAt + createdAt)
|
||||
final QueryResult<dc.ListShiftsForPerformanceByBusinessData, dc.ListShiftsForPerformanceByBusinessVariables> perfResponse = await _service.connector
|
||||
.listShiftsForPerformanceByBusiness(
|
||||
businessId: id,
|
||||
startDate: _service.toTimestamp(startDate),
|
||||
endDate: _service.toTimestamp(endDate),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final QueryResult<dc.ListInvoicesForSpendByBusinessData, dc.ListInvoicesForSpendByBusinessVariables> invoicesResponse = await _service.connector
|
||||
.listInvoicesForSpendByBusiness(
|
||||
businessId: id,
|
||||
startDate: _service.toTimestamp(startDate),
|
||||
endDate: _service.toTimestamp(endDate),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final List<dc.ListShiftsForForecastByBusinessShifts> forecastShifts = shiftsResponse.data.shifts;
|
||||
final List<dc.ListShiftsForPerformanceByBusinessShifts> perfShifts = perfResponse.data.shifts;
|
||||
final List<dc.ListInvoicesForSpendByBusinessInvoices> invoices = invoicesResponse.data.invoices;
|
||||
|
||||
// Aggregate hours and fill rate from forecast shifts
|
||||
double totalHours = 0;
|
||||
int totalNeeded = 0;
|
||||
|
||||
for (final dc.ListShiftsForForecastByBusinessShifts shift in forecastShifts) {
|
||||
totalHours += (shift.hours ?? 0).toDouble();
|
||||
totalNeeded += shift.workersNeeded ?? 0;
|
||||
}
|
||||
|
||||
// Aggregate fill rate from performance shifts (has 'filled' field)
|
||||
int perfNeeded = 0;
|
||||
int perfFilled = 0;
|
||||
double totalFillTimeSeconds = 0;
|
||||
int filledShiftsWithTime = 0;
|
||||
|
||||
for (final dc.ListShiftsForPerformanceByBusinessShifts shift in perfShifts) {
|
||||
perfNeeded += shift.workersNeeded ?? 0;
|
||||
perfFilled += shift.filled ?? 0;
|
||||
|
||||
if (shift.filledAt != null && shift.createdAt != null) {
|
||||
final DateTime createdAt = shift.createdAt!.toDateTime();
|
||||
final DateTime filledAt = shift.filledAt!.toDateTime();
|
||||
totalFillTimeSeconds += filledAt.difference(createdAt).inSeconds;
|
||||
filledShiftsWithTime++;
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate total spend from invoices
|
||||
double totalSpend = 0;
|
||||
for (final dc.ListInvoicesForSpendByBusinessInvoices inv in invoices) {
|
||||
totalSpend += (inv.amount ?? 0).toDouble();
|
||||
}
|
||||
|
||||
// Fetch no-show rate using forecast shift IDs
|
||||
final List<String> shiftIds = forecastShifts.map((dc.ListShiftsForForecastByBusinessShifts s) => s.id).toList();
|
||||
double noShowRate = 0;
|
||||
if (shiftIds.isNotEmpty) {
|
||||
final QueryResult<dc.ListApplicationsForNoShowRangeData, dc.ListApplicationsForNoShowRangeVariables> appsResponse = await _service.connector
|
||||
.listApplicationsForNoShowRange(shiftIds: shiftIds)
|
||||
.execute();
|
||||
final List<dc.ListApplicationsForNoShowRangeApplications> apps = appsResponse.data.applications;
|
||||
final List<dc.ListApplicationsForNoShowRangeApplications> noShowApps = apps.where((dc.ListApplicationsForNoShowRangeApplications a) => (a.status.stringValue) == 'NO_SHOW').toList();
|
||||
noShowRate = apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0;
|
||||
}
|
||||
|
||||
final double fillRate = perfNeeded == 0 ? 100.0 : (perfFilled / perfNeeded) * 100.0;
|
||||
|
||||
return ReportsSummary(
|
||||
totalHours: totalHours,
|
||||
otHours: totalHours * 0.05, // ~5% OT approximation until schema supports it
|
||||
totalSpend: totalSpend,
|
||||
fillRate: fillRate,
|
||||
avgFillTimeHours: filledShiftsWithTime == 0
|
||||
? 0
|
||||
: (totalFillTimeSeconds / filledShiftsWithTime) / 3600,
|
||||
noShowRate: noShowRate,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for reports connector queries.
|
||||
///
|
||||
/// This interface defines the contract for accessing report-related data
|
||||
/// from the backend via Data Connect.
|
||||
abstract interface class ReportsConnectorRepository {
|
||||
/// Fetches the daily operations report for a specific business and date.
|
||||
Future<DailyOpsReport> getDailyOpsReport({
|
||||
String? businessId,
|
||||
required DateTime date,
|
||||
});
|
||||
|
||||
/// Fetches the spend report for a specific business and date range.
|
||||
Future<SpendReport> getSpendReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
});
|
||||
|
||||
/// Fetches the coverage report for a specific business and date range.
|
||||
Future<CoverageReport> getCoverageReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
});
|
||||
|
||||
/// Fetches the forecast report for a specific business and date range.
|
||||
Future<ForecastReport> getForecastReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
});
|
||||
|
||||
/// Fetches the performance report for a specific business and date range.
|
||||
Future<PerformanceReport> getPerformanceReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
});
|
||||
|
||||
/// Fetches the no-show report for a specific business and date range.
|
||||
Future<NoShowReport> getNoShowReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
});
|
||||
|
||||
/// Fetches a summary of all reports for a specific business and date range.
|
||||
Future<ReportsSummary> getReportsSummary({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
});
|
||||
}
|
||||
@@ -1,797 +0,0 @@
|
||||
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/shifts_connector_repository.dart';
|
||||
|
||||
/// Implementation of [ShiftsConnectorRepository].
|
||||
///
|
||||
/// Handles shift-related data operations by interacting with Data Connect.
|
||||
class ShiftsConnectorRepositoryImpl implements ShiftsConnectorRepository {
|
||||
/// Creates a new [ShiftsConnectorRepositoryImpl].
|
||||
ShiftsConnectorRepositoryImpl({dc.DataConnectService? service})
|
||||
: _service = service ?? dc.DataConnectService.instance;
|
||||
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getMyShifts({
|
||||
required String staffId,
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final dc.GetApplicationsByStaffIdVariablesBuilder query = _service
|
||||
.connector
|
||||
.getApplicationsByStaffId(staffId: staffId)
|
||||
.dayStart(_service.toTimestamp(start))
|
||||
.dayEnd(_service.toTimestamp(end));
|
||||
|
||||
final QueryResult<
|
||||
dc.GetApplicationsByStaffIdData,
|
||||
dc.GetApplicationsByStaffIdVariables
|
||||
>
|
||||
response = await query.execute();
|
||||
return _mapApplicationsToShifts(response.data.applications);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getAvailableShifts({
|
||||
required String staffId,
|
||||
String? query,
|
||||
String? type,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
// First, fetch all available shift roles for the vendor/business
|
||||
// Use the session owner ID (vendorId)
|
||||
final String? vendorId = dc.StaffSessionStore.instance.session?.ownerId;
|
||||
if (vendorId == null || vendorId.isEmpty) return <Shift>[];
|
||||
|
||||
final QueryResult<
|
||||
dc.ListShiftRolesByVendorIdData,
|
||||
dc.ListShiftRolesByVendorIdVariables
|
||||
>
|
||||
response = await _service.connector
|
||||
.listShiftRolesByVendorId(vendorId: vendorId)
|
||||
.execute();
|
||||
|
||||
final List<dc.ListShiftRolesByVendorIdShiftRoles> allShiftRoles =
|
||||
response.data.shiftRoles;
|
||||
|
||||
// Fetch current applications to filter out already booked shifts
|
||||
final QueryResult<
|
||||
dc.GetApplicationsByStaffIdData,
|
||||
dc.GetApplicationsByStaffIdVariables
|
||||
>
|
||||
myAppsResponse = await _service.connector
|
||||
.getApplicationsByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
final Set<String> appliedShiftIds = myAppsResponse.data.applications
|
||||
.map((dc.GetApplicationsByStaffIdApplications a) => a.shiftId)
|
||||
.toSet();
|
||||
|
||||
final List<Shift> mappedShifts = <Shift>[];
|
||||
for (final dc.ListShiftRolesByVendorIdShiftRoles sr in allShiftRoles) {
|
||||
if (appliedShiftIds.contains(sr.shiftId)) continue;
|
||||
|
||||
final DateTime? shiftDate = _service.toDateTime(sr.shift.date);
|
||||
final DateTime? startDt = _service.toDateTime(sr.startTime);
|
||||
final DateTime? endDt = _service.toDateTime(sr.endTime);
|
||||
final DateTime? createdDt = _service.toDateTime(sr.createdAt);
|
||||
|
||||
// Normalise orderType to uppercase for consistent checks in the UI.
|
||||
// RECURRING → groups shifts into Multi-Day cards.
|
||||
// PERMANENT → groups shifts into Long Term cards.
|
||||
final String orderTypeStr = sr.shift.order.orderType.stringValue
|
||||
.toUpperCase();
|
||||
|
||||
final dc.ListShiftRolesByVendorIdShiftRolesShiftOrder order =
|
||||
sr.shift.order;
|
||||
final DateTime? startDate = _service.toDateTime(order.startDate);
|
||||
final DateTime? endDate = _service.toDateTime(order.endDate);
|
||||
|
||||
final String startTime = startDt != null
|
||||
? DateFormat('HH:mm').format(startDt)
|
||||
: '';
|
||||
final String endTime = endDt != null
|
||||
? DateFormat('HH:mm').format(endDt)
|
||||
: '';
|
||||
|
||||
final List<ShiftSchedule>? schedules = _generateSchedules(
|
||||
orderType: orderTypeStr,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
recurringDays: order.recurringDays,
|
||||
permanentDays: order.permanentDays,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
);
|
||||
|
||||
mappedShifts.add(
|
||||
Shift(
|
||||
id: sr.shiftId,
|
||||
roleId: sr.roleId,
|
||||
title: sr.role.name,
|
||||
clientName: sr.shift.order.business.businessName,
|
||||
logoUrl: null,
|
||||
hourlyRate: sr.role.costPerHour,
|
||||
location: sr.shift.location ?? '',
|
||||
locationAddress: sr.shift.locationAddress ?? '',
|
||||
date: shiftDate?.toIso8601String() ?? '',
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: sr.shift.status?.stringValue.toLowerCase() ?? 'open',
|
||||
description: sr.shift.description,
|
||||
durationDays: sr.shift.durationDays ?? schedules?.length,
|
||||
requiredSlots: sr.count,
|
||||
filledSlots: sr.assigned ?? 0,
|
||||
latitude: sr.shift.latitude,
|
||||
longitude: sr.shift.longitude,
|
||||
// orderId + orderType power the grouping and type-badge logic in
|
||||
// FindShiftsTab._groupMultiDayShifts and MyShiftCard._getShiftType.
|
||||
orderId: sr.shift.orderId,
|
||||
orderType: orderTypeStr,
|
||||
startDate: startDate?.toIso8601String(),
|
||||
endDate: endDate?.toIso8601String(),
|
||||
recurringDays: sr.shift.order.recurringDays,
|
||||
permanentDays: sr.shift.order.permanentDays,
|
||||
schedules: schedules,
|
||||
breakInfo: BreakAdapter.fromData(
|
||||
isPaid: sr.isBreakPaid ?? false,
|
||||
breakTime: sr.breakType?.stringValue,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (query != null && query.isNotEmpty) {
|
||||
final String lowerQuery = query.toLowerCase();
|
||||
return mappedShifts.where((Shift s) {
|
||||
return s.title.toLowerCase().contains(lowerQuery) ||
|
||||
s.clientName.toLowerCase().contains(lowerQuery);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
return mappedShifts;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getPendingAssignments({required String staffId}) async {
|
||||
return _service.run(() async {
|
||||
// Current schema doesn't have a specific "pending assignment" query that differs from confirmed
|
||||
// unless we filter by status. In the old repo it was returning an empty list.
|
||||
return <Shift>[];
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Shift?> getShiftDetails({
|
||||
required String shiftId,
|
||||
required String staffId,
|
||||
String? roleId,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
if (roleId != null && roleId.isNotEmpty) {
|
||||
final QueryResult<dc.GetShiftRoleByIdData, dc.GetShiftRoleByIdVariables>
|
||||
roleResult = await _service.connector
|
||||
.getShiftRoleById(shiftId: shiftId, roleId: roleId)
|
||||
.execute();
|
||||
final dc.GetShiftRoleByIdShiftRole? sr = roleResult.data.shiftRole;
|
||||
if (sr == null) return null;
|
||||
|
||||
final DateTime? startDt = _service.toDateTime(sr.startTime);
|
||||
final DateTime? endDt = _service.toDateTime(sr.endTime);
|
||||
final DateTime? createdDt = _service.toDateTime(sr.createdAt);
|
||||
|
||||
bool hasApplied = false;
|
||||
String status = 'open';
|
||||
|
||||
final QueryResult<
|
||||
dc.GetApplicationsByStaffIdData,
|
||||
dc.GetApplicationsByStaffIdVariables
|
||||
>
|
||||
appsResponse = await _service.connector
|
||||
.getApplicationsByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
|
||||
final dc.GetApplicationsByStaffIdApplications? app = appsResponse
|
||||
.data
|
||||
.applications
|
||||
.where(
|
||||
(dc.GetApplicationsByStaffIdApplications a) =>
|
||||
a.shiftId == shiftId && a.shiftRole.roleId == roleId,
|
||||
)
|
||||
.firstOrNull;
|
||||
|
||||
if (app != null) {
|
||||
hasApplied = true;
|
||||
final String s = app.status.stringValue;
|
||||
status = _mapApplicationStatus(s);
|
||||
}
|
||||
|
||||
return Shift(
|
||||
id: sr.shiftId,
|
||||
roleId: sr.roleId,
|
||||
title: sr.shift.order.business.businessName,
|
||||
clientName: sr.shift.order.business.businessName,
|
||||
logoUrl: sr.shift.order.business.companyLogoUrl,
|
||||
hourlyRate: sr.role.costPerHour,
|
||||
location: sr.shift.location ?? sr.shift.order.teamHub.hubName,
|
||||
locationAddress: sr.shift.locationAddress ?? '',
|
||||
date: startDt?.toIso8601String() ?? '',
|
||||
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
|
||||
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: status,
|
||||
description: sr.shift.description,
|
||||
durationDays: null,
|
||||
requiredSlots: sr.count,
|
||||
filledSlots: sr.assigned ?? 0,
|
||||
hasApplied: hasApplied,
|
||||
totalValue: sr.totalValue,
|
||||
latitude: sr.shift.latitude,
|
||||
longitude: sr.shift.longitude,
|
||||
breakInfo: BreakAdapter.fromData(
|
||||
isPaid: sr.isBreakPaid ?? false,
|
||||
breakTime: sr.breakType?.stringValue,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final QueryResult<dc.GetShiftByIdData, dc.GetShiftByIdVariables> result =
|
||||
await _service.connector.getShiftById(id: shiftId).execute();
|
||||
final dc.GetShiftByIdShift? s = result.data.shift;
|
||||
if (s == null) return null;
|
||||
|
||||
int? required;
|
||||
int? filled;
|
||||
Break? breakInfo;
|
||||
|
||||
try {
|
||||
final QueryResult<
|
||||
dc.ListShiftRolesByShiftIdData,
|
||||
dc.ListShiftRolesByShiftIdVariables
|
||||
>
|
||||
rolesRes = await _service.connector
|
||||
.listShiftRolesByShiftId(shiftId: shiftId)
|
||||
.execute();
|
||||
if (rolesRes.data.shiftRoles.isNotEmpty) {
|
||||
required = 0;
|
||||
filled = 0;
|
||||
for (dc.ListShiftRolesByShiftIdShiftRoles r
|
||||
in rolesRes.data.shiftRoles) {
|
||||
required = (required ?? 0) + r.count;
|
||||
filled = (filled ?? 0) + (r.assigned ?? 0);
|
||||
}
|
||||
final dc.ListShiftRolesByShiftIdShiftRoles firstRole =
|
||||
rolesRes.data.shiftRoles.first;
|
||||
breakInfo = BreakAdapter.fromData(
|
||||
isPaid: firstRole.isBreakPaid ?? false,
|
||||
breakTime: firstRole.breakType?.stringValue,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final DateTime? startDt = _service.toDateTime(s.startTime);
|
||||
final DateTime? endDt = _service.toDateTime(s.endTime);
|
||||
final DateTime? createdDt = _service.toDateTime(s.createdAt);
|
||||
|
||||
return Shift(
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
clientName: s.order.business.businessName,
|
||||
logoUrl: null,
|
||||
hourlyRate: s.cost ?? 0.0,
|
||||
location: s.location ?? '',
|
||||
locationAddress: s.locationAddress ?? '',
|
||||
date: startDt?.toIso8601String() ?? '',
|
||||
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
|
||||
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: s.status?.stringValue ?? 'OPEN',
|
||||
description: s.description,
|
||||
durationDays: s.durationDays,
|
||||
requiredSlots: required,
|
||||
filledSlots: filled,
|
||||
latitude: s.latitude,
|
||||
longitude: s.longitude,
|
||||
breakInfo: breakInfo,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> applyForShift({
|
||||
required String shiftId,
|
||||
required String staffId,
|
||||
bool isInstantBook = false,
|
||||
String? roleId,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final String targetRoleId = roleId ?? '';
|
||||
if (targetRoleId.isEmpty) throw Exception('Missing role id.');
|
||||
|
||||
// 1. Fetch the initial shift to determine order type
|
||||
final QueryResult<dc.GetShiftByIdData, dc.GetShiftByIdVariables>
|
||||
shiftResult = await _service.connector
|
||||
.getShiftById(id: shiftId)
|
||||
.execute();
|
||||
final dc.GetShiftByIdShift? initialShift = shiftResult.data.shift;
|
||||
if (initialShift == null) throw Exception('Shift not found');
|
||||
|
||||
final dc.EnumValue<dc.OrderType> orderTypeEnum =
|
||||
initialShift.order.orderType;
|
||||
final bool isMultiDay =
|
||||
orderTypeEnum is dc.Known<dc.OrderType> &&
|
||||
(orderTypeEnum.value == dc.OrderType.RECURRING ||
|
||||
orderTypeEnum.value == dc.OrderType.PERMANENT);
|
||||
final List<_TargetShiftRole> targets = [];
|
||||
|
||||
if (isMultiDay) {
|
||||
// 2. Fetch all shifts for this order to apply to all of them for the same role
|
||||
final QueryResult<
|
||||
dc.ListShiftRolesByBusinessAndOrderData,
|
||||
dc.ListShiftRolesByBusinessAndOrderVariables
|
||||
>
|
||||
allRolesRes = await _service.connector
|
||||
.listShiftRolesByBusinessAndOrder(
|
||||
businessId: initialShift.order.businessId,
|
||||
orderId: initialShift.orderId,
|
||||
)
|
||||
.execute();
|
||||
|
||||
for (final role in allRolesRes.data.shiftRoles) {
|
||||
if (role.roleId == targetRoleId) {
|
||||
targets.add(
|
||||
_TargetShiftRole(
|
||||
shiftId: role.shiftId,
|
||||
roleId: role.roleId,
|
||||
count: role.count,
|
||||
assigned: role.assigned ?? 0,
|
||||
shiftFilled: role.shift.filled ?? 0,
|
||||
date: _service.toDateTime(role.shift.date),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single shift application
|
||||
final QueryResult<dc.GetShiftRoleByIdData, dc.GetShiftRoleByIdVariables>
|
||||
roleResult = await _service.connector
|
||||
.getShiftRoleById(shiftId: shiftId, roleId: targetRoleId)
|
||||
.execute();
|
||||
final dc.GetShiftRoleByIdShiftRole? role = roleResult.data.shiftRole;
|
||||
if (role == null) throw Exception('Shift role not found');
|
||||
|
||||
targets.add(
|
||||
_TargetShiftRole(
|
||||
shiftId: shiftId,
|
||||
roleId: targetRoleId,
|
||||
count: role.count,
|
||||
assigned: role.assigned ?? 0,
|
||||
shiftFilled: initialShift.filled ?? 0,
|
||||
date: _service.toDateTime(initialShift.date),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (targets.isEmpty) {
|
||||
throw Exception('No valid shifts found to apply for.');
|
||||
}
|
||||
|
||||
int appliedCount = 0;
|
||||
final List<String> errors = [];
|
||||
|
||||
for (final target in targets) {
|
||||
try {
|
||||
await _applyToSingleShiftRole(target: target, staffId: staffId);
|
||||
appliedCount++;
|
||||
} catch (e) {
|
||||
// For multi-shift apply, we might want to continue even if some fail due to conflicts
|
||||
if (targets.length == 1) rethrow;
|
||||
errors.add('Shift on ${target.date}: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
if (appliedCount == 0 && targets.length > 1) {
|
||||
throw Exception('Failed to apply for any shifts: ${errors.join(", ")}');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _applyToSingleShiftRole({
|
||||
required _TargetShiftRole target,
|
||||
required String staffId,
|
||||
}) async {
|
||||
// Validate daily limit
|
||||
if (target.date != null) {
|
||||
final DateTime dayStartUtc = DateTime.utc(
|
||||
target.date!.year,
|
||||
target.date!.month,
|
||||
target.date!.day,
|
||||
);
|
||||
final DateTime dayEndUtc = dayStartUtc
|
||||
.add(const Duration(days: 1))
|
||||
.subtract(const Duration(microseconds: 1));
|
||||
|
||||
final QueryResult<
|
||||
dc.VaidateDayStaffApplicationData,
|
||||
dc.VaidateDayStaffApplicationVariables
|
||||
>
|
||||
validationResponse = await _service.connector
|
||||
.vaidateDayStaffApplication(staffId: staffId)
|
||||
.dayStart(_service.toTimestamp(dayStartUtc))
|
||||
.dayEnd(_service.toTimestamp(dayEndUtc))
|
||||
.execute();
|
||||
|
||||
// if (validationResponse.data.applications.isNotEmpty) {
|
||||
// throw Exception('The user already has a shift that day.');
|
||||
// }
|
||||
}
|
||||
|
||||
// Check for existing application
|
||||
final QueryResult<
|
||||
dc.GetApplicationByStaffShiftAndRoleData,
|
||||
dc.GetApplicationByStaffShiftAndRoleVariables
|
||||
>
|
||||
existingAppRes = await _service.connector
|
||||
.getApplicationByStaffShiftAndRole(
|
||||
staffId: staffId,
|
||||
shiftId: target.shiftId,
|
||||
roleId: target.roleId,
|
||||
)
|
||||
.execute();
|
||||
|
||||
if (existingAppRes.data.applications.isNotEmpty) {
|
||||
throw Exception('Application already exists.');
|
||||
}
|
||||
|
||||
if (target.assigned >= target.count) {
|
||||
throw Exception('This shift is full.');
|
||||
}
|
||||
|
||||
String? createdAppId;
|
||||
try {
|
||||
final OperationResult<
|
||||
dc.CreateApplicationData,
|
||||
dc.CreateApplicationVariables
|
||||
>
|
||||
createRes = await _service.connector
|
||||
.createApplication(
|
||||
shiftId: target.shiftId,
|
||||
staffId: staffId,
|
||||
roleId: target.roleId,
|
||||
status: dc.ApplicationStatus.CONFIRMED,
|
||||
origin: dc.ApplicationOrigin.STAFF,
|
||||
)
|
||||
.execute();
|
||||
|
||||
createdAppId = createRes.data.application_insert.id;
|
||||
|
||||
await _service.connector
|
||||
.updateShiftRole(shiftId: target.shiftId, roleId: target.roleId)
|
||||
.assigned(target.assigned + 1)
|
||||
.execute();
|
||||
|
||||
await _service.connector
|
||||
.updateShift(id: target.shiftId)
|
||||
.filled(target.shiftFilled + 1)
|
||||
.execute();
|
||||
} catch (e) {
|
||||
// Simple rollback attempt (not guaranteed)
|
||||
if (createdAppId != null) {
|
||||
await _service.connector.deleteApplication(id: createdAppId).execute();
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> acceptShift({required String shiftId, required String staffId}) {
|
||||
return _updateApplicationStatus(
|
||||
shiftId,
|
||||
staffId,
|
||||
dc.ApplicationStatus.CONFIRMED,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> declineShift({
|
||||
required String shiftId,
|
||||
required String staffId,
|
||||
}) {
|
||||
return _updateApplicationStatus(
|
||||
shiftId,
|
||||
staffId,
|
||||
dc.ApplicationStatus.REJECTED,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getCancelledShifts({required String staffId}) async {
|
||||
return _service.run(() async {
|
||||
// Logic would go here to fetch by REJECTED status if needed
|
||||
return <Shift>[];
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getHistoryShifts({required String staffId}) async {
|
||||
return _service.run(() async {
|
||||
final QueryResult<
|
||||
dc.ListCompletedApplicationsByStaffIdData,
|
||||
dc.ListCompletedApplicationsByStaffIdVariables
|
||||
>
|
||||
response = await _service.connector
|
||||
.listCompletedApplicationsByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
|
||||
final List<Shift> shifts = <Shift>[];
|
||||
for (final dc.ListCompletedApplicationsByStaffIdApplications app
|
||||
in response.data.applications) {
|
||||
final String roleName = app.shiftRole.role.name;
|
||||
final String orderName =
|
||||
(app.shift.order.eventName ?? '').trim().isNotEmpty
|
||||
? app.shift.order.eventName!
|
||||
: app.shift.order.business.businessName;
|
||||
final String title = '$roleName - $orderName';
|
||||
|
||||
final DateTime? shiftDate = _service.toDateTime(app.shift.date);
|
||||
final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime);
|
||||
final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime);
|
||||
final DateTime? createdDt = _service.toDateTime(app.createdAt);
|
||||
|
||||
shifts.add(
|
||||
Shift(
|
||||
id: app.shift.id,
|
||||
roleId: app.shiftRole.roleId,
|
||||
title: title,
|
||||
clientName: app.shift.order.business.businessName,
|
||||
logoUrl: app.shift.order.business.companyLogoUrl,
|
||||
hourlyRate: app.shiftRole.role.costPerHour,
|
||||
location: app.shift.location ?? '',
|
||||
locationAddress: app.shift.order.teamHub.hubName,
|
||||
date: shiftDate?.toIso8601String() ?? '',
|
||||
startTime: startDt != null
|
||||
? DateFormat('HH:mm').format(startDt)
|
||||
: '',
|
||||
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: 'completed', // Hardcoded as checked out implies completion
|
||||
description: app.shift.description,
|
||||
durationDays: app.shift.durationDays,
|
||||
requiredSlots: app.shiftRole.count,
|
||||
filledSlots: app.shiftRole.assigned ?? 0,
|
||||
hasApplied: true,
|
||||
latitude: app.shift.latitude,
|
||||
longitude: app.shift.longitude,
|
||||
breakInfo: BreakAdapter.fromData(
|
||||
isPaid: app.shiftRole.isBreakPaid ?? false,
|
||||
breakTime: app.shiftRole.breakType?.stringValue,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return shifts;
|
||||
});
|
||||
}
|
||||
|
||||
// --- PRIVATE HELPERS ---
|
||||
|
||||
List<Shift> _mapApplicationsToShifts(List<dynamic> apps) {
|
||||
return apps.map((app) {
|
||||
final String roleName = app.shiftRole.role.name;
|
||||
final String orderName =
|
||||
(app.shift.order.eventName ?? '').trim().isNotEmpty
|
||||
? app.shift.order.eventName!
|
||||
: app.shift.order.business.businessName;
|
||||
final String title = '$roleName - $orderName';
|
||||
|
||||
final DateTime? shiftDate = _service.toDateTime(app.shift.date);
|
||||
final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime);
|
||||
final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime);
|
||||
final DateTime? createdDt = _service.toDateTime(app.createdAt);
|
||||
|
||||
final bool hasCheckIn = app.checkInTime != null;
|
||||
final bool hasCheckOut = app.checkOutTime != null;
|
||||
|
||||
String status;
|
||||
if (hasCheckOut) {
|
||||
status = 'completed';
|
||||
} else if (hasCheckIn) {
|
||||
status = 'checked_in';
|
||||
} else {
|
||||
status = _mapApplicationStatus(app.status.stringValue);
|
||||
}
|
||||
|
||||
return Shift(
|
||||
id: app.shift.id,
|
||||
roleId: app.shiftRole.roleId,
|
||||
title: title,
|
||||
clientName: app.shift.order.business.businessName,
|
||||
logoUrl: app.shift.order.business.companyLogoUrl,
|
||||
hourlyRate: app.shiftRole.role.costPerHour,
|
||||
location: app.shift.location ?? '',
|
||||
locationAddress: app.shift.order.teamHub.hubName,
|
||||
date: shiftDate?.toIso8601String() ?? '',
|
||||
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
|
||||
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: status,
|
||||
description: app.shift.description,
|
||||
durationDays: app.shift.durationDays,
|
||||
requiredSlots: app.shiftRole.count,
|
||||
filledSlots: app.shiftRole.assigned ?? 0,
|
||||
hasApplied: true,
|
||||
latitude: app.shift.latitude,
|
||||
longitude: app.shift.longitude,
|
||||
breakInfo: BreakAdapter.fromData(
|
||||
isPaid: app.shiftRole.isBreakPaid ?? false,
|
||||
breakTime: app.shiftRole.breakType?.stringValue,
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
String _mapApplicationStatus(String status) {
|
||||
switch (status) {
|
||||
case 'CONFIRMED':
|
||||
return 'confirmed';
|
||||
case 'PENDING':
|
||||
return 'pending';
|
||||
case 'CHECKED_OUT':
|
||||
return 'completed';
|
||||
case 'REJECTED':
|
||||
return 'cancelled';
|
||||
default:
|
||||
return 'open';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateApplicationStatus(
|
||||
String shiftId,
|
||||
String staffId,
|
||||
dc.ApplicationStatus newStatus,
|
||||
) async {
|
||||
return _service.run(() async {
|
||||
// First try to find the application
|
||||
final QueryResult<
|
||||
dc.GetApplicationsByStaffIdData,
|
||||
dc.GetApplicationsByStaffIdVariables
|
||||
>
|
||||
appsResponse = await _service.connector
|
||||
.getApplicationsByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
|
||||
final dc.GetApplicationsByStaffIdApplications? app = appsResponse
|
||||
.data
|
||||
.applications
|
||||
.where(
|
||||
(dc.GetApplicationsByStaffIdApplications a) => a.shiftId == shiftId,
|
||||
)
|
||||
.firstOrNull;
|
||||
|
||||
if (app != null) {
|
||||
await _service.connector
|
||||
.updateApplicationStatus(id: app.id)
|
||||
.status(newStatus)
|
||||
.execute();
|
||||
} else if (newStatus == dc.ApplicationStatus.REJECTED) {
|
||||
// If declining but no app found, create a rejected application
|
||||
final QueryResult<
|
||||
dc.ListShiftRolesByShiftIdData,
|
||||
dc.ListShiftRolesByShiftIdVariables
|
||||
>
|
||||
rolesRes = await _service.connector
|
||||
.listShiftRolesByShiftId(shiftId: shiftId)
|
||||
.execute();
|
||||
|
||||
if (rolesRes.data.shiftRoles.isNotEmpty) {
|
||||
final dc.ListShiftRolesByShiftIdShiftRoles firstRole =
|
||||
rolesRes.data.shiftRoles.first;
|
||||
await _service.connector
|
||||
.createApplication(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
roleId: firstRole.id,
|
||||
status: dc.ApplicationStatus.REJECTED,
|
||||
origin: dc.ApplicationOrigin.STAFF,
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
} else {
|
||||
throw Exception("Application not found for shift $shiftId");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Generates a list of [ShiftSchedule] for RECURRING or PERMANENT orders.
|
||||
List<ShiftSchedule>? _generateSchedules({
|
||||
required String orderType,
|
||||
required DateTime? startDate,
|
||||
required DateTime? endDate,
|
||||
required List<String>? recurringDays,
|
||||
required List<String>? permanentDays,
|
||||
required String startTime,
|
||||
required String endTime,
|
||||
}) {
|
||||
if (orderType != 'RECURRING' && orderType != 'PERMANENT') return null;
|
||||
if (startDate == null || endDate == null) return null;
|
||||
|
||||
final List<String>? daysToInclude = orderType == 'RECURRING'
|
||||
? recurringDays
|
||||
: permanentDays;
|
||||
if (daysToInclude == null || daysToInclude.isEmpty) return null;
|
||||
|
||||
final List<ShiftSchedule> schedules = <ShiftSchedule>[];
|
||||
final Set<int> targetWeekdayIndex = daysToInclude
|
||||
.map((String day) {
|
||||
switch (day.toUpperCase()) {
|
||||
case 'MONDAY':
|
||||
return DateTime.monday;
|
||||
case 'TUESDAY':
|
||||
return DateTime.tuesday;
|
||||
case 'WEDNESDAY':
|
||||
return DateTime.wednesday;
|
||||
case 'THURSDAY':
|
||||
return DateTime.thursday;
|
||||
case 'FRIDAY':
|
||||
return DateTime.friday;
|
||||
case 'SATURDAY':
|
||||
return DateTime.saturday;
|
||||
case 'SUNDAY':
|
||||
return DateTime.sunday;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
})
|
||||
.where((int idx) => idx != -1)
|
||||
.toSet();
|
||||
|
||||
DateTime current = startDate;
|
||||
while (current.isBefore(endDate) ||
|
||||
current.isAtSameMomentAs(endDate) ||
|
||||
// Handle cases where the time component might differ slightly by checking date equality
|
||||
(current.year == endDate.year &&
|
||||
current.month == endDate.month &&
|
||||
current.day == endDate.day)) {
|
||||
if (targetWeekdayIndex.contains(current.weekday)) {
|
||||
schedules.add(
|
||||
ShiftSchedule(
|
||||
date: current.toIso8601String(),
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
),
|
||||
);
|
||||
}
|
||||
current = current.add(const Duration(days: 1));
|
||||
|
||||
// Safety break to prevent infinite loops if dates are messed up
|
||||
if (schedules.length > 365) break;
|
||||
}
|
||||
|
||||
return schedules;
|
||||
}
|
||||
}
|
||||
|
||||
class _TargetShiftRole {
|
||||
final String shiftId;
|
||||
final String roleId;
|
||||
final int count;
|
||||
final int assigned;
|
||||
final int shiftFilled;
|
||||
final DateTime? date;
|
||||
|
||||
_TargetShiftRole({
|
||||
required this.shiftId,
|
||||
required this.roleId,
|
||||
required this.count,
|
||||
required this.assigned,
|
||||
required this.shiftFilled,
|
||||
this.date,
|
||||
});
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for shifts connector operations.
|
||||
///
|
||||
/// This acts as a buffer layer between the domain repository and the Data Connect SDK.
|
||||
abstract interface class ShiftsConnectorRepository {
|
||||
/// Retrieves shifts assigned to the current staff member.
|
||||
Future<List<Shift>> getMyShifts({
|
||||
required String staffId,
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
});
|
||||
|
||||
/// Retrieves available shifts.
|
||||
Future<List<Shift>> getAvailableShifts({
|
||||
required String staffId,
|
||||
String? query,
|
||||
String? type,
|
||||
});
|
||||
|
||||
/// Retrieves pending shift assignments for the current staff member.
|
||||
Future<List<Shift>> getPendingAssignments({required String staffId});
|
||||
|
||||
/// Retrieves detailed information for a specific shift.
|
||||
Future<Shift?> getShiftDetails({
|
||||
required String shiftId,
|
||||
required String staffId,
|
||||
String? roleId,
|
||||
});
|
||||
|
||||
/// Applies for a specific open shift.
|
||||
Future<void> applyForShift({
|
||||
required String shiftId,
|
||||
required String staffId,
|
||||
bool isInstantBook = false,
|
||||
String? roleId,
|
||||
});
|
||||
|
||||
/// Accepts a pending shift assignment.
|
||||
Future<void> acceptShift({
|
||||
required String shiftId,
|
||||
required String staffId,
|
||||
});
|
||||
|
||||
/// Declines a pending shift assignment.
|
||||
Future<void> declineShift({
|
||||
required String shiftId,
|
||||
required String staffId,
|
||||
});
|
||||
|
||||
/// Retrieves cancelled shifts for the current staff member.
|
||||
Future<List<Shift>> getCancelledShifts({required String staffId});
|
||||
|
||||
/// Retrieves historical (completed) shifts for the current staff member.
|
||||
Future<List<Shift>> getHistoryShifts({required String staffId});
|
||||
}
|
||||
@@ -1,876 +0,0 @@
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
|
||||
import '../../domain/repositories/staff_connector_repository.dart';
|
||||
|
||||
/// Implementation of [StaffConnectorRepository].
|
||||
///
|
||||
/// Fetches staff-related data from the Data Connect backend using
|
||||
/// the staff connector queries.
|
||||
class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
||||
/// Creates a new [StaffConnectorRepositoryImpl].
|
||||
///
|
||||
/// Requires a [DataConnectService] instance for backend communication.
|
||||
StaffConnectorRepositoryImpl({dc.DataConnectService? service})
|
||||
: _service = service ?? dc.DataConnectService.instance;
|
||||
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
@override
|
||||
Future<bool> getProfileCompletion() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final QueryResult<
|
||||
dc.GetStaffProfileCompletionData,
|
||||
dc.GetStaffProfileCompletionVariables
|
||||
>
|
||||
response = await _service.connector
|
||||
.getStaffProfileCompletion(id: staffId)
|
||||
.execute();
|
||||
|
||||
final dc.GetStaffProfileCompletionStaff? staff = response.data.staff;
|
||||
final List<dc.GetStaffProfileCompletionEmergencyContacts>
|
||||
emergencyContacts = response.data.emergencyContacts;
|
||||
return _isProfileComplete(staff, emergencyContacts);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> getPersonalInfoCompletion() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final QueryResult<
|
||||
dc.GetStaffPersonalInfoCompletionData,
|
||||
dc.GetStaffPersonalInfoCompletionVariables
|
||||
>
|
||||
response = await _service.connector
|
||||
.getStaffPersonalInfoCompletion(id: staffId)
|
||||
.execute();
|
||||
|
||||
final dc.GetStaffPersonalInfoCompletionStaff? staff = response.data.staff;
|
||||
return _isPersonalInfoComplete(staff);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> getEmergencyContactsCompletion() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final QueryResult<
|
||||
dc.GetStaffEmergencyProfileCompletionData,
|
||||
dc.GetStaffEmergencyProfileCompletionVariables
|
||||
>
|
||||
response = await _service.connector
|
||||
.getStaffEmergencyProfileCompletion(id: staffId)
|
||||
.execute();
|
||||
|
||||
return response.data.emergencyContacts.isNotEmpty;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> getExperienceCompletion() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final QueryResult<
|
||||
dc.GetStaffExperienceProfileCompletionData,
|
||||
dc.GetStaffExperienceProfileCompletionVariables
|
||||
>
|
||||
response = await _service.connector
|
||||
.getStaffExperienceProfileCompletion(id: staffId)
|
||||
.execute();
|
||||
|
||||
final dc.GetStaffExperienceProfileCompletionStaff? staff =
|
||||
response.data.staff;
|
||||
return _hasExperience(staff);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> getTaxFormsCompletion() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final QueryResult<
|
||||
dc.GetStaffTaxFormsProfileCompletionData,
|
||||
dc.GetStaffTaxFormsProfileCompletionVariables
|
||||
>
|
||||
response = await _service.connector
|
||||
.getStaffTaxFormsProfileCompletion(id: staffId)
|
||||
.execute();
|
||||
|
||||
final List<dc.GetStaffTaxFormsProfileCompletionTaxForms> taxForms =
|
||||
response.data.taxForms;
|
||||
|
||||
// Return false if no tax forms exist
|
||||
if (taxForms.isEmpty) return false;
|
||||
|
||||
// Return true only if all tax forms have status == "SUBMITTED"
|
||||
return taxForms.every(
|
||||
(dc.GetStaffTaxFormsProfileCompletionTaxForms form) {
|
||||
if (form.status is dc.Unknown) return false;
|
||||
final dc.TaxFormStatus status =
|
||||
(form.status as dc.Known<dc.TaxFormStatus>).value;
|
||||
return status == dc.TaxFormStatus.SUBMITTED;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool?> getAttireOptionsCompletion() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final List<QueryResult<Object, Object?>> results =
|
||||
await Future.wait<QueryResult<Object, Object?>>(
|
||||
<Future<QueryResult<Object, Object?>>>[
|
||||
_service.connector.listAttireOptions().execute(),
|
||||
_service.connector.getStaffAttire(staffId: staffId).execute(),
|
||||
],
|
||||
);
|
||||
|
||||
final QueryResult<dc.ListAttireOptionsData, void> optionsRes =
|
||||
results[0] as QueryResult<dc.ListAttireOptionsData, void>;
|
||||
final QueryResult<dc.GetStaffAttireData, dc.GetStaffAttireVariables>
|
||||
staffAttireRes =
|
||||
results[1]
|
||||
as QueryResult<dc.GetStaffAttireData, dc.GetStaffAttireVariables>;
|
||||
|
||||
final List<dc.ListAttireOptionsAttireOptions> attireOptions =
|
||||
optionsRes.data.attireOptions;
|
||||
final List<dc.GetStaffAttireStaffAttires> staffAttire =
|
||||
staffAttireRes.data.staffAttires;
|
||||
|
||||
// Get only mandatory attire options
|
||||
final List<dc.ListAttireOptionsAttireOptions> mandatoryOptions =
|
||||
attireOptions
|
||||
.where((dc.ListAttireOptionsAttireOptions opt) =>
|
||||
opt.isMandatory ?? false)
|
||||
.toList();
|
||||
|
||||
// Return null if no mandatory attire options
|
||||
if (mandatoryOptions.isEmpty) return null;
|
||||
|
||||
// Return true only if all mandatory attire items are verified
|
||||
return mandatoryOptions.every(
|
||||
(dc.ListAttireOptionsAttireOptions mandatoryOpt) {
|
||||
final dc.GetStaffAttireStaffAttires? currentAttire = staffAttire
|
||||
.where(
|
||||
(dc.GetStaffAttireStaffAttires a) =>
|
||||
a.attireOptionId == mandatoryOpt.id,
|
||||
)
|
||||
.firstOrNull;
|
||||
|
||||
if (currentAttire == null) return false; // Not uploaded
|
||||
if (currentAttire.verificationStatus is dc.Unknown) return false;
|
||||
final dc.AttireVerificationStatus status =
|
||||
(currentAttire.verificationStatus
|
||||
as dc.Known<dc.AttireVerificationStatus>)
|
||||
.value;
|
||||
return status == dc.AttireVerificationStatus.APPROVED;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool?> getStaffDocumentsCompletion() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final QueryResult<
|
||||
dc.ListStaffDocumentsByStaffIdData,
|
||||
dc.ListStaffDocumentsByStaffIdVariables
|
||||
>
|
||||
response = await _service.connector
|
||||
.listStaffDocumentsByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
|
||||
final List<dc.ListStaffDocumentsByStaffIdStaffDocuments> staffDocs =
|
||||
response.data.staffDocuments;
|
||||
|
||||
// Return null if no documents
|
||||
if (staffDocs.isEmpty) return null;
|
||||
|
||||
// Return true only if all documents are verified
|
||||
return staffDocs.every(
|
||||
(dc.ListStaffDocumentsByStaffIdStaffDocuments doc) {
|
||||
if (doc.status is dc.Unknown) return false;
|
||||
final dc.DocumentStatus status =
|
||||
(doc.status as dc.Known<dc.DocumentStatus>).value;
|
||||
return status == dc.DocumentStatus.VERIFIED;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool?> getStaffCertificatesCompletion() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final QueryResult<
|
||||
dc.ListCertificatesByStaffIdData,
|
||||
dc.ListCertificatesByStaffIdVariables
|
||||
>
|
||||
response = await _service.connector
|
||||
.listCertificatesByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
|
||||
final List<dc.ListCertificatesByStaffIdCertificates> certificates =
|
||||
response.data.certificates;
|
||||
|
||||
// Return false if no certificates
|
||||
if (certificates.isEmpty) return null;
|
||||
|
||||
// Return true only if all certificates are fully validated
|
||||
return certificates.every(
|
||||
(dc.ListCertificatesByStaffIdCertificates cert) {
|
||||
if (cert.validationStatus is dc.Unknown) return false;
|
||||
final dc.ValidationStatus status =
|
||||
(cert.validationStatus as dc.Known<dc.ValidationStatus>).value;
|
||||
return status == dc.ValidationStatus.APPROVED;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Checks if personal info is complete.
|
||||
bool _isPersonalInfoComplete(dc.GetStaffPersonalInfoCompletionStaff? staff) {
|
||||
if (staff == null) return false;
|
||||
final String fullName = staff.fullName;
|
||||
final String? email = staff.email;
|
||||
final String? phone = staff.phone;
|
||||
return fullName.trim().isNotEmpty &&
|
||||
(email?.trim().isNotEmpty ?? false) &&
|
||||
(phone?.trim().isNotEmpty ?? false);
|
||||
}
|
||||
|
||||
/// Checks if staff has experience data (skills or industries).
|
||||
bool _hasExperience(dc.GetStaffExperienceProfileCompletionStaff? staff) {
|
||||
if (staff == null) return false;
|
||||
final List<String>? skills = staff.skills;
|
||||
final List<String>? industries = staff.industries;
|
||||
return (skills?.isNotEmpty ?? false) || (industries?.isNotEmpty ?? false);
|
||||
}
|
||||
|
||||
/// Determines if the profile is complete based on all sections.
|
||||
bool _isProfileComplete(
|
||||
dc.GetStaffProfileCompletionStaff? staff,
|
||||
List<dc.GetStaffProfileCompletionEmergencyContacts> emergencyContacts,
|
||||
) {
|
||||
if (staff == null) return false;
|
||||
|
||||
final List<String>? skills = staff.skills;
|
||||
final List<String>? industries = staff.industries;
|
||||
final bool hasExperience =
|
||||
(skills?.isNotEmpty ?? false) || (industries?.isNotEmpty ?? false);
|
||||
|
||||
return (staff.fullName.trim().isNotEmpty) &&
|
||||
(staff.email?.trim().isNotEmpty ?? false) &&
|
||||
emergencyContacts.isNotEmpty &&
|
||||
hasExperience;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.Staff> getStaffProfile() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final QueryResult<dc.GetStaffByIdData, dc.GetStaffByIdVariables>
|
||||
response = await _service.connector.getStaffById(id: staffId).execute();
|
||||
|
||||
final dc.GetStaffByIdStaff? staff = response.data.staff;
|
||||
|
||||
if (staff == null) {
|
||||
throw Exception('Staff not found');
|
||||
}
|
||||
|
||||
return domain.Staff(
|
||||
id: staff.id,
|
||||
authProviderId: staff.userId,
|
||||
name: staff.fullName,
|
||||
email: staff.email ?? '',
|
||||
phone: staff.phone,
|
||||
avatar: staff.photoUrl,
|
||||
status: domain.StaffStatus.active,
|
||||
address: staff.addres,
|
||||
totalShifts: staff.totalShifts,
|
||||
averageRating: staff.averageRating,
|
||||
onTimeRate: staff.onTimeRate,
|
||||
noShowCount: staff.noShowCount,
|
||||
cancellationCount: staff.cancellationCount,
|
||||
reliabilityScore: staff.reliabilityScore,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<domain.Benefit>> getBenefits() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final QueryResult<
|
||||
dc.ListBenefitsDataByStaffIdData,
|
||||
dc.ListBenefitsDataByStaffIdVariables
|
||||
>
|
||||
response = await _service.connector
|
||||
.listBenefitsDataByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
|
||||
return response.data.benefitsDatas.map((
|
||||
dc.ListBenefitsDataByStaffIdBenefitsDatas e,
|
||||
) {
|
||||
final double total = e.vendorBenefitPlan.total?.toDouble() ?? 0.0;
|
||||
final double remaining = e.current.toDouble();
|
||||
return domain.Benefit(
|
||||
title: e.vendorBenefitPlan.title,
|
||||
entitlementHours: total,
|
||||
usedHours: (total - remaining).clamp(0.0, total),
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<domain.AttireItem>> getAttireOptions() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final List<QueryResult<Object, Object?>> results =
|
||||
await Future.wait<QueryResult<Object, Object?>>(
|
||||
<Future<QueryResult<Object, Object?>>>[
|
||||
_service.connector.listAttireOptions().execute(),
|
||||
_service.connector.getStaffAttire(staffId: staffId).execute(),
|
||||
],
|
||||
);
|
||||
|
||||
final QueryResult<dc.ListAttireOptionsData, void> optionsRes =
|
||||
results[0] as QueryResult<dc.ListAttireOptionsData, void>;
|
||||
final QueryResult<dc.GetStaffAttireData, dc.GetStaffAttireVariables>
|
||||
staffAttireRes =
|
||||
results[1]
|
||||
as QueryResult<dc.GetStaffAttireData, dc.GetStaffAttireVariables>;
|
||||
|
||||
final List<dc.GetStaffAttireStaffAttires> staffAttire =
|
||||
staffAttireRes.data.staffAttires;
|
||||
|
||||
return optionsRes.data.attireOptions.map((
|
||||
dc.ListAttireOptionsAttireOptions opt,
|
||||
) {
|
||||
final dc.GetStaffAttireStaffAttires currentAttire = staffAttire
|
||||
.firstWhere(
|
||||
(dc.GetStaffAttireStaffAttires a) => a.attireOptionId == opt.id,
|
||||
orElse: () => dc.GetStaffAttireStaffAttires(
|
||||
attireOptionId: opt.id,
|
||||
verificationPhotoUrl: null,
|
||||
verificationId: null,
|
||||
verificationStatus: null,
|
||||
),
|
||||
);
|
||||
|
||||
return domain.AttireItem(
|
||||
id: opt.id,
|
||||
code: opt.itemId,
|
||||
label: opt.label,
|
||||
description: opt.description,
|
||||
imageUrl: opt.imageUrl,
|
||||
isMandatory: opt.isMandatory ?? false,
|
||||
photoUrl: currentAttire.verificationPhotoUrl,
|
||||
verificationId: currentAttire.verificationId,
|
||||
verificationStatus: currentAttire.verificationStatus != null
|
||||
? _mapFromDCStatus(currentAttire.verificationStatus!)
|
||||
: null,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> upsertStaffAttire({
|
||||
required String attireOptionId,
|
||||
required String photoUrl,
|
||||
String? verificationId,
|
||||
domain.AttireVerificationStatus? verificationStatus,
|
||||
}) async {
|
||||
await _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
await _service.connector
|
||||
.upsertStaffAttire(staffId: staffId, attireOptionId: attireOptionId)
|
||||
.verificationPhotoUrl(photoUrl)
|
||||
.verificationId(verificationId)
|
||||
.verificationStatus(
|
||||
verificationStatus != null
|
||||
? dc.AttireVerificationStatus.values.firstWhere(
|
||||
(dc.AttireVerificationStatus e) =>
|
||||
e.name == verificationStatus.value.toUpperCase(),
|
||||
orElse: () => dc.AttireVerificationStatus.PENDING,
|
||||
)
|
||||
: null,
|
||||
)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
domain.AttireVerificationStatus _mapFromDCStatus(
|
||||
dc.EnumValue<dc.AttireVerificationStatus> status,
|
||||
) {
|
||||
if (status is dc.Unknown) {
|
||||
return domain.AttireVerificationStatus.error;
|
||||
}
|
||||
final String name =
|
||||
(status as dc.Known<dc.AttireVerificationStatus>).value.name;
|
||||
switch (name) {
|
||||
case 'PENDING':
|
||||
return domain.AttireVerificationStatus.pending;
|
||||
case 'PROCESSING':
|
||||
return domain.AttireVerificationStatus.processing;
|
||||
case 'AUTO_PASS':
|
||||
return domain.AttireVerificationStatus.autoPass;
|
||||
case 'AUTO_FAIL':
|
||||
return domain.AttireVerificationStatus.autoFail;
|
||||
case 'NEEDS_REVIEW':
|
||||
return domain.AttireVerificationStatus.needsReview;
|
||||
case 'APPROVED':
|
||||
return domain.AttireVerificationStatus.approved;
|
||||
case 'REJECTED':
|
||||
return domain.AttireVerificationStatus.rejected;
|
||||
case 'ERROR':
|
||||
return domain.AttireVerificationStatus.error;
|
||||
default:
|
||||
return domain.AttireVerificationStatus.error;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveStaffProfile({
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
String? bio,
|
||||
String? profilePictureUrl,
|
||||
}) async {
|
||||
await _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
final String? fullName = (firstName != null || lastName != null)
|
||||
? '${firstName ?? ''} ${lastName ?? ''}'.trim()
|
||||
: null;
|
||||
|
||||
await _service.connector
|
||||
.updateStaff(id: staffId)
|
||||
.fullName(fullName)
|
||||
.bio(bio)
|
||||
.photoUrl(profilePictureUrl)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
try {
|
||||
await _service.signOut();
|
||||
} catch (e) {
|
||||
throw Exception('Error signing out: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<domain.StaffDocument>> getStaffDocuments() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final List<QueryResult<Object, Object?>> results =
|
||||
await Future.wait<QueryResult<Object, Object?>>(
|
||||
<Future<QueryResult<Object, Object?>>>[
|
||||
_service.connector.listDocuments().execute(),
|
||||
_service.connector
|
||||
.listStaffDocumentsByStaffId(staffId: staffId)
|
||||
.execute(),
|
||||
],
|
||||
);
|
||||
|
||||
final QueryResult<dc.ListDocumentsData, void> documentsRes =
|
||||
results[0] as QueryResult<dc.ListDocumentsData, void>;
|
||||
final QueryResult<
|
||||
dc.ListStaffDocumentsByStaffIdData,
|
||||
dc.ListStaffDocumentsByStaffIdVariables
|
||||
>
|
||||
staffDocsRes =
|
||||
results[1]
|
||||
as QueryResult<
|
||||
dc.ListStaffDocumentsByStaffIdData,
|
||||
dc.ListStaffDocumentsByStaffIdVariables
|
||||
>;
|
||||
|
||||
final List<dc.ListStaffDocumentsByStaffIdStaffDocuments> staffDocs =
|
||||
staffDocsRes.data.staffDocuments;
|
||||
|
||||
return documentsRes.data.documents.map((dc.ListDocumentsDocuments doc) {
|
||||
// Find if this staff member has already uploaded this document
|
||||
final dc.ListStaffDocumentsByStaffIdStaffDocuments? currentDoc =
|
||||
staffDocs
|
||||
.where(
|
||||
(dc.ListStaffDocumentsByStaffIdStaffDocuments d) =>
|
||||
d.documentId == doc.id,
|
||||
)
|
||||
.firstOrNull;
|
||||
|
||||
return domain.StaffDocument(
|
||||
id: currentDoc?.id ?? '',
|
||||
staffId: staffId,
|
||||
documentId: doc.id,
|
||||
name: doc.name,
|
||||
description: doc.description,
|
||||
status: currentDoc != null
|
||||
? _mapDocumentStatus(currentDoc.status)
|
||||
: domain.DocumentStatus.missing,
|
||||
documentUrl: currentDoc?.documentUrl,
|
||||
expiryDate: currentDoc?.expiryDate == null
|
||||
? null
|
||||
: DateTimeUtils.toDeviceTime(
|
||||
currentDoc!.expiryDate!.toDateTime(),
|
||||
),
|
||||
verificationId: currentDoc?.verificationId,
|
||||
verificationStatus: currentDoc != null
|
||||
? _mapFromDCDocumentVerificationStatus(currentDoc.status)
|
||||
: null,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> upsertStaffDocument({
|
||||
required String documentId,
|
||||
required String documentUrl,
|
||||
domain.DocumentStatus? status,
|
||||
String? verificationId,
|
||||
}) async {
|
||||
await _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
final domain.Staff staff = await getStaffProfile();
|
||||
|
||||
await _service.connector
|
||||
.upsertStaffDocument(
|
||||
staffId: staffId,
|
||||
staffName: staff.name,
|
||||
documentId: documentId,
|
||||
status: _mapToDCDocumentStatus(status),
|
||||
)
|
||||
.documentUrl(documentUrl)
|
||||
.verificationId(verificationId)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
domain.DocumentStatus _mapDocumentStatus(
|
||||
dc.EnumValue<dc.DocumentStatus> status,
|
||||
) {
|
||||
if (status is dc.Unknown) {
|
||||
return domain.DocumentStatus.pending;
|
||||
}
|
||||
final dc.DocumentStatus value =
|
||||
(status as dc.Known<dc.DocumentStatus>).value;
|
||||
switch (value) {
|
||||
case dc.DocumentStatus.VERIFIED:
|
||||
return domain.DocumentStatus.verified;
|
||||
case dc.DocumentStatus.PENDING:
|
||||
return domain.DocumentStatus.pending;
|
||||
case dc.DocumentStatus.MISSING:
|
||||
return domain.DocumentStatus.missing;
|
||||
case dc.DocumentStatus.UPLOADED:
|
||||
case dc.DocumentStatus.EXPIRING:
|
||||
return domain.DocumentStatus.pending;
|
||||
case dc.DocumentStatus.PROCESSING:
|
||||
case dc.DocumentStatus.AUTO_PASS:
|
||||
case dc.DocumentStatus.AUTO_FAIL:
|
||||
case dc.DocumentStatus.NEEDS_REVIEW:
|
||||
case dc.DocumentStatus.APPROVED:
|
||||
case dc.DocumentStatus.REJECTED:
|
||||
case dc.DocumentStatus.ERROR:
|
||||
if (value == dc.DocumentStatus.AUTO_PASS ||
|
||||
value == dc.DocumentStatus.APPROVED) {
|
||||
return domain.DocumentStatus.verified;
|
||||
}
|
||||
if (value == dc.DocumentStatus.AUTO_FAIL ||
|
||||
value == dc.DocumentStatus.REJECTED ||
|
||||
value == dc.DocumentStatus.ERROR) {
|
||||
return domain.DocumentStatus.rejected;
|
||||
}
|
||||
return domain.DocumentStatus.pending;
|
||||
}
|
||||
}
|
||||
|
||||
domain.DocumentVerificationStatus _mapFromDCDocumentVerificationStatus(
|
||||
dc.EnumValue<dc.DocumentStatus> status,
|
||||
) {
|
||||
if (status is dc.Unknown) {
|
||||
return domain.DocumentVerificationStatus.error;
|
||||
}
|
||||
final String name = (status as dc.Known<dc.DocumentStatus>).value.name;
|
||||
switch (name) {
|
||||
case 'PENDING':
|
||||
return domain.DocumentVerificationStatus.pending;
|
||||
case 'PROCESSING':
|
||||
return domain.DocumentVerificationStatus.processing;
|
||||
case 'AUTO_PASS':
|
||||
return domain.DocumentVerificationStatus.autoPass;
|
||||
case 'AUTO_FAIL':
|
||||
return domain.DocumentVerificationStatus.autoFail;
|
||||
case 'NEEDS_REVIEW':
|
||||
return domain.DocumentVerificationStatus.needsReview;
|
||||
case 'APPROVED':
|
||||
return domain.DocumentVerificationStatus.approved;
|
||||
case 'REJECTED':
|
||||
return domain.DocumentVerificationStatus.rejected;
|
||||
case 'VERIFIED':
|
||||
return domain.DocumentVerificationStatus.approved;
|
||||
case 'ERROR':
|
||||
return domain.DocumentVerificationStatus.error;
|
||||
default:
|
||||
return domain.DocumentVerificationStatus.error;
|
||||
}
|
||||
}
|
||||
|
||||
dc.DocumentStatus _mapToDCDocumentStatus(domain.DocumentStatus? status) {
|
||||
if (status == null) return dc.DocumentStatus.PENDING;
|
||||
switch (status) {
|
||||
case domain.DocumentStatus.verified:
|
||||
return dc.DocumentStatus.VERIFIED;
|
||||
case domain.DocumentStatus.pending:
|
||||
return dc.DocumentStatus.PENDING;
|
||||
case domain.DocumentStatus.missing:
|
||||
return dc.DocumentStatus.MISSING;
|
||||
case domain.DocumentStatus.rejected:
|
||||
return dc.DocumentStatus.REJECTED;
|
||||
case domain.DocumentStatus.expired:
|
||||
return dc.DocumentStatus.EXPIRING;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<domain.StaffCertificate>> getStaffCertificates() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
final QueryResult<
|
||||
dc.ListCertificatesByStaffIdData,
|
||||
dc.ListCertificatesByStaffIdVariables
|
||||
>
|
||||
response = await _service.connector
|
||||
.listCertificatesByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
|
||||
return response.data.certificates.map((
|
||||
dc.ListCertificatesByStaffIdCertificates cert,
|
||||
) {
|
||||
return domain.StaffCertificate(
|
||||
id: cert.id,
|
||||
staffId: cert.staffId,
|
||||
name: cert.name,
|
||||
description: cert.description,
|
||||
expiryDate: _service.toDateTime(cert.expiry),
|
||||
status: _mapToDomainCertificateStatus(cert.status),
|
||||
certificateUrl: cert.fileUrl,
|
||||
icon: cert.icon,
|
||||
certificationType: _mapToDomainComplianceType(cert.certificationType),
|
||||
issuer: cert.issuer,
|
||||
certificateNumber: cert.certificateNumber,
|
||||
validationStatus: _mapToDomainValidationStatus(cert.validationStatus),
|
||||
createdAt: _service.toDateTime(cert.createdAt),
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> upsertStaffCertificate({
|
||||
required domain.ComplianceType certificationType,
|
||||
required String name,
|
||||
required domain.StaffCertificateStatus status,
|
||||
String? fileUrl,
|
||||
DateTime? expiry,
|
||||
String? issuer,
|
||||
String? certificateNumber,
|
||||
domain.StaffCertificateValidationStatus? validationStatus,
|
||||
String? verificationId,
|
||||
}) async {
|
||||
await _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
await _service.connector
|
||||
.upsertStaffCertificate(
|
||||
staffId: staffId,
|
||||
certificationType: _mapToDCComplianceType(certificationType),
|
||||
name: name,
|
||||
status: _mapToDCCertificateStatus(status),
|
||||
)
|
||||
.fileUrl(fileUrl)
|
||||
.expiry(_service.tryToTimestamp(expiry))
|
||||
.issuer(issuer)
|
||||
.certificateNumber(certificateNumber)
|
||||
.validationStatus(_mapToDCValidationStatus(validationStatus))
|
||||
// .verificationId(verificationId) // FIXME: Uncomment after running 'make dataconnect-generate-sdk'
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteStaffCertificate({
|
||||
required domain.ComplianceType certificationType,
|
||||
}) async {
|
||||
await _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
await _service.connector
|
||||
.deleteCertificate(
|
||||
staffId: staffId,
|
||||
certificationType: _mapToDCComplianceType(certificationType),
|
||||
)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
domain.StaffCertificateStatus _mapToDomainCertificateStatus(
|
||||
dc.EnumValue<dc.CertificateStatus> status,
|
||||
) {
|
||||
if (status is dc.Unknown) return domain.StaffCertificateStatus.notStarted;
|
||||
final dc.CertificateStatus value =
|
||||
(status as dc.Known<dc.CertificateStatus>).value;
|
||||
switch (value) {
|
||||
case dc.CertificateStatus.CURRENT:
|
||||
return domain.StaffCertificateStatus.current;
|
||||
case dc.CertificateStatus.EXPIRING_SOON:
|
||||
return domain.StaffCertificateStatus.expiringSoon;
|
||||
case dc.CertificateStatus.COMPLETED:
|
||||
return domain.StaffCertificateStatus.completed;
|
||||
case dc.CertificateStatus.PENDING:
|
||||
return domain.StaffCertificateStatus.pending;
|
||||
case dc.CertificateStatus.EXPIRED:
|
||||
return domain.StaffCertificateStatus.expired;
|
||||
case dc.CertificateStatus.EXPIRING:
|
||||
return domain.StaffCertificateStatus.expiring;
|
||||
case dc.CertificateStatus.NOT_STARTED:
|
||||
return domain.StaffCertificateStatus.notStarted;
|
||||
}
|
||||
}
|
||||
|
||||
dc.CertificateStatus _mapToDCCertificateStatus(
|
||||
domain.StaffCertificateStatus status,
|
||||
) {
|
||||
switch (status) {
|
||||
case domain.StaffCertificateStatus.current:
|
||||
return dc.CertificateStatus.CURRENT;
|
||||
case domain.StaffCertificateStatus.expiringSoon:
|
||||
return dc.CertificateStatus.EXPIRING_SOON;
|
||||
case domain.StaffCertificateStatus.completed:
|
||||
return dc.CertificateStatus.COMPLETED;
|
||||
case domain.StaffCertificateStatus.pending:
|
||||
return dc.CertificateStatus.PENDING;
|
||||
case domain.StaffCertificateStatus.expired:
|
||||
return dc.CertificateStatus.EXPIRED;
|
||||
case domain.StaffCertificateStatus.expiring:
|
||||
return dc.CertificateStatus.EXPIRING;
|
||||
case domain.StaffCertificateStatus.notStarted:
|
||||
return dc.CertificateStatus.NOT_STARTED;
|
||||
}
|
||||
}
|
||||
|
||||
domain.ComplianceType _mapToDomainComplianceType(
|
||||
dc.EnumValue<dc.ComplianceType> type,
|
||||
) {
|
||||
if (type is dc.Unknown) return domain.ComplianceType.other;
|
||||
final dc.ComplianceType value = (type as dc.Known<dc.ComplianceType>).value;
|
||||
switch (value) {
|
||||
case dc.ComplianceType.BACKGROUND_CHECK:
|
||||
return domain.ComplianceType.backgroundCheck;
|
||||
case dc.ComplianceType.FOOD_HANDLER:
|
||||
return domain.ComplianceType.foodHandler;
|
||||
case dc.ComplianceType.RBS:
|
||||
return domain.ComplianceType.rbs;
|
||||
case dc.ComplianceType.LEGAL:
|
||||
return domain.ComplianceType.legal;
|
||||
case dc.ComplianceType.OPERATIONAL:
|
||||
return domain.ComplianceType.operational;
|
||||
case dc.ComplianceType.SAFETY:
|
||||
return domain.ComplianceType.safety;
|
||||
case dc.ComplianceType.TRAINING:
|
||||
return domain.ComplianceType.training;
|
||||
case dc.ComplianceType.LICENSE:
|
||||
return domain.ComplianceType.license;
|
||||
case dc.ComplianceType.OTHER:
|
||||
return domain.ComplianceType.other;
|
||||
}
|
||||
}
|
||||
|
||||
dc.ComplianceType _mapToDCComplianceType(domain.ComplianceType type) {
|
||||
switch (type) {
|
||||
case domain.ComplianceType.backgroundCheck:
|
||||
return dc.ComplianceType.BACKGROUND_CHECK;
|
||||
case domain.ComplianceType.foodHandler:
|
||||
return dc.ComplianceType.FOOD_HANDLER;
|
||||
case domain.ComplianceType.rbs:
|
||||
return dc.ComplianceType.RBS;
|
||||
case domain.ComplianceType.legal:
|
||||
return dc.ComplianceType.LEGAL;
|
||||
case domain.ComplianceType.operational:
|
||||
return dc.ComplianceType.OPERATIONAL;
|
||||
case domain.ComplianceType.safety:
|
||||
return dc.ComplianceType.SAFETY;
|
||||
case domain.ComplianceType.training:
|
||||
return dc.ComplianceType.TRAINING;
|
||||
case domain.ComplianceType.license:
|
||||
return dc.ComplianceType.LICENSE;
|
||||
case domain.ComplianceType.other:
|
||||
return dc.ComplianceType.OTHER;
|
||||
}
|
||||
}
|
||||
|
||||
domain.StaffCertificateValidationStatus? _mapToDomainValidationStatus(
|
||||
dc.EnumValue<dc.ValidationStatus>? status,
|
||||
) {
|
||||
if (status == null || status is dc.Unknown) return null;
|
||||
final dc.ValidationStatus value =
|
||||
(status as dc.Known<dc.ValidationStatus>).value;
|
||||
switch (value) {
|
||||
case dc.ValidationStatus.APPROVED:
|
||||
return domain.StaffCertificateValidationStatus.approved;
|
||||
case dc.ValidationStatus.PENDING_EXPERT_REVIEW:
|
||||
return domain.StaffCertificateValidationStatus.pendingExpertReview;
|
||||
case dc.ValidationStatus.REJECTED:
|
||||
return domain.StaffCertificateValidationStatus.rejected;
|
||||
case dc.ValidationStatus.AI_VERIFIED:
|
||||
return domain.StaffCertificateValidationStatus.aiVerified;
|
||||
case dc.ValidationStatus.AI_FLAGGED:
|
||||
return domain.StaffCertificateValidationStatus.aiFlagged;
|
||||
case dc.ValidationStatus.MANUAL_REVIEW_NEEDED:
|
||||
return domain.StaffCertificateValidationStatus.manualReviewNeeded;
|
||||
}
|
||||
}
|
||||
|
||||
dc.ValidationStatus? _mapToDCValidationStatus(
|
||||
domain.StaffCertificateValidationStatus? status,
|
||||
) {
|
||||
if (status == null) return null;
|
||||
switch (status) {
|
||||
case domain.StaffCertificateValidationStatus.approved:
|
||||
return dc.ValidationStatus.APPROVED;
|
||||
case domain.StaffCertificateValidationStatus.pendingExpertReview:
|
||||
return dc.ValidationStatus.PENDING_EXPERT_REVIEW;
|
||||
case domain.StaffCertificateValidationStatus.rejected:
|
||||
return dc.ValidationStatus.REJECTED;
|
||||
case domain.StaffCertificateValidationStatus.aiVerified:
|
||||
return dc.ValidationStatus.AI_VERIFIED;
|
||||
case domain.StaffCertificateValidationStatus.aiFlagged:
|
||||
return dc.ValidationStatus.AI_FLAGGED;
|
||||
case domain.StaffCertificateValidationStatus.manualReviewNeeded:
|
||||
return dc.ValidationStatus.MANUAL_REVIEW_NEEDED;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for staff connector queries.
|
||||
///
|
||||
/// This interface defines the contract for accessing staff-related data
|
||||
/// from the backend via Data Connect.
|
||||
abstract interface class StaffConnectorRepository {
|
||||
/// Fetches whether the profile is complete for the current staff member.
|
||||
///
|
||||
/// Returns true if all required profile sections have been completed,
|
||||
/// false otherwise.
|
||||
///
|
||||
/// Throws an exception if the query fails.
|
||||
Future<bool> getProfileCompletion();
|
||||
|
||||
/// Fetches personal information completion status.
|
||||
///
|
||||
/// Returns true if personal info (name, email, phone, locations) is complete.
|
||||
Future<bool> getPersonalInfoCompletion();
|
||||
|
||||
/// Fetches emergency contacts completion status.
|
||||
///
|
||||
/// Returns true if at least one emergency contact exists.
|
||||
Future<bool> getEmergencyContactsCompletion();
|
||||
|
||||
/// Fetches experience completion status.
|
||||
///
|
||||
/// Returns true if staff has industries or skills defined.
|
||||
Future<bool> getExperienceCompletion();
|
||||
|
||||
/// Fetches tax forms completion status.
|
||||
///
|
||||
/// Returns true if at least one tax form exists.
|
||||
Future<bool> getTaxFormsCompletion();
|
||||
|
||||
/// Fetches attire options completion status.
|
||||
///
|
||||
/// Returns true if all mandatory attire options are verified.
|
||||
Future<bool?> getAttireOptionsCompletion();
|
||||
|
||||
/// Fetches documents completion status.
|
||||
///
|
||||
/// Returns true if all mandatory documents are verified.
|
||||
Future<bool?> getStaffDocumentsCompletion();
|
||||
|
||||
/// Fetches certificates completion status.
|
||||
///
|
||||
/// Returns true if all certificates are validated.
|
||||
Future<bool?> getStaffCertificatesCompletion();
|
||||
|
||||
/// Fetches the full staff profile for the current authenticated user.
|
||||
///
|
||||
/// Returns a [Staff] entity containing all profile information.
|
||||
///
|
||||
/// Throws an exception if the profile cannot be retrieved.
|
||||
Future<Staff> getStaffProfile();
|
||||
|
||||
/// Fetches the benefits for the current authenticated user.
|
||||
///
|
||||
/// Returns a list of [Benefit] entities.
|
||||
Future<List<Benefit>> getBenefits();
|
||||
|
||||
/// Fetches the attire options for the current authenticated user.
|
||||
///
|
||||
/// Returns a list of [AttireItem] entities.
|
||||
Future<List<AttireItem>> getAttireOptions();
|
||||
|
||||
/// Upserts staff attire photo information.
|
||||
Future<void> upsertStaffAttire({
|
||||
required String attireOptionId,
|
||||
required String photoUrl,
|
||||
String? verificationId,
|
||||
AttireVerificationStatus? verificationStatus,
|
||||
});
|
||||
|
||||
/// Signs out the current user.
|
||||
///
|
||||
/// Clears the user's session and authentication state.
|
||||
///
|
||||
/// Throws an exception if the sign-out fails.
|
||||
Future<void> signOut();
|
||||
|
||||
/// Saves the staff profile information.
|
||||
Future<void> saveStaffProfile({
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
String? bio,
|
||||
String? profilePictureUrl,
|
||||
});
|
||||
|
||||
/// Fetches the staff documents for the current authenticated user.
|
||||
Future<List<StaffDocument>> getStaffDocuments();
|
||||
|
||||
/// Upserts staff document information.
|
||||
Future<void> upsertStaffDocument({
|
||||
required String documentId,
|
||||
required String documentUrl,
|
||||
DocumentStatus? status,
|
||||
String? verificationId,
|
||||
});
|
||||
|
||||
/// Fetches the staff certificates for the current authenticated user.
|
||||
Future<List<StaffCertificate>> getStaffCertificates();
|
||||
|
||||
/// Upserts staff certificate information.
|
||||
Future<void> upsertStaffCertificate({
|
||||
required ComplianceType certificationType,
|
||||
required String name,
|
||||
required StaffCertificateStatus status,
|
||||
String? fileUrl,
|
||||
DateTime? expiry,
|
||||
String? issuer,
|
||||
String? certificateNumber,
|
||||
StaffCertificateValidationStatus? validationStatus,
|
||||
String? verificationId,
|
||||
});
|
||||
|
||||
/// Deletes a staff certificate.
|
||||
Future<void> deleteStaffCertificate({
|
||||
required ComplianceType certificationType,
|
||||
});
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../repositories/staff_connector_repository.dart';
|
||||
|
||||
/// Use case for retrieving attire options completion status.
|
||||
///
|
||||
/// This use case encapsulates the business logic for determining whether
|
||||
/// a staff member has fully uploaded and verified all mandatory attire options.
|
||||
/// It delegates to the repository for data access.
|
||||
class GetAttireOptionsCompletionUseCase extends NoInputUseCase<bool?> {
|
||||
/// Creates a [GetAttireOptionsCompletionUseCase].
|
||||
///
|
||||
/// Requires a [StaffConnectorRepository] for data access.
|
||||
GetAttireOptionsCompletionUseCase({
|
||||
required StaffConnectorRepository repository,
|
||||
}) : _repository = repository;
|
||||
|
||||
final StaffConnectorRepository _repository;
|
||||
|
||||
/// Executes the use case to get attire options completion status.
|
||||
///
|
||||
/// Returns true if all mandatory attire options are verified, false otherwise.
|
||||
///
|
||||
/// Throws an exception if the operation fails.
|
||||
@override
|
||||
Future<bool?> call() => _repository.getAttireOptionsCompletion();
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../repositories/staff_connector_repository.dart';
|
||||
|
||||
/// Use case for retrieving emergency contacts completion status.
|
||||
///
|
||||
/// This use case encapsulates the business logic for determining whether
|
||||
/// a staff member has at least one emergency contact registered.
|
||||
/// It delegates to the repository for data access.
|
||||
class GetEmergencyContactsCompletionUseCase extends NoInputUseCase<bool> {
|
||||
/// Creates a [GetEmergencyContactsCompletionUseCase].
|
||||
///
|
||||
/// Requires a [StaffConnectorRepository] for data access.
|
||||
GetEmergencyContactsCompletionUseCase({
|
||||
required StaffConnectorRepository repository,
|
||||
}) : _repository = repository;
|
||||
|
||||
final StaffConnectorRepository _repository;
|
||||
|
||||
/// Executes the use case to get emergency contacts completion status.
|
||||
///
|
||||
/// Returns true if emergency contacts are registered, false otherwise.
|
||||
///
|
||||
/// Throws an exception if the operation fails.
|
||||
@override
|
||||
Future<bool> call() => _repository.getEmergencyContactsCompletion();
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../repositories/staff_connector_repository.dart';
|
||||
|
||||
/// Use case for retrieving experience completion status.
|
||||
///
|
||||
/// This use case encapsulates the business logic for determining whether
|
||||
/// a staff member has experience data (skills or industries) defined.
|
||||
/// It delegates to the repository for data access.
|
||||
class GetExperienceCompletionUseCase extends NoInputUseCase<bool> {
|
||||
/// Creates a [GetExperienceCompletionUseCase].
|
||||
///
|
||||
/// Requires a [StaffConnectorRepository] for data access.
|
||||
GetExperienceCompletionUseCase({
|
||||
required StaffConnectorRepository repository,
|
||||
}) : _repository = repository;
|
||||
|
||||
final StaffConnectorRepository _repository;
|
||||
|
||||
/// Executes the use case to get experience completion status.
|
||||
///
|
||||
/// Returns true if experience data is defined, false otherwise.
|
||||
///
|
||||
/// Throws an exception if the operation fails.
|
||||
@override
|
||||
Future<bool> call() => _repository.getExperienceCompletion();
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../repositories/staff_connector_repository.dart';
|
||||
|
||||
/// Use case for retrieving personal information completion status.
|
||||
///
|
||||
/// This use case encapsulates the business logic for determining whether
|
||||
/// a staff member's personal information is complete (name, email, phone).
|
||||
/// It delegates to the repository for data access.
|
||||
class GetPersonalInfoCompletionUseCase extends NoInputUseCase<bool> {
|
||||
/// Creates a [GetPersonalInfoCompletionUseCase].
|
||||
///
|
||||
/// Requires a [StaffConnectorRepository] for data access.
|
||||
GetPersonalInfoCompletionUseCase({
|
||||
required StaffConnectorRepository repository,
|
||||
}) : _repository = repository;
|
||||
|
||||
final StaffConnectorRepository _repository;
|
||||
|
||||
/// Executes the use case to get personal info completion status.
|
||||
///
|
||||
/// Returns true if personal information is complete, false otherwise.
|
||||
///
|
||||
/// Throws an exception if the operation fails.
|
||||
@override
|
||||
Future<bool> call() => _repository.getPersonalInfoCompletion();
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../repositories/staff_connector_repository.dart';
|
||||
|
||||
/// Use case for retrieving staff profile completion status.
|
||||
///
|
||||
/// This use case encapsulates the business logic for determining whether
|
||||
/// a staff member's profile is complete. It delegates to the repository
|
||||
/// for data access.
|
||||
class GetProfileCompletionUseCase extends NoInputUseCase<bool> {
|
||||
/// Creates a [GetProfileCompletionUseCase].
|
||||
///
|
||||
/// Requires a [StaffConnectorRepository] for data access.
|
||||
GetProfileCompletionUseCase({
|
||||
required StaffConnectorRepository repository,
|
||||
}) : _repository = repository;
|
||||
|
||||
final StaffConnectorRepository _repository;
|
||||
|
||||
/// Executes the use case to get profile completion status.
|
||||
///
|
||||
/// Returns true if the profile is complete, false otherwise.
|
||||
///
|
||||
/// Throws an exception if the operation fails.
|
||||
@override
|
||||
Future<bool> call() => _repository.getProfileCompletion();
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../repositories/staff_connector_repository.dart';
|
||||
|
||||
/// Use case for retrieving certificates completion status.
|
||||
///
|
||||
/// This use case encapsulates the business logic for determining whether
|
||||
/// a staff member has fully validated all certificates.
|
||||
/// It delegates to the repository for data access.
|
||||
class GetStaffCertificatesCompletionUseCase extends NoInputUseCase<bool?> {
|
||||
/// Creates a [GetStaffCertificatesCompletionUseCase].
|
||||
///
|
||||
/// Requires a [StaffConnectorRepository] for data access.
|
||||
GetStaffCertificatesCompletionUseCase({
|
||||
required StaffConnectorRepository repository,
|
||||
}) : _repository = repository;
|
||||
|
||||
final StaffConnectorRepository _repository;
|
||||
|
||||
/// Executes the use case to get certificates completion status.
|
||||
///
|
||||
/// Returns true if all certificates are validated, false otherwise.
|
||||
///
|
||||
/// Throws an exception if the operation fails.
|
||||
@override
|
||||
Future<bool?> call() => _repository.getStaffCertificatesCompletion();
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../repositories/staff_connector_repository.dart';
|
||||
|
||||
/// Use case for retrieving documents completion status.
|
||||
///
|
||||
/// This use case encapsulates the business logic for determining whether
|
||||
/// a staff member has fully uploaded and verified all mandatory documents.
|
||||
/// It delegates to the repository for data access.
|
||||
class GetStaffDocumentsCompletionUseCase extends NoInputUseCase<bool?> {
|
||||
/// Creates a [GetStaffDocumentsCompletionUseCase].
|
||||
///
|
||||
/// Requires a [StaffConnectorRepository] for data access.
|
||||
GetStaffDocumentsCompletionUseCase({
|
||||
required StaffConnectorRepository repository,
|
||||
}) : _repository = repository;
|
||||
|
||||
final StaffConnectorRepository _repository;
|
||||
|
||||
/// Executes the use case to get documents completion status.
|
||||
///
|
||||
/// Returns true if all mandatory documents are verified, false otherwise.
|
||||
///
|
||||
/// Throws an exception if the operation fails.
|
||||
@override
|
||||
Future<bool?> call() => _repository.getStaffDocumentsCompletion();
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../repositories/staff_connector_repository.dart';
|
||||
|
||||
/// Use case for fetching a staff member's full profile information.
|
||||
///
|
||||
/// This use case encapsulates the business logic for retrieving the complete
|
||||
/// staff profile including personal info, ratings, and reliability scores.
|
||||
/// It delegates to the repository for data access.
|
||||
class GetStaffProfileUseCase extends UseCase<void, Staff> {
|
||||
/// Creates a [GetStaffProfileUseCase].
|
||||
///
|
||||
/// Requires a [StaffConnectorRepository] for data access.
|
||||
GetStaffProfileUseCase({
|
||||
required StaffConnectorRepository repository,
|
||||
}) : _repository = repository;
|
||||
|
||||
final StaffConnectorRepository _repository;
|
||||
|
||||
/// Executes the use case to get the staff profile.
|
||||
///
|
||||
/// Returns a [Staff] entity containing all profile information.
|
||||
///
|
||||
/// Throws an exception if the operation fails.
|
||||
@override
|
||||
Future<Staff> call([void params]) => _repository.getStaffProfile();
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../repositories/staff_connector_repository.dart';
|
||||
|
||||
/// Use case for retrieving tax forms completion status.
|
||||
///
|
||||
/// This use case encapsulates the business logic for determining whether
|
||||
/// a staff member has at least one tax form submitted.
|
||||
/// It delegates to the repository for data access.
|
||||
class GetTaxFormsCompletionUseCase extends NoInputUseCase<bool> {
|
||||
/// Creates a [GetTaxFormsCompletionUseCase].
|
||||
///
|
||||
/// Requires a [StaffConnectorRepository] for data access.
|
||||
GetTaxFormsCompletionUseCase({
|
||||
required StaffConnectorRepository repository,
|
||||
}) : _repository = repository;
|
||||
|
||||
final StaffConnectorRepository _repository;
|
||||
|
||||
/// Executes the use case to get tax forms completion status.
|
||||
///
|
||||
/// Returns true if tax forms are submitted, false otherwise.
|
||||
///
|
||||
/// Throws an exception if the operation fails.
|
||||
@override
|
||||
Future<bool> call() => _repository.getTaxFormsCompletion();
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../repositories/staff_connector_repository.dart';
|
||||
|
||||
/// Use case for signing out the current staff user.
|
||||
///
|
||||
/// This use case encapsulates the business logic for signing out,
|
||||
/// including clearing authentication state and cache.
|
||||
/// It delegates to the repository for data access.
|
||||
class SignOutStaffUseCase extends NoInputUseCase<void> {
|
||||
/// Creates a [SignOutStaffUseCase].
|
||||
///
|
||||
/// Requires a [StaffConnectorRepository] for data access.
|
||||
SignOutStaffUseCase({
|
||||
required StaffConnectorRepository repository,
|
||||
}) : _repository = repository;
|
||||
|
||||
final StaffConnectorRepository _repository;
|
||||
|
||||
/// Executes the use case to sign out the user.
|
||||
///
|
||||
/// Throws an exception if the operation fails.
|
||||
@override
|
||||
Future<void> call() => _repository.signOut();
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'connectors/reports/domain/repositories/reports_connector_repository.dart';
|
||||
import 'connectors/reports/data/repositories/reports_connector_repository_impl.dart';
|
||||
import 'connectors/shifts/domain/repositories/shifts_connector_repository.dart';
|
||||
import 'connectors/shifts/data/repositories/shifts_connector_repository_impl.dart';
|
||||
import 'connectors/hubs/domain/repositories/hubs_connector_repository.dart';
|
||||
import 'connectors/hubs/data/repositories/hubs_connector_repository_impl.dart';
|
||||
import 'connectors/billing/domain/repositories/billing_connector_repository.dart';
|
||||
import 'connectors/billing/data/repositories/billing_connector_repository_impl.dart';
|
||||
import 'connectors/coverage/domain/repositories/coverage_connector_repository.dart';
|
||||
import 'connectors/coverage/data/repositories/coverage_connector_repository_impl.dart';
|
||||
import 'services/data_connect_service.dart';
|
||||
|
||||
/// A module that provides Data Connect dependencies.
|
||||
class DataConnectModule extends Module {
|
||||
@override
|
||||
void exportedBinds(Injector i) {
|
||||
i.addInstance<DataConnectService>(DataConnectService.instance);
|
||||
|
||||
// Repositories
|
||||
i.addLazySingleton<ReportsConnectorRepository>(
|
||||
ReportsConnectorRepositoryImpl.new,
|
||||
);
|
||||
i.addLazySingleton<ShiftsConnectorRepository>(
|
||||
ShiftsConnectorRepositoryImpl.new,
|
||||
);
|
||||
i.addLazySingleton<HubsConnectorRepository>(
|
||||
HubsConnectorRepositoryImpl.new,
|
||||
);
|
||||
i.addLazySingleton<BillingConnectorRepository>(
|
||||
BillingConnectorRepositoryImpl.new,
|
||||
);
|
||||
i.addLazySingleton<CoverageConnectorRepository>(
|
||||
CoverageConnectorRepositoryImpl.new,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
|
||||
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
|
||||
import '../connectors/reports/domain/repositories/reports_connector_repository.dart';
|
||||
import '../connectors/reports/data/repositories/reports_connector_repository_impl.dart';
|
||||
import '../connectors/shifts/domain/repositories/shifts_connector_repository.dart';
|
||||
import '../connectors/shifts/data/repositories/shifts_connector_repository_impl.dart';
|
||||
import '../connectors/hubs/domain/repositories/hubs_connector_repository.dart';
|
||||
import '../connectors/hubs/data/repositories/hubs_connector_repository_impl.dart';
|
||||
import '../connectors/billing/domain/repositories/billing_connector_repository.dart';
|
||||
import '../connectors/billing/data/repositories/billing_connector_repository_impl.dart';
|
||||
import '../connectors/coverage/domain/repositories/coverage_connector_repository.dart';
|
||||
import '../connectors/coverage/data/repositories/coverage_connector_repository_impl.dart';
|
||||
import '../connectors/staff/domain/repositories/staff_connector_repository.dart';
|
||||
import '../connectors/staff/data/repositories/staff_connector_repository_impl.dart';
|
||||
import 'mixins/data_error_handler.dart';
|
||||
import 'mixins/session_handler_mixin.dart';
|
||||
|
||||
/// A centralized service for interacting with Firebase Data Connect.
|
||||
///
|
||||
/// This service provides common utilities and context management for all repositories.
|
||||
class DataConnectService with DataErrorHandler, SessionHandlerMixin {
|
||||
DataConnectService._();
|
||||
|
||||
/// The singleton instance of the [DataConnectService].
|
||||
static final DataConnectService instance = DataConnectService._();
|
||||
|
||||
/// The Data Connect connector used for data operations.
|
||||
final dc.ExampleConnector connector = dc.ExampleConnector.instance;
|
||||
|
||||
// Repositories
|
||||
ReportsConnectorRepository? _reportsRepository;
|
||||
ShiftsConnectorRepository? _shiftsRepository;
|
||||
HubsConnectorRepository? _hubsRepository;
|
||||
BillingConnectorRepository? _billingRepository;
|
||||
CoverageConnectorRepository? _coverageRepository;
|
||||
StaffConnectorRepository? _staffRepository;
|
||||
|
||||
/// Gets the reports connector repository.
|
||||
ReportsConnectorRepository getReportsRepository() {
|
||||
return _reportsRepository ??= ReportsConnectorRepositoryImpl(service: this);
|
||||
}
|
||||
|
||||
/// Gets the shifts connector repository.
|
||||
ShiftsConnectorRepository getShiftsRepository() {
|
||||
return _shiftsRepository ??= ShiftsConnectorRepositoryImpl(service: this);
|
||||
}
|
||||
|
||||
/// Gets the hubs connector repository.
|
||||
HubsConnectorRepository getHubsRepository() {
|
||||
return _hubsRepository ??= HubsConnectorRepositoryImpl(service: this);
|
||||
}
|
||||
|
||||
/// Gets the billing connector repository.
|
||||
BillingConnectorRepository getBillingRepository() {
|
||||
return _billingRepository ??= BillingConnectorRepositoryImpl(service: this);
|
||||
}
|
||||
|
||||
/// Gets the coverage connector repository.
|
||||
CoverageConnectorRepository getCoverageRepository() {
|
||||
return _coverageRepository ??= CoverageConnectorRepositoryImpl(
|
||||
service: this,
|
||||
);
|
||||
}
|
||||
|
||||
/// Gets the staff connector repository.
|
||||
StaffConnectorRepository getStaffRepository() {
|
||||
return _staffRepository ??= StaffConnectorRepositoryImpl(service: this);
|
||||
}
|
||||
|
||||
/// Returns the current Firebase Auth instance.
|
||||
@override
|
||||
firebase.FirebaseAuth get auth => firebase.FirebaseAuth.instance;
|
||||
|
||||
/// Helper to get the current staff ID from the session.
|
||||
Future<String> getStaffId() async {
|
||||
String? staffId = dc.StaffSessionStore.instance.session?.staff?.id;
|
||||
|
||||
if (staffId == null || staffId.isEmpty) {
|
||||
// Attempt to recover session if user is signed in
|
||||
final user = auth.currentUser;
|
||||
if (user != null) {
|
||||
await _loadSession(user.uid);
|
||||
staffId = dc.StaffSessionStore.instance.session?.staff?.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (staffId == null || staffId.isEmpty) {
|
||||
throw Exception('No staff ID found in session.');
|
||||
}
|
||||
return staffId;
|
||||
}
|
||||
|
||||
/// Helper to get the current business ID from the session.
|
||||
Future<String> getBusinessId() async {
|
||||
String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
|
||||
|
||||
if (businessId == null || businessId.isEmpty) {
|
||||
// Attempt to recover session if user is signed in
|
||||
final user = auth.currentUser;
|
||||
if (user != null) {
|
||||
await _loadSession(user.uid);
|
||||
businessId = dc.ClientSessionStore.instance.session?.business?.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (businessId == null || businessId.isEmpty) {
|
||||
throw Exception('No business ID found in session.');
|
||||
}
|
||||
return businessId;
|
||||
}
|
||||
|
||||
/// Logic to load session data from backend and populate stores.
|
||||
Future<void> _loadSession(String userId) async {
|
||||
try {
|
||||
final role = await fetchUserRole(userId);
|
||||
if (role == null) return;
|
||||
|
||||
// Load Staff Session if applicable
|
||||
if (role == 'STAFF' || role == 'BOTH') {
|
||||
final response = await connector
|
||||
.getStaffByUserId(userId: userId)
|
||||
.execute();
|
||||
if (response.data.staffs.isNotEmpty) {
|
||||
final s = response.data.staffs.first;
|
||||
dc.StaffSessionStore.instance.setSession(
|
||||
dc.StaffSession(
|
||||
ownerId: s.ownerId,
|
||||
staff: domain.Staff(
|
||||
id: s.id,
|
||||
authProviderId: s.userId,
|
||||
name: s.fullName,
|
||||
email: s.email ?? '',
|
||||
phone: s.phone,
|
||||
status: domain.StaffStatus.completedProfile,
|
||||
address: s.addres,
|
||||
avatar: s.photoUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Load Client Session if applicable
|
||||
if (role == 'BUSINESS' || role == 'BOTH') {
|
||||
final response = await connector
|
||||
.getBusinessesByUserId(userId: userId)
|
||||
.execute();
|
||||
if (response.data.businesses.isNotEmpty) {
|
||||
final b = response.data.businesses.first;
|
||||
dc.ClientSessionStore.instance.setSession(
|
||||
dc.ClientSession(
|
||||
business: dc.ClientBusinessSession(
|
||||
id: b.id,
|
||||
businessName: b.businessName,
|
||||
email: b.email,
|
||||
city: b.city,
|
||||
contactName: b.contactName,
|
||||
companyLogoUrl: b.companyLogoUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('DataConnectService: Error loading session for $userId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a Data Connect [Timestamp] to a Dart [DateTime] in local time.
|
||||
///
|
||||
/// Firebase Data Connect always stores and returns timestamps in UTC.
|
||||
/// Calling [toLocal] ensures the result reflects the device's timezone so
|
||||
/// that shift dates, start/end times, and formatted strings are correct for
|
||||
/// the end user.
|
||||
DateTime? toDateTime(dynamic timestamp) {
|
||||
if (timestamp == null) return null;
|
||||
if (timestamp is fdc.Timestamp) {
|
||||
return timestamp.toDateTime().toLocal();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Converts a Dart [DateTime] to a Data Connect [Timestamp].
|
||||
///
|
||||
/// Converts the [DateTime] to UTC before creating the [Timestamp].
|
||||
fdc.Timestamp toTimestamp(DateTime dateTime) {
|
||||
final DateTime utc = dateTime.toUtc();
|
||||
final int millis = utc.millisecondsSinceEpoch;
|
||||
final int seconds = millis ~/ 1000;
|
||||
final int nanos = (millis % 1000) * 1000000;
|
||||
return fdc.Timestamp(nanos, seconds);
|
||||
}
|
||||
|
||||
/// Converts a nullable Dart [DateTime] to a nullable Data Connect [Timestamp].
|
||||
fdc.Timestamp? tryToTimestamp(DateTime? dateTime) {
|
||||
if (dateTime == null) return null;
|
||||
return toTimestamp(dateTime);
|
||||
}
|
||||
|
||||
/// Executes an operation with centralized error handling.
|
||||
Future<T> run<T>(
|
||||
Future<T> Function() operation, {
|
||||
bool requiresAuthentication = true,
|
||||
}) async {
|
||||
if (requiresAuthentication) {
|
||||
await ensureSessionValid();
|
||||
}
|
||||
return executeProtected(operation);
|
||||
}
|
||||
|
||||
/// Implementation for SessionHandlerMixin.
|
||||
@override
|
||||
Future<String?> fetchUserRole(String userId) async {
|
||||
try {
|
||||
final response = await connector.getUserById(id: userId).execute();
|
||||
return response.data.user?.userRole;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Signs out the current user from Firebase Auth and clears all session data.
|
||||
Future<void> signOut() async {
|
||||
try {
|
||||
await auth.signOut();
|
||||
_clearCache();
|
||||
} catch (e) {
|
||||
debugPrint('DataConnectService: Error signing out: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears Cached Repositories and Session data.
|
||||
void _clearCache() {
|
||||
_reportsRepository = null;
|
||||
_shiftsRepository = null;
|
||||
_hubsRepository = null;
|
||||
_billingRepository = null;
|
||||
_coverageRepository = null;
|
||||
_staffRepository = null;
|
||||
|
||||
dc.StaffSessionStore.instance.clear();
|
||||
dc.ClientSessionStore.instance.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Mixin to handle Data Layer errors and map them to Domain Failures.
|
||||
///
|
||||
/// Use this in Repositories to wrap remote calls.
|
||||
/// It catches [SocketException], [FirebaseException], etc., and throws [AppException].
|
||||
mixin DataErrorHandler {
|
||||
/// Executes a Future and maps low-level exceptions to [AppException].
|
||||
///
|
||||
/// [timeout] defaults to 30 seconds.
|
||||
Future<T> executeProtected<T>(
|
||||
Future<T> Function() action, {
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
}) async {
|
||||
try {
|
||||
return await action().timeout(timeout);
|
||||
} on TimeoutException {
|
||||
debugPrint(
|
||||
'DataErrorHandler: Request timed out after ${timeout.inSeconds}s',
|
||||
);
|
||||
throw ServiceUnavailableException(
|
||||
technicalMessage: 'Request timed out after ${timeout.inSeconds}s',
|
||||
);
|
||||
} on SocketException catch (e) {
|
||||
throw NetworkException(technicalMessage: 'SocketException: ${e.message}');
|
||||
} on FirebaseException catch (e) {
|
||||
final String code = e.code.toLowerCase();
|
||||
final String msg = (e.message ?? '').toLowerCase();
|
||||
if (code == 'unavailable' ||
|
||||
code == 'network-request-failed' ||
|
||||
msg.contains('offline') ||
|
||||
msg.contains('network') ||
|
||||
msg.contains('connection failed')) {
|
||||
debugPrint(
|
||||
'DataErrorHandler: Firebase network error: ${e.code} - ${e.message}',
|
||||
);
|
||||
throw NetworkException(
|
||||
technicalMessage: 'Firebase ${e.code}: ${e.message}',
|
||||
);
|
||||
}
|
||||
if (code == 'deadline-exceeded') {
|
||||
debugPrint(
|
||||
'DataErrorHandler: Firebase timeout error: ${e.code} - ${e.message}',
|
||||
);
|
||||
throw ServiceUnavailableException(
|
||||
technicalMessage: 'Firebase ${e.code}: ${e.message}',
|
||||
);
|
||||
}
|
||||
debugPrint('DataErrorHandler: Firebase error: ${e.code} - ${e.message}');
|
||||
// Fallback for other Firebase errors
|
||||
throw ServerException(
|
||||
technicalMessage: 'Firebase ${e.code}: ${e.message}',
|
||||
);
|
||||
} catch (e) {
|
||||
final String errorStr = e.toString().toLowerCase();
|
||||
if (errorStr.contains('socketexception') ||
|
||||
errorStr.contains('network') ||
|
||||
errorStr.contains('offline') ||
|
||||
errorStr.contains('connection failed') ||
|
||||
errorStr.contains('unavailable') ||
|
||||
errorStr.contains('handshake') ||
|
||||
errorStr.contains('clientexception') ||
|
||||
errorStr.contains('failed host lookup') ||
|
||||
errorStr.contains('connection error') ||
|
||||
errorStr.contains('grpc error') ||
|
||||
errorStr.contains('terminated') ||
|
||||
errorStr.contains('connectexception')) {
|
||||
debugPrint('DataErrorHandler: Network-related error: $e');
|
||||
throw NetworkException(technicalMessage: e.toString());
|
||||
}
|
||||
|
||||
// If it's already an AppException, rethrow it
|
||||
if (e is AppException) rethrow;
|
||||
|
||||
// Debugging: Log unexpected errors
|
||||
debugPrint('DataErrorHandler: Unhandled exception caught: $e');
|
||||
|
||||
throw UnknownException(technicalMessage: e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
/// Enum representing the current session state.
|
||||
enum SessionStateType { loading, authenticated, unauthenticated, error }
|
||||
|
||||
/// Data class for session state.
|
||||
class SessionState {
|
||||
/// Creates a [SessionState].
|
||||
SessionState({required this.type, this.userId, this.errorMessage});
|
||||
|
||||
/// Creates a loading state.
|
||||
factory SessionState.loading() =>
|
||||
SessionState(type: SessionStateType.loading);
|
||||
|
||||
/// Creates an authenticated state.
|
||||
factory SessionState.authenticated({required String userId}) =>
|
||||
SessionState(type: SessionStateType.authenticated, userId: userId);
|
||||
|
||||
/// Creates an unauthenticated state.
|
||||
factory SessionState.unauthenticated() =>
|
||||
SessionState(type: SessionStateType.unauthenticated);
|
||||
|
||||
/// Creates an error state.
|
||||
factory SessionState.error(String message) =>
|
||||
SessionState(type: SessionStateType.error, errorMessage: message);
|
||||
|
||||
/// The type of session state.
|
||||
final SessionStateType type;
|
||||
|
||||
/// The current user ID (if authenticated).
|
||||
final String? userId;
|
||||
|
||||
/// Error message (if error occurred).
|
||||
final String? errorMessage;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SessionState(type: $type, userId: $userId, error: $errorMessage)';
|
||||
}
|
||||
|
||||
/// Mixin for handling Firebase Auth session management, token refresh, and state emissions.
|
||||
mixin SessionHandlerMixin {
|
||||
/// Stream controller for session state changes.
|
||||
final StreamController<SessionState> _sessionStateController =
|
||||
StreamController<SessionState>.broadcast();
|
||||
|
||||
/// Last emitted session state (for late subscribers).
|
||||
SessionState? _lastSessionState;
|
||||
|
||||
/// Public stream for listening to session state changes.
|
||||
/// Late subscribers will immediately receive the last emitted state.
|
||||
Stream<SessionState> get onSessionStateChanged {
|
||||
// Create a custom stream that emits the last state before forwarding new events
|
||||
return _createStreamWithLastState();
|
||||
}
|
||||
|
||||
/// Creates a stream that emits the last state before subscribing to new events.
|
||||
Stream<SessionState> _createStreamWithLastState() async* {
|
||||
// If we have a last state, emit it immediately to late subscribers
|
||||
if (_lastSessionState != null) {
|
||||
yield _lastSessionState!;
|
||||
}
|
||||
// Then forward all subsequent events
|
||||
yield* _sessionStateController.stream;
|
||||
}
|
||||
|
||||
/// Last token refresh timestamp to avoid excessive checks.
|
||||
DateTime? _lastTokenRefreshTime;
|
||||
|
||||
/// Subscription to auth state changes.
|
||||
StreamSubscription<firebase_auth.User?>? _authStateSubscription;
|
||||
|
||||
/// Minimum interval between token refresh checks.
|
||||
static const Duration _minRefreshCheckInterval = Duration(seconds: 2);
|
||||
|
||||
/// Time before token expiry to trigger a refresh.
|
||||
static const Duration _refreshThreshold = Duration(minutes: 5);
|
||||
|
||||
/// Firebase Auth instance (to be provided by implementing class).
|
||||
firebase_auth.FirebaseAuth get auth;
|
||||
|
||||
/// List of allowed roles for this app (to be set during initialization).
|
||||
List<String> _allowedRoles = <String>[];
|
||||
|
||||
/// Initialize the auth state listener (call once on app startup).
|
||||
void initializeAuthListener({List<String> allowedRoles = const <String>[]}) {
|
||||
_allowedRoles = allowedRoles;
|
||||
|
||||
// Cancel any existing subscription first
|
||||
_authStateSubscription?.cancel();
|
||||
|
||||
// Listen to Firebase auth state changes
|
||||
_authStateSubscription = auth.authStateChanges().listen(
|
||||
(firebase_auth.User? user) async {
|
||||
if (user == null) {
|
||||
handleSignOut();
|
||||
} else {
|
||||
await _handleSignIn(user);
|
||||
}
|
||||
},
|
||||
onError: (Object error) {
|
||||
_emitSessionState(SessionState.error(error.toString()));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Validates if user has one of the allowed roles.
|
||||
/// Returns true if user role is in allowed roles, false otherwise.
|
||||
Future<bool> validateUserRole(
|
||||
String userId,
|
||||
List<String> allowedRoles,
|
||||
) async {
|
||||
try {
|
||||
final String? userRole = await fetchUserRole(userId);
|
||||
return userRole != null && allowedRoles.contains(userRole);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to validate user role: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches user role from Data Connect.
|
||||
/// To be implemented by concrete class.
|
||||
Future<String?> fetchUserRole(String userId);
|
||||
|
||||
/// Ensures the Firebase auth token is valid and refreshes if needed.
|
||||
/// Retries up to 3 times with exponential backoff before emitting error.
|
||||
Future<void> ensureSessionValid() async {
|
||||
final firebase_auth.User? user = auth.currentUser;
|
||||
|
||||
// No user = not authenticated, skip check
|
||||
if (user == null) return;
|
||||
|
||||
// Optimization: Skip if we just checked within the last 2 seconds
|
||||
final DateTime now = DateTime.now();
|
||||
if (_lastTokenRefreshTime != null) {
|
||||
final Duration timeSinceLastCheck = now.difference(
|
||||
_lastTokenRefreshTime!,
|
||||
);
|
||||
if (timeSinceLastCheck < _minRefreshCheckInterval) {
|
||||
return; // Skip redundant check
|
||||
}
|
||||
}
|
||||
|
||||
const int maxRetries = 3;
|
||||
int retryCount = 0;
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
try {
|
||||
// Get token result (doesn't fetch from network unless needed)
|
||||
final firebase_auth.IdTokenResult idToken = await user
|
||||
.getIdTokenResult();
|
||||
|
||||
// Extract expiration time
|
||||
final DateTime? expiryTime = idToken.expirationTime;
|
||||
|
||||
if (expiryTime == null) {
|
||||
return; // Token info unavailable, proceed anyway
|
||||
}
|
||||
|
||||
// Calculate time until expiry
|
||||
final Duration timeUntilExpiry = expiryTime.difference(now);
|
||||
|
||||
// If token expires within 5 minutes, refresh it
|
||||
if (timeUntilExpiry <= _refreshThreshold) {
|
||||
await user.getIdTokenResult();
|
||||
}
|
||||
|
||||
// Update last refresh check timestamp
|
||||
_lastTokenRefreshTime = now;
|
||||
return; // Success, exit retry loop
|
||||
} catch (e) {
|
||||
retryCount++;
|
||||
debugPrint(
|
||||
'Token validation error (attempt $retryCount/$maxRetries): $e',
|
||||
);
|
||||
|
||||
// If we've exhausted retries, emit error
|
||||
if (retryCount >= maxRetries) {
|
||||
_emitSessionState(
|
||||
SessionState.error(
|
||||
'Token validation failed after $maxRetries attempts: $e',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Exponential backoff: 1s, 2s, 4s
|
||||
final Duration backoffDuration = Duration(
|
||||
seconds: 1 << (retryCount - 1), // 2^(retryCount-1)
|
||||
);
|
||||
debugPrint(
|
||||
'Retrying token validation in ${backoffDuration.inSeconds}s',
|
||||
);
|
||||
await Future<void>.delayed(backoffDuration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle user sign-in event.
|
||||
Future<void> _handleSignIn(firebase_auth.User user) async {
|
||||
try {
|
||||
_emitSessionState(SessionState.loading());
|
||||
|
||||
// Validate role only when allowed roles are specified.
|
||||
if (_allowedRoles.isNotEmpty) {
|
||||
final String? userRole = await fetchUserRole(user.uid);
|
||||
|
||||
if (userRole == null) {
|
||||
// User has no record in the database yet. This is expected during
|
||||
// the sign-up flow: Firebase Auth fires authStateChanges before the
|
||||
// repository has created the PostgreSQL user record. Do NOT sign out —
|
||||
// just emit unauthenticated and let the registration flow complete.
|
||||
_emitSessionState(SessionState.unauthenticated());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_allowedRoles.contains(userRole)) {
|
||||
// User IS in the database but has a role that is not permitted in
|
||||
// this app (e.g., a STAFF-only user trying to use the Client app).
|
||||
// Sign them out to force them to use the correct app.
|
||||
await auth.signOut();
|
||||
_emitSessionState(SessionState.unauthenticated());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get fresh token to validate session
|
||||
final firebase_auth.IdTokenResult idToken = await user.getIdTokenResult();
|
||||
if (idToken.expirationTime != null &&
|
||||
DateTime.now().difference(idToken.expirationTime!) <
|
||||
const Duration(minutes: 5)) {
|
||||
// Token is expiring soon, refresh it
|
||||
await user.getIdTokenResult();
|
||||
}
|
||||
|
||||
// Emit authenticated state
|
||||
_emitSessionState(SessionState.authenticated(userId: user.uid));
|
||||
} catch (e) {
|
||||
_emitSessionState(SessionState.error(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle user sign-out event.
|
||||
void handleSignOut() {
|
||||
_emitSessionState(SessionState.unauthenticated());
|
||||
}
|
||||
|
||||
/// Emit session state update.
|
||||
void _emitSessionState(SessionState state) {
|
||||
_lastSessionState = state;
|
||||
if (!_sessionStateController.isClosed) {
|
||||
_sessionStateController.add(state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispose session handler resources.
|
||||
Future<void> disposeSessionHandler() async {
|
||||
await _authStateSubscription?.cancel();
|
||||
await _sessionStateController.close();
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
class ClientBusinessSession {
|
||||
|
||||
const ClientBusinessSession({
|
||||
required this.id,
|
||||
required this.businessName,
|
||||
this.email,
|
||||
this.city,
|
||||
this.contactName,
|
||||
this.companyLogoUrl,
|
||||
});
|
||||
final String id;
|
||||
final String businessName;
|
||||
final String? email;
|
||||
final String? city;
|
||||
final String? contactName;
|
||||
final String? companyLogoUrl;
|
||||
}
|
||||
|
||||
class ClientSession {
|
||||
|
||||
const ClientSession({required this.business});
|
||||
final ClientBusinessSession? business;
|
||||
}
|
||||
|
||||
class ClientSessionStore {
|
||||
|
||||
ClientSessionStore._();
|
||||
ClientSession? _session;
|
||||
|
||||
ClientSession? get session => _session;
|
||||
|
||||
void setSession(ClientSession session) {
|
||||
_session = session;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_session = null;
|
||||
}
|
||||
|
||||
static final ClientSessionStore instance = ClientSessionStore._();
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
|
||||
class StaffSession {
|
||||
const StaffSession({this.staff, this.ownerId});
|
||||
|
||||
final domain.Staff? staff;
|
||||
final String? ownerId;
|
||||
}
|
||||
|
||||
class StaffSessionStore {
|
||||
StaffSessionStore._();
|
||||
StaffSession? _session;
|
||||
|
||||
StaffSession? get session => _session;
|
||||
|
||||
void setSession(StaffSession session) {
|
||||
_session = session;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_session = null;
|
||||
}
|
||||
|
||||
static final StaffSessionStore instance = StaffSessionStore._();
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
name: krow_data_connect
|
||||
description: Firebase Data Connect access layer.
|
||||
version: 0.0.1
|
||||
publish_to: none
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: '>=3.10.0 <4.0.0'
|
||||
flutter: ">=3.0.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
krow_domain:
|
||||
path: ../domain
|
||||
krow_core:
|
||||
path: ../core
|
||||
flutter_modular: ^6.3.0
|
||||
firebase_data_connect: ^0.2.2+2
|
||||
firebase_core: ^4.4.0
|
||||
firebase_auth: ^6.1.4
|
||||
@@ -13,10 +13,16 @@ class TimeSlot extends Equatable {
|
||||
});
|
||||
|
||||
/// Deserialises from a JSON map inside the availability slots array.
|
||||
///
|
||||
/// Supports both V2 API keys (`start`/`end`) and legacy keys
|
||||
/// (`startTime`/`endTime`).
|
||||
factory TimeSlot.fromJson(Map<String, dynamic> json) {
|
||||
return TimeSlot(
|
||||
startTime: json['startTime'] as String? ?? '00:00',
|
||||
endTime: json['endTime'] as String? ?? '00:00',
|
||||
startTime: json['start'] as String? ??
|
||||
json['startTime'] as String? ??
|
||||
'00:00',
|
||||
endTime:
|
||||
json['end'] as String? ?? json['endTime'] as String? ?? '00:00',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,11 +32,11 @@ class TimeSlot extends Equatable {
|
||||
/// End time in `HH:MM` format.
|
||||
final String endTime;
|
||||
|
||||
/// Serialises to JSON.
|
||||
/// Serialises to JSON matching the V2 API contract.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'startTime': startTime,
|
||||
'endTime': endTime,
|
||||
'start': startTime,
|
||||
'end': endTime,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../benefits/benefit.dart';
|
||||
import '../shifts/assigned_shift.dart';
|
||||
import '../shifts/open_shift.dart';
|
||||
import '../shifts/today_shift.dart';
|
||||
|
||||
/// Staff dashboard data with shifts and benefits overview.
|
||||
///
|
||||
@@ -9,9 +12,9 @@ class StaffDashboard extends Equatable {
|
||||
/// Creates a [StaffDashboard] instance.
|
||||
const StaffDashboard({
|
||||
required this.staffName,
|
||||
this.todaysShifts = const <Map<String, dynamic>>[],
|
||||
this.tomorrowsShifts = const <Map<String, dynamic>>[],
|
||||
this.recommendedShifts = const <Map<String, dynamic>>[],
|
||||
this.todaysShifts = const <TodayShift>[],
|
||||
this.tomorrowsShifts = const <AssignedShift>[],
|
||||
this.recommendedShifts = const <OpenShift>[],
|
||||
this.benefits = const <Benefit>[],
|
||||
});
|
||||
|
||||
@@ -25,10 +28,19 @@ class StaffDashboard extends Equatable {
|
||||
: const <Benefit>[];
|
||||
|
||||
return StaffDashboard(
|
||||
staffName: json['staffName'] as String,
|
||||
todaysShifts: _castShiftList(json['todaysShifts']),
|
||||
tomorrowsShifts: _castShiftList(json['tomorrowsShifts']),
|
||||
recommendedShifts: _castShiftList(json['recommendedShifts']),
|
||||
staffName: json['staffName'] as String? ?? '',
|
||||
todaysShifts: _parseList<TodayShift>(
|
||||
json['todaysShifts'],
|
||||
TodayShift.fromJson,
|
||||
),
|
||||
tomorrowsShifts: _parseList<AssignedShift>(
|
||||
json['tomorrowsShifts'],
|
||||
AssignedShift.fromJson,
|
||||
),
|
||||
recommendedShifts: _parseList<OpenShift>(
|
||||
json['recommendedShifts'],
|
||||
OpenShift.fromJson,
|
||||
),
|
||||
benefits: benefitsList,
|
||||
);
|
||||
}
|
||||
@@ -37,13 +49,13 @@ class StaffDashboard extends Equatable {
|
||||
final String staffName;
|
||||
|
||||
/// Shifts assigned for today.
|
||||
final List<Map<String, dynamic>> todaysShifts;
|
||||
final List<TodayShift> todaysShifts;
|
||||
|
||||
/// Shifts assigned for tomorrow.
|
||||
final List<Map<String, dynamic>> tomorrowsShifts;
|
||||
final List<AssignedShift> tomorrowsShifts;
|
||||
|
||||
/// Recommended open shifts.
|
||||
final List<Map<String, dynamic>> recommendedShifts;
|
||||
final List<OpenShift> recommendedShifts;
|
||||
|
||||
/// Active benefits.
|
||||
final List<Benefit> benefits;
|
||||
@@ -52,21 +64,27 @@ class StaffDashboard extends Equatable {
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'staffName': staffName,
|
||||
'todaysShifts': todaysShifts,
|
||||
'tomorrowsShifts': tomorrowsShifts,
|
||||
'recommendedShifts': recommendedShifts,
|
||||
'todaysShifts':
|
||||
todaysShifts.map((TodayShift s) => s.toJson()).toList(),
|
||||
'tomorrowsShifts':
|
||||
tomorrowsShifts.map((AssignedShift s) => s.toJson()).toList(),
|
||||
'recommendedShifts':
|
||||
recommendedShifts.map((OpenShift s) => s.toJson()).toList(),
|
||||
'benefits': benefits.map((Benefit b) => b.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
static List<Map<String, dynamic>> _castShiftList(dynamic raw) {
|
||||
/// Safely parses a JSON list into a typed [List].
|
||||
static List<T> _parseList<T>(
|
||||
dynamic raw,
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
if (raw is List) {
|
||||
return raw
|
||||
.map((dynamic e) =>
|
||||
Map<String, dynamic>.from(e as Map<dynamic, dynamic>))
|
||||
.map((dynamic e) => fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
return const <Map<String, dynamic>>[];
|
||||
return <T>[];
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -17,6 +17,7 @@ class StaffPersonalInfo extends Equatable {
|
||||
this.skills = const <String>[],
|
||||
this.email,
|
||||
this.phone,
|
||||
this.photoUrl,
|
||||
});
|
||||
|
||||
/// Deserialises a [StaffPersonalInfo] from the V2 API JSON response.
|
||||
@@ -32,6 +33,7 @@ class StaffPersonalInfo extends Equatable {
|
||||
skills: _parseStringList(json['skills']),
|
||||
email: json['email'] as String?,
|
||||
phone: json['phone'] as String?,
|
||||
photoUrl: json['photoUrl'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,6 +67,9 @@ class StaffPersonalInfo extends Equatable {
|
||||
/// Contact phone number.
|
||||
final String? phone;
|
||||
|
||||
/// URL of the staff member's profile photo.
|
||||
final String? photoUrl;
|
||||
|
||||
/// Serialises this [StaffPersonalInfo] to a JSON map.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
@@ -78,6 +83,7 @@ class StaffPersonalInfo extends Equatable {
|
||||
'skills': skills,
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'photoUrl': photoUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,6 +99,7 @@ class StaffPersonalInfo extends Equatable {
|
||||
skills,
|
||||
email,
|
||||
phone,
|
||||
photoUrl,
|
||||
];
|
||||
|
||||
/// Parses a dynamic value into a list of strings.
|
||||
|
||||
@@ -1,31 +1,6 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Lifecycle status of a staff account in V2.
|
||||
enum StaffStatus {
|
||||
/// Staff is active and eligible for work.
|
||||
active,
|
||||
|
||||
/// Staff has been invited but has not completed onboarding.
|
||||
invited,
|
||||
|
||||
/// Staff account has been deactivated.
|
||||
inactive,
|
||||
|
||||
/// Staff account has been blocked by an admin.
|
||||
blocked,
|
||||
}
|
||||
|
||||
/// Onboarding progress of a staff member.
|
||||
enum OnboardingStatus {
|
||||
/// Onboarding has not started.
|
||||
pending,
|
||||
|
||||
/// Onboarding is in progress.
|
||||
inProgress,
|
||||
|
||||
/// Onboarding is complete.
|
||||
completed,
|
||||
}
|
||||
import 'package:krow_domain/krow_domain.dart' show OnboardingStatus, StaffStatus;
|
||||
|
||||
/// Represents a worker profile in the KROW platform.
|
||||
///
|
||||
@@ -63,9 +38,9 @@ class Staff extends Equatable {
|
||||
fullName: json['fullName'] as String,
|
||||
email: json['email'] as String?,
|
||||
phone: json['phone'] as String?,
|
||||
status: _parseStaffStatus(json['status'] as String?),
|
||||
status: StaffStatus.fromJson(json['status'] as String?),
|
||||
primaryRole: json['primaryRole'] as String?,
|
||||
onboardingStatus: _parseOnboardingStatus(json['onboardingStatus'] as String?),
|
||||
onboardingStatus: OnboardingStatus.fromJson(json['onboardingStatus'] as String?),
|
||||
averageRating: _parseDouble(json['averageRating']),
|
||||
ratingCount: (json['ratingCount'] as num?)?.toInt() ?? 0,
|
||||
metadata: (json['metadata'] as Map<String, dynamic>?) ?? const <String, dynamic>{},
|
||||
@@ -137,9 +112,9 @@ class Staff extends Equatable {
|
||||
'fullName': fullName,
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'status': status.name.toUpperCase(),
|
||||
'status': status.toJson(),
|
||||
'primaryRole': primaryRole,
|
||||
'onboardingStatus': onboardingStatus.name.toUpperCase(),
|
||||
'onboardingStatus': onboardingStatus.toJson(),
|
||||
'averageRating': averageRating,
|
||||
'ratingCount': ratingCount,
|
||||
'metadata': metadata,
|
||||
@@ -172,36 +147,6 @@ class Staff extends Equatable {
|
||||
updatedAt,
|
||||
];
|
||||
|
||||
/// Parses a status string into a [StaffStatus].
|
||||
static StaffStatus _parseStaffStatus(String? value) {
|
||||
switch (value?.toUpperCase()) {
|
||||
case 'ACTIVE':
|
||||
return StaffStatus.active;
|
||||
case 'INVITED':
|
||||
return StaffStatus.invited;
|
||||
case 'INACTIVE':
|
||||
return StaffStatus.inactive;
|
||||
case 'BLOCKED':
|
||||
return StaffStatus.blocked;
|
||||
default:
|
||||
return StaffStatus.active;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an onboarding status string into an [OnboardingStatus].
|
||||
static OnboardingStatus _parseOnboardingStatus(String? value) {
|
||||
switch (value?.toUpperCase()) {
|
||||
case 'PENDING':
|
||||
return OnboardingStatus.pending;
|
||||
case 'IN_PROGRESS':
|
||||
return OnboardingStatus.inProgress;
|
||||
case 'COMPLETED':
|
||||
return OnboardingStatus.completed;
|
||||
default:
|
||||
return OnboardingStatus.pending;
|
||||
}
|
||||
}
|
||||
|
||||
/// Safely parses a numeric value to double.
|
||||
static double _parseDouble(Object? value) {
|
||||
if (value is num) return value.toDouble();
|
||||
|
||||
@@ -2,7 +2,8 @@ library;
|
||||
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'src/data/repositories_impl/auth_repository_impl.dart';
|
||||
import 'src/domain/repositories/auth_repository_interface.dart';
|
||||
import 'src/domain/usecases/sign_in_with_email_use_case.dart';
|
||||
@@ -21,14 +22,19 @@ export 'src/presentation/pages/client_sign_up_page.dart';
|
||||
export 'package:core_localization/core_localization.dart';
|
||||
|
||||
/// A [Module] for the client authentication feature.
|
||||
///
|
||||
/// Imports [CoreModule] for [BaseApiService] and registers repositories,
|
||||
/// use cases, and BLoCs for the client authentication flow.
|
||||
class ClientAuthenticationModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[DataConnectModule()];
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<AuthRepositoryInterface>(AuthRepositoryImpl.new);
|
||||
i.addLazySingleton<AuthRepositoryInterface>(
|
||||
() => AuthRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||
);
|
||||
|
||||
// UseCases
|
||||
i.addLazySingleton(
|
||||
|
||||
@@ -1,68 +1,96 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart'
|
||||
show
|
||||
AccountExistsException,
|
||||
ApiResponse,
|
||||
AppException,
|
||||
BaseApiService,
|
||||
ClientSession,
|
||||
InvalidCredentialsException,
|
||||
NetworkException,
|
||||
PasswordMismatchException,
|
||||
SignInFailedException,
|
||||
SignUpFailedException,
|
||||
WeakPasswordException,
|
||||
AccountExistsException,
|
||||
UserNotFoundException,
|
||||
UnauthorizedAppException,
|
||||
PasswordMismatchException,
|
||||
NetworkException;
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
User,
|
||||
UserStatus,
|
||||
WeakPasswordException;
|
||||
|
||||
import '../../domain/repositories/auth_repository_interface.dart';
|
||||
import 'package:client_authentication/src/domain/repositories/auth_repository_interface.dart';
|
||||
|
||||
/// Production-ready implementation of the [AuthRepositoryInterface] for the client app.
|
||||
/// Production implementation of the [AuthRepositoryInterface] for the client app.
|
||||
///
|
||||
/// This implementation integrates with Firebase Authentication for user
|
||||
/// identity management and KROW's Data Connect SDK for storing user profile data.
|
||||
/// Uses Firebase Auth client-side for sign-in (to maintain local auth state for
|
||||
/// the [AuthInterceptor]), then calls V2 `GET /auth/session` to retrieve
|
||||
/// business context. Sign-up provisioning (tenant, business, memberships) is
|
||||
/// handled entirely server-side by the V2 API.
|
||||
class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
/// Creates an [AuthRepositoryImpl] with the real dependencies.
|
||||
AuthRepositoryImpl({dc.DataConnectService? service})
|
||||
: _service = service ?? dc.DataConnectService.instance;
|
||||
/// Creates an [AuthRepositoryImpl] with the given [BaseApiService].
|
||||
AuthRepositoryImpl({required BaseApiService apiService})
|
||||
: _apiService = apiService;
|
||||
|
||||
final dc.DataConnectService _service;
|
||||
/// The V2 API service for backend calls.
|
||||
final BaseApiService _apiService;
|
||||
|
||||
/// Firebase Auth instance for client-side sign-in/sign-up.
|
||||
firebase.FirebaseAuth get _auth => firebase.FirebaseAuth.instance;
|
||||
|
||||
@override
|
||||
Future<domain.User> signInWithEmail({
|
||||
Future<User> signInWithEmail({
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
try {
|
||||
final firebase.UserCredential credential = await _service.auth
|
||||
.signInWithEmailAndPassword(email: email, password: password);
|
||||
// Step 1: Call V2 sign-in endpoint — server handles Firebase Auth
|
||||
// via Identity Toolkit and returns a full auth envelope.
|
||||
final ApiResponse response = await _apiService.post(
|
||||
V2ApiEndpoints.clientSignIn,
|
||||
data: <String, dynamic>{
|
||||
'email': email,
|
||||
'password': password,
|
||||
},
|
||||
);
|
||||
|
||||
final Map<String, dynamic> body =
|
||||
response.data as Map<String, dynamic>;
|
||||
|
||||
// Check for V2 error responses.
|
||||
if (response.code != '200' && response.code != '201') {
|
||||
final String errorCode = body['code']?.toString() ?? response.code;
|
||||
if (errorCode == 'INVALID_CREDENTIALS' ||
|
||||
response.message.contains('INVALID_LOGIN_CREDENTIALS')) {
|
||||
throw InvalidCredentialsException(
|
||||
technicalMessage: response.message,
|
||||
);
|
||||
}
|
||||
throw SignInFailedException(
|
||||
technicalMessage: '$errorCode: ${response.message}',
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Sign in locally so AuthInterceptor can attach Bearer tokens
|
||||
// to subsequent requests. The V2 API already validated credentials, so
|
||||
// email/password sign-in establishes the local Firebase Auth state.
|
||||
final firebase.UserCredential credential =
|
||||
await _auth.signInWithEmailAndPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
|
||||
final firebase.User? firebaseUser = credential.user;
|
||||
if (firebaseUser == null) {
|
||||
throw const SignInFailedException(
|
||||
technicalMessage: 'No Firebase user received after sign-in',
|
||||
technicalMessage: 'Local Firebase sign-in failed after V2 sign-in',
|
||||
);
|
||||
}
|
||||
|
||||
return _getUserProfile(
|
||||
firebaseUserId: firebaseUser.uid,
|
||||
fallbackEmail: firebaseUser.email ?? email,
|
||||
requireBusinessRole: true,
|
||||
);
|
||||
} on firebase.FirebaseAuthException catch (e) {
|
||||
if (e.code == 'invalid-credential' || e.code == 'wrong-password') {
|
||||
throw InvalidCredentialsException(
|
||||
technicalMessage: 'Firebase error code: ${e.code}',
|
||||
);
|
||||
} else if (e.code == 'network-request-failed') {
|
||||
throw NetworkException(technicalMessage: 'Firebase: ${e.message}');
|
||||
} else {
|
||||
throw SignInFailedException(
|
||||
technicalMessage: 'Firebase auth error: ${e.message}',
|
||||
);
|
||||
}
|
||||
} on domain.AppException {
|
||||
// Step 3: Populate session store from the V2 auth envelope directly
|
||||
// (no need for a separate GET /auth/session call).
|
||||
return _populateStoreFromAuthEnvelope(body, firebaseUser, email);
|
||||
} on AppException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw SignInFailedException(technicalMessage: 'Unexpected error: $e');
|
||||
@@ -70,50 +98,57 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.User> signUpWithEmail({
|
||||
Future<User> signUpWithEmail({
|
||||
required String companyName,
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
firebase.User? firebaseUser;
|
||||
String? createdBusinessId;
|
||||
|
||||
try {
|
||||
// Step 1: Try to create Firebase Auth user
|
||||
final firebase.UserCredential credential = await _service.auth
|
||||
.createUserWithEmailAndPassword(email: email, password: password);
|
||||
// Step 1: Call V2 sign-up endpoint which handles everything server-side:
|
||||
// - Creates Firebase Auth account via Identity Toolkit
|
||||
// - Creates user, tenant, business, memberships in one transaction
|
||||
// - Returns full auth envelope with session tokens
|
||||
final ApiResponse response = await _apiService.post(
|
||||
V2ApiEndpoints.clientSignUp,
|
||||
data: <String, dynamic>{
|
||||
'companyName': companyName,
|
||||
'email': email,
|
||||
'password': password,
|
||||
},
|
||||
);
|
||||
|
||||
firebaseUser = credential.user;
|
||||
// Check for V2 error responses.
|
||||
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
|
||||
if (response.code != '201' && response.code != '200') {
|
||||
final String errorCode = body['code']?.toString() ?? response.code;
|
||||
_throwSignUpError(errorCode, response.message);
|
||||
}
|
||||
|
||||
// Step 2: Sign in locally to Firebase Auth so AuthInterceptor works
|
||||
// for subsequent requests. The V2 API already created the Firebase
|
||||
// account, so this should succeed.
|
||||
final firebase.UserCredential credential =
|
||||
await _auth.signInWithEmailAndPassword(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
|
||||
final firebase.User? firebaseUser = credential.user;
|
||||
if (firebaseUser == null) {
|
||||
throw const SignUpFailedException(
|
||||
technicalMessage: 'Firebase user could not be created',
|
||||
technicalMessage: 'Local Firebase sign-in failed after V2 sign-up',
|
||||
);
|
||||
}
|
||||
|
||||
// Force-refresh the ID token so the Data Connect SDK has a valid bearer
|
||||
// token before we fire any mutations. Without this, there is a race
|
||||
// condition where the gRPC layer sends the request unauthenticated
|
||||
// immediately after account creation (gRPC code 16 UNAUTHENTICATED).
|
||||
await firebaseUser.getIdToken(true);
|
||||
|
||||
// New user created successfully, proceed to create PostgreSQL entities
|
||||
return await _createBusinessAndUser(
|
||||
firebaseUser: firebaseUser,
|
||||
companyName: companyName,
|
||||
email: email,
|
||||
onBusinessCreated: (String businessId) =>
|
||||
createdBusinessId = businessId,
|
||||
);
|
||||
// Step 3: Populate store from the sign-up response envelope.
|
||||
return _populateStoreFromAuthEnvelope(body, firebaseUser, email);
|
||||
} on firebase.FirebaseAuthException catch (e) {
|
||||
if (e.code == 'weak-password') {
|
||||
throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}');
|
||||
} else if (e.code == 'email-already-in-use') {
|
||||
// Email exists in Firebase Auth - try to sign in and complete registration
|
||||
return await _handleExistingFirebaseAccount(
|
||||
email: email,
|
||||
password: password,
|
||||
companyName: companyName,
|
||||
if (e.code == 'email-already-in-use') {
|
||||
throw AccountExistsException(
|
||||
technicalMessage: 'Firebase: ${e.message}',
|
||||
);
|
||||
} else if (e.code == 'weak-password') {
|
||||
throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}');
|
||||
} else if (e.code == 'network-request-failed') {
|
||||
throw NetworkException(technicalMessage: 'Firebase: ${e.message}');
|
||||
} else {
|
||||
@@ -121,304 +156,103 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
technicalMessage: 'Firebase auth error: ${e.message}',
|
||||
);
|
||||
}
|
||||
} on domain.AppException {
|
||||
// Rollback for our known exceptions
|
||||
await _rollbackSignUp(
|
||||
firebaseUser: firebaseUser,
|
||||
businessId: createdBusinessId,
|
||||
);
|
||||
} on AppException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
// Rollback: Clean up any partially created resources
|
||||
await _rollbackSignUp(
|
||||
firebaseUser: firebaseUser,
|
||||
businessId: createdBusinessId,
|
||||
);
|
||||
throw SignUpFailedException(technicalMessage: 'Unexpected error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the case where email already exists in Firebase Auth.
|
||||
///
|
||||
/// This can happen when:
|
||||
/// 1. User signed up with Google in another app sharing the same Firebase project
|
||||
/// 2. User already has a KROW account
|
||||
///
|
||||
/// The flow:
|
||||
/// 1. Try to sign in with provided password
|
||||
/// 2. If sign-in succeeds, check if BUSINESS user exists in PostgreSQL
|
||||
/// 3. If not, create Business + User (user is new to KROW)
|
||||
/// 4. If yes, they already have a KROW account
|
||||
Future<domain.User> _handleExistingFirebaseAccount({
|
||||
required String email,
|
||||
required String password,
|
||||
required String companyName,
|
||||
}) async {
|
||||
developer.log(
|
||||
'Email exists in Firebase, attempting sign-in: $email',
|
||||
name: 'AuthRepository',
|
||||
);
|
||||
|
||||
try {
|
||||
// Try to sign in with the provided password
|
||||
final firebase.UserCredential credential = await _service.auth
|
||||
.signInWithEmailAndPassword(email: email, password: password);
|
||||
|
||||
final firebase.User? firebaseUser = credential.user;
|
||||
if (firebaseUser == null) {
|
||||
throw const SignUpFailedException(
|
||||
technicalMessage: 'Sign-in succeeded but no user returned',
|
||||
);
|
||||
}
|
||||
|
||||
// Force-refresh the ID token so the Data Connect SDK receives a valid
|
||||
// bearer token before any subsequent Data Connect queries run.
|
||||
await firebaseUser.getIdToken(true);
|
||||
|
||||
// Sign-in succeeded! Check if user already has a BUSINESS account in PostgreSQL
|
||||
final bool hasBusinessAccount = await _checkBusinessUserExists(
|
||||
firebaseUser.uid,
|
||||
);
|
||||
|
||||
if (hasBusinessAccount) {
|
||||
// User already has a KROW Client account
|
||||
developer.log(
|
||||
'User already has BUSINESS account: ${firebaseUser.uid}',
|
||||
name: 'AuthRepository',
|
||||
);
|
||||
throw AccountExistsException(
|
||||
technicalMessage:
|
||||
'User ${firebaseUser.uid} already has BUSINESS role',
|
||||
);
|
||||
}
|
||||
|
||||
// User exists in Firebase but not in KROW PostgreSQL - create the entities
|
||||
developer.log(
|
||||
'Creating BUSINESS account for existing Firebase user: ${firebaseUser.uid}',
|
||||
name: 'AuthRepository',
|
||||
);
|
||||
return await _createBusinessAndUser(
|
||||
firebaseUser: firebaseUser,
|
||||
companyName: companyName,
|
||||
email: email,
|
||||
onBusinessCreated:
|
||||
(_) {}, // No rollback needed for existing Firebase user
|
||||
);
|
||||
} on firebase.FirebaseAuthException catch (e) {
|
||||
// Sign-in failed - check why
|
||||
developer.log(
|
||||
'Sign-in failed with code: ${e.code}',
|
||||
name: 'AuthRepository',
|
||||
);
|
||||
|
||||
if (e.code == 'wrong-password' || e.code == 'invalid-credential') {
|
||||
// Password doesn't match - check what providers are available
|
||||
return await _handlePasswordMismatch(email);
|
||||
} else {
|
||||
throw SignUpFailedException(
|
||||
technicalMessage: 'Firebase sign-in error: ${e.message}',
|
||||
);
|
||||
}
|
||||
} on domain.AppException {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the case where the password doesn't match the existing account.
|
||||
///
|
||||
/// Note: fetchSignInMethodsForEmail was deprecated by Firebase for security
|
||||
/// reasons (email enumeration). We show a combined message that covers both
|
||||
/// cases: wrong password OR account uses different sign-in method (Google).
|
||||
Future<Never> _handlePasswordMismatch(String email) async {
|
||||
// We can't distinguish between "wrong password" and "no password provider"
|
||||
// due to Firebase deprecating fetchSignInMethodsForEmail.
|
||||
// The PasswordMismatchException message covers both scenarios.
|
||||
developer.log(
|
||||
'Password mismatch or different provider for: $email',
|
||||
name: 'AuthRepository',
|
||||
);
|
||||
throw PasswordMismatchException(
|
||||
technicalMessage:
|
||||
'Email $email: password mismatch or different auth provider',
|
||||
);
|
||||
}
|
||||
|
||||
/// Checks if a user with BUSINESS role exists in PostgreSQL.
|
||||
|
||||
Future<bool> _checkBusinessUserExists(String firebaseUserId) async {
|
||||
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response =
|
||||
await _service.run(
|
||||
() => _service.connector.getUserById(id: firebaseUserId).execute(),
|
||||
);
|
||||
final dc.GetUserByIdUser? user = response.data.user;
|
||||
return user != null &&
|
||||
(user.userRole == 'BUSINESS' || user.userRole == 'BOTH');
|
||||
}
|
||||
|
||||
/// Creates Business and User entities in PostgreSQL for a Firebase user.
|
||||
Future<domain.User> _createBusinessAndUser({
|
||||
required firebase.User firebaseUser,
|
||||
required String companyName,
|
||||
required String email,
|
||||
required void Function(String businessId) onBusinessCreated,
|
||||
}) async {
|
||||
// Create Business entity in PostgreSQL
|
||||
|
||||
final OperationResult<dc.CreateBusinessData, dc.CreateBusinessVariables>
|
||||
createBusinessResponse = await _service.run(
|
||||
() => _service.connector
|
||||
.createBusiness(
|
||||
businessName: companyName,
|
||||
userId: firebaseUser.uid,
|
||||
rateGroup: dc.BusinessRateGroup.STANDARD,
|
||||
status: dc.BusinessStatus.PENDING,
|
||||
)
|
||||
.execute(),
|
||||
);
|
||||
|
||||
final dc.CreateBusinessBusinessInsert businessData =
|
||||
createBusinessResponse.data.business_insert;
|
||||
onBusinessCreated(businessData.id);
|
||||
|
||||
// Check if User entity already exists in PostgreSQL
|
||||
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> userResult =
|
||||
await _service.run(
|
||||
() => _service.connector.getUserById(id: firebaseUser.uid).execute(),
|
||||
);
|
||||
final dc.GetUserByIdUser? existingUser = userResult.data.user;
|
||||
|
||||
if (existingUser != null) {
|
||||
// User exists (likely in another app like STAFF). Update role to BOTH.
|
||||
await _service.run(
|
||||
() => _service.connector
|
||||
.updateUser(id: firebaseUser.uid)
|
||||
.userRole('BOTH')
|
||||
.execute(),
|
||||
);
|
||||
} else {
|
||||
// Create new User entity in PostgreSQL
|
||||
await _service.run(
|
||||
() => _service.connector
|
||||
.createUser(id: firebaseUser.uid, role: dc.UserBaseRole.USER)
|
||||
.email(email)
|
||||
.userRole('BUSINESS')
|
||||
.execute(),
|
||||
);
|
||||
}
|
||||
|
||||
return _getUserProfile(
|
||||
firebaseUserId: firebaseUser.uid,
|
||||
fallbackEmail: firebaseUser.email ?? email,
|
||||
);
|
||||
}
|
||||
|
||||
/// Rollback helper to clean up partially created resources during sign-up.
|
||||
Future<void> _rollbackSignUp({
|
||||
firebase.User? firebaseUser,
|
||||
String? businessId,
|
||||
}) async {
|
||||
// Delete business first (if created)
|
||||
if (businessId != null) {
|
||||
try {
|
||||
await _service.connector.deleteBusiness(id: businessId).execute();
|
||||
} catch (_) {
|
||||
// Log but don't throw - we're already in error recovery
|
||||
}
|
||||
}
|
||||
// Delete Firebase user (if created)
|
||||
if (firebaseUser != null) {
|
||||
try {
|
||||
await firebaseUser.delete();
|
||||
} catch (_) {
|
||||
// Log but don't throw - we're already in error recovery
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
try {
|
||||
await _service.signOut();
|
||||
} catch (e) {
|
||||
throw Exception('Error signing out: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.User> signInWithSocial({required String provider}) {
|
||||
Future<User> signInWithSocial({required String provider}) {
|
||||
throw UnimplementedError(
|
||||
'Social authentication with $provider is not yet implemented.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<domain.User> _getUserProfile({
|
||||
required String firebaseUserId,
|
||||
required String? fallbackEmail,
|
||||
bool requireBusinessRole = false,
|
||||
}) async {
|
||||
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response =
|
||||
await _service.run(
|
||||
() => _service.connector.getUserById(id: firebaseUserId).execute(),
|
||||
);
|
||||
final dc.GetUserByIdUser? user = response.data.user;
|
||||
if (user == null) {
|
||||
throw UserNotFoundException(
|
||||
technicalMessage:
|
||||
'Firebase UID $firebaseUserId not found in users table',
|
||||
);
|
||||
}
|
||||
if (requireBusinessRole &&
|
||||
user.userRole != 'BUSINESS' &&
|
||||
user.userRole != 'BOTH') {
|
||||
await _service.signOut();
|
||||
throw UnauthorizedAppException(
|
||||
technicalMessage:
|
||||
'User role is ${user.userRole}, expected BUSINESS or BOTH',
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
try {
|
||||
// Step 1: Call V2 sign-out endpoint for server-side token revocation.
|
||||
await _apiService.post(V2ApiEndpoints.clientSignOut);
|
||||
} catch (e) {
|
||||
developer.log(
|
||||
'V2 sign-out request failed: $e',
|
||||
name: 'AuthRepository',
|
||||
);
|
||||
// Continue with local sign-out even if server-side fails.
|
||||
}
|
||||
|
||||
final String? email = user.email ?? fallbackEmail;
|
||||
if (email == null || email.isEmpty) {
|
||||
throw UserNotFoundException(
|
||||
technicalMessage: 'User email missing for UID $firebaseUserId',
|
||||
);
|
||||
try {
|
||||
// Step 2: Sign out from local Firebase Auth.
|
||||
await _auth.signOut();
|
||||
} catch (e) {
|
||||
throw Exception('Error signing out locally: $e');
|
||||
}
|
||||
|
||||
final domain.User domainUser = domain.User(
|
||||
id: user.id,
|
||||
// Step 3: Clear the client session store.
|
||||
ClientSessionStore.instance.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Populates the session store from a V2 auth envelope response and
|
||||
/// returns a domain [User].
|
||||
User _populateStoreFromAuthEnvelope(
|
||||
Map<String, dynamic> envelope,
|
||||
firebase.User firebaseUser,
|
||||
String fallbackEmail,
|
||||
) {
|
||||
final Map<String, dynamic>? userJson =
|
||||
envelope['user'] as Map<String, dynamic>?;
|
||||
final Map<String, dynamic>? businessJson =
|
||||
envelope['business'] as Map<String, dynamic>?;
|
||||
|
||||
if (businessJson != null) {
|
||||
final ClientSession clientSession = ClientSession.fromJson(envelope);
|
||||
ClientSessionStore.instance.setSession(clientSession);
|
||||
}
|
||||
|
||||
final String userId =
|
||||
userJson?['id'] as String? ?? firebaseUser.uid;
|
||||
final String? email = userJson?['email'] as String? ?? fallbackEmail;
|
||||
|
||||
return User(
|
||||
id: userId,
|
||||
email: email,
|
||||
role: user.role.stringValue,
|
||||
displayName: userJson?['displayName'] as String?,
|
||||
phone: userJson?['phone'] as String?,
|
||||
status: _parseUserStatus(userJson?['status'] as String?),
|
||||
);
|
||||
}
|
||||
|
||||
final QueryResult<
|
||||
dc.GetBusinessesByUserIdData,
|
||||
dc.GetBusinessesByUserIdVariables
|
||||
>
|
||||
businessResponse = await _service.run(
|
||||
() => _service.connector
|
||||
.getBusinessesByUserId(userId: firebaseUserId)
|
||||
.execute(),
|
||||
);
|
||||
final dc.GetBusinessesByUserIdBusinesses? business =
|
||||
businessResponse.data.businesses.isNotEmpty
|
||||
? businessResponse.data.businesses.first
|
||||
: null;
|
||||
/// Maps a V2 error code to the appropriate domain exception for sign-up.
|
||||
Never _throwSignUpError(String errorCode, String message) {
|
||||
switch (errorCode) {
|
||||
case 'AUTH_PROVIDER_ERROR' when message.contains('EMAIL_EXISTS'):
|
||||
throw AccountExistsException(technicalMessage: message);
|
||||
case 'AUTH_PROVIDER_ERROR' when message.contains('WEAK_PASSWORD'):
|
||||
throw WeakPasswordException(technicalMessage: message);
|
||||
case 'FORBIDDEN':
|
||||
throw PasswordMismatchException(technicalMessage: message);
|
||||
default:
|
||||
throw SignUpFailedException(technicalMessage: '$errorCode: $message');
|
||||
}
|
||||
}
|
||||
|
||||
dc.ClientSessionStore.instance.setSession(
|
||||
dc.ClientSession(
|
||||
business: business == null
|
||||
? null
|
||||
: dc.ClientBusinessSession(
|
||||
id: business.id,
|
||||
businessName: business.businessName,
|
||||
email: business.email,
|
||||
city: business.city,
|
||||
contactName: business.contactName,
|
||||
companyLogoUrl: business.companyLogoUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return domainUser;
|
||||
/// Parses a status string from the API into a [UserStatus].
|
||||
static UserStatus _parseUserStatus(String? value) {
|
||||
switch (value?.toUpperCase()) {
|
||||
case 'ACTIVE':
|
||||
return UserStatus.active;
|
||||
case 'INVITED':
|
||||
return UserStatus.invited;
|
||||
case 'DISABLED':
|
||||
return UserStatus.disabled;
|
||||
default:
|
||||
return UserStatus.active;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,17 +14,13 @@ dependencies:
|
||||
flutter_bloc: ^8.1.0
|
||||
flutter_modular: ^6.3.0
|
||||
equatable: ^2.0.5
|
||||
firebase_core: ^4.2.1
|
||||
firebase_auth: ^6.1.2 # Updated for compatibility
|
||||
firebase_data_connect: ^0.2.2+1
|
||||
|
||||
firebase_auth: ^6.1.2
|
||||
|
||||
# Architecture Packages
|
||||
design_system:
|
||||
path: ../../../design_system
|
||||
core_localization:
|
||||
path: ../../../core_localization
|
||||
krow_data_connect:
|
||||
path: ../../../data_connect
|
||||
krow_domain:
|
||||
path: ../../../domain
|
||||
krow_core:
|
||||
@@ -35,7 +31,6 @@ dev_dependencies:
|
||||
sdk: flutter
|
||||
bloc_test: ^9.1.0
|
||||
mocktail: ^1.0.0
|
||||
build_runner: ^2.4.15
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -1,30 +1,37 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'data/repositories_impl/billing_repository_impl.dart';
|
||||
import 'domain/repositories/billing_repository.dart';
|
||||
import 'domain/usecases/get_bank_accounts.dart';
|
||||
import 'domain/usecases/get_current_bill_amount.dart';
|
||||
import 'domain/usecases/get_invoice_history.dart';
|
||||
import 'domain/usecases/get_pending_invoices.dart';
|
||||
import 'domain/usecases/get_savings_amount.dart';
|
||||
import 'domain/usecases/get_spending_breakdown.dart';
|
||||
import 'domain/usecases/approve_invoice.dart';
|
||||
import 'domain/usecases/dispute_invoice.dart';
|
||||
import 'presentation/blocs/billing_bloc.dart';
|
||||
import 'presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
|
||||
import 'presentation/models/billing_invoice_model.dart';
|
||||
import 'presentation/pages/billing_page.dart';
|
||||
import 'presentation/pages/completion_review_page.dart';
|
||||
import 'presentation/pages/invoice_ready_page.dart';
|
||||
import 'presentation/pages/pending_invoices_page.dart';
|
||||
import 'package:billing/src/data/repositories_impl/billing_repository_impl.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/usecases/approve_invoice.dart';
|
||||
import 'package:billing/src/domain/usecases/dispute_invoice.dart';
|
||||
import 'package:billing/src/domain/usecases/get_bank_accounts.dart';
|
||||
import 'package:billing/src/domain/usecases/get_current_bill_amount.dart';
|
||||
import 'package:billing/src/domain/usecases/get_invoice_history.dart';
|
||||
import 'package:billing/src/domain/usecases/get_pending_invoices.dart';
|
||||
import 'package:billing/src/domain/usecases/get_savings_amount.dart';
|
||||
import 'package:billing/src/domain/usecases/get_spending_breakdown.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
|
||||
import 'package:billing/src/presentation/pages/billing_page.dart';
|
||||
import 'package:billing/src/presentation/pages/completion_review_page.dart';
|
||||
import 'package:billing/src/presentation/pages/invoice_ready_page.dart';
|
||||
import 'package:billing/src/presentation/pages/pending_invoices_page.dart';
|
||||
|
||||
/// Modular module for the billing feature.
|
||||
///
|
||||
/// Uses [BaseApiService] for all backend access via V2 REST API.
|
||||
class BillingModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<BillingRepository>(BillingRepositoryImpl.new);
|
||||
i.addLazySingleton<BillingRepository>(
|
||||
() => BillingRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||
);
|
||||
|
||||
// Use Cases
|
||||
i.addLazySingleton(GetBankAccountsUseCase.new);
|
||||
@@ -32,7 +39,7 @@ class BillingModule extends Module {
|
||||
i.addLazySingleton(GetSavingsAmountUseCase.new);
|
||||
i.addLazySingleton(GetPendingInvoicesUseCase.new);
|
||||
i.addLazySingleton(GetInvoiceHistoryUseCase.new);
|
||||
i.addLazySingleton(GetSpendingBreakdownUseCase.new);
|
||||
i.addLazySingleton(GetSpendBreakdownUseCase.new);
|
||||
i.addLazySingleton(ApproveInvoiceUseCase.new);
|
||||
i.addLazySingleton(DisputeInvoiceUseCase.new);
|
||||
|
||||
@@ -44,7 +51,7 @@ class BillingModule extends Module {
|
||||
getSavingsAmount: i.get<GetSavingsAmountUseCase>(),
|
||||
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),
|
||||
getInvoiceHistory: i.get<GetInvoiceHistoryUseCase>(),
|
||||
getSpendingBreakdown: i.get<GetSpendingBreakdownUseCase>(),
|
||||
getSpendBreakdown: i.get<GetSpendBreakdownUseCase>(),
|
||||
),
|
||||
);
|
||||
i.add<ShiftCompletionReviewBloc>(
|
||||
@@ -62,16 +69,20 @@ class BillingModule extends Module {
|
||||
child: (_) => const BillingPage(),
|
||||
);
|
||||
r.child(
|
||||
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.completionReview),
|
||||
child: (_) =>
|
||||
ShiftCompletionReviewPage(invoice: r.args.data as BillingInvoice?),
|
||||
ClientPaths.childRoute(
|
||||
ClientPaths.billing, ClientPaths.completionReview),
|
||||
child: (_) => ShiftCompletionReviewPage(
|
||||
invoice:
|
||||
r.args.data is Invoice ? r.args.data as Invoice : null,
|
||||
),
|
||||
);
|
||||
r.child(
|
||||
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.invoiceReady),
|
||||
child: (_) => const InvoiceReadyPage(),
|
||||
);
|
||||
r.child(
|
||||
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.awaitingApproval),
|
||||
ClientPaths.childRoute(
|
||||
ClientPaths.billing, ClientPaths.awaitingApproval),
|
||||
child: (_) => const PendingInvoicesPage(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,70 +1,103 @@
|
||||
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Implementation of [BillingRepository] that delegates to [dc.BillingConnectorRepository].
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Implementation of [BillingRepository] using the V2 REST API.
|
||||
///
|
||||
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
|
||||
/// connector repository from the data_connect package.
|
||||
/// All backend calls go through [BaseApiService] with [V2ApiEndpoints].
|
||||
class BillingRepositoryImpl implements BillingRepository {
|
||||
/// Creates a [BillingRepositoryImpl].
|
||||
BillingRepositoryImpl({required BaseApiService apiService})
|
||||
: _apiService = apiService;
|
||||
|
||||
BillingRepositoryImpl({
|
||||
dc.BillingConnectorRepository? connectorRepository,
|
||||
dc.DataConnectService? service,
|
||||
}) : _connectorRepository = connectorRepository ??
|
||||
dc.DataConnectService.instance.getBillingRepository(),
|
||||
_service = service ?? dc.DataConnectService.instance;
|
||||
final dc.BillingConnectorRepository _connectorRepository;
|
||||
final dc.DataConnectService _service;
|
||||
/// The API service used for all HTTP requests.
|
||||
final BaseApiService _apiService;
|
||||
|
||||
@override
|
||||
Future<List<BusinessBankAccount>> getBankAccounts() async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.getBankAccounts(businessId: businessId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<double> getCurrentBillAmount() async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.getCurrentBillAmount(businessId: businessId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Invoice>> getInvoiceHistory() async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.getInvoiceHistory(businessId: businessId);
|
||||
Future<List<BillingAccount>> getBankAccounts() async {
|
||||
final ApiResponse response =
|
||||
await _apiService.get(V2ApiEndpoints.clientBillingAccounts);
|
||||
final List<dynamic> items =
|
||||
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
|
||||
return items
|
||||
.map((dynamic json) =>
|
||||
BillingAccount.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Invoice>> getPendingInvoices() async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.getPendingInvoices(businessId: businessId);
|
||||
final ApiResponse response =
|
||||
await _apiService.get(V2ApiEndpoints.clientBillingInvoicesPending);
|
||||
final List<dynamic> items =
|
||||
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
|
||||
return items
|
||||
.map(
|
||||
(dynamic json) => Invoice.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<double> getSavingsAmount() async {
|
||||
// Simulating savings calculation
|
||||
return 0.0;
|
||||
Future<List<Invoice>> getInvoiceHistory() async {
|
||||
final ApiResponse response =
|
||||
await _apiService.get(V2ApiEndpoints.clientBillingInvoicesHistory);
|
||||
final List<dynamic> items =
|
||||
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
|
||||
return items
|
||||
.map(
|
||||
(dynamic json) => Invoice.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period) async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.getSpendingBreakdown(
|
||||
businessId: businessId,
|
||||
period: period,
|
||||
Future<int> getCurrentBillCents() async {
|
||||
final ApiResponse response =
|
||||
await _apiService.get(V2ApiEndpoints.clientBillingCurrentBill);
|
||||
final Map<String, dynamic> data =
|
||||
response.data as Map<String, dynamic>;
|
||||
return (data['currentBillCents'] as num).toInt();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getSavingsCents() async {
|
||||
final ApiResponse response =
|
||||
await _apiService.get(V2ApiEndpoints.clientBillingSavings);
|
||||
final Map<String, dynamic> data =
|
||||
response.data as Map<String, dynamic>;
|
||||
return (data['savingsCents'] as num).toInt();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SpendItem>> getSpendBreakdown({
|
||||
required String startDate,
|
||||
required String endDate,
|
||||
}) async {
|
||||
final ApiResponse response = await _apiService.get(
|
||||
V2ApiEndpoints.clientBillingSpendBreakdown,
|
||||
params: <String, dynamic>{
|
||||
'startDate': startDate,
|
||||
'endDate': endDate,
|
||||
},
|
||||
);
|
||||
final List<dynamic> items =
|
||||
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
|
||||
return items
|
||||
.map((dynamic json) =>
|
||||
SpendItem.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> approveInvoice(String id) async {
|
||||
return _connectorRepository.approveInvoice(id: id);
|
||||
await _apiService.post(V2ApiEndpoints.clientInvoiceApprove(id));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disputeInvoice(String id, String reason) async {
|
||||
return _connectorRepository.disputeInvoice(id: id, reason: reason);
|
||||
await _apiService.post(
|
||||
V2ApiEndpoints.clientInvoiceDispute(id),
|
||||
data: <String, dynamic>{'reason': reason},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
/// It allows the Domain layer to remain independent of specific data sources.
|
||||
abstract class BillingRepository {
|
||||
/// Fetches bank accounts associated with the business.
|
||||
Future<List<BusinessBankAccount>> getBankAccounts();
|
||||
Future<List<BillingAccount>> getBankAccounts();
|
||||
|
||||
/// Fetches invoices that are pending approval or payment.
|
||||
Future<List<Invoice>> getPendingInvoices();
|
||||
@@ -15,14 +15,17 @@ abstract class BillingRepository {
|
||||
/// Fetches historically paid invoices.
|
||||
Future<List<Invoice>> getInvoiceHistory();
|
||||
|
||||
/// Fetches the current bill amount for the period.
|
||||
Future<double> getCurrentBillAmount();
|
||||
/// Fetches the current bill amount in cents for the period.
|
||||
Future<int> getCurrentBillCents();
|
||||
|
||||
/// Fetches the savings amount.
|
||||
Future<double> getSavingsAmount();
|
||||
/// Fetches the savings amount in cents.
|
||||
Future<int> getSavingsCents();
|
||||
|
||||
/// Fetches invoice items for spending breakdown analysis.
|
||||
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period);
|
||||
/// Fetches spending breakdown by category for a date range.
|
||||
Future<List<SpendItem>> getSpendBreakdown({
|
||||
required String startDate,
|
||||
required String endDate,
|
||||
});
|
||||
|
||||
/// Approves an invoice.
|
||||
Future<void> approveInvoice(String id);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for approving an invoice.
|
||||
class ApproveInvoiceUseCase extends UseCase<String, void> {
|
||||
/// Creates an [ApproveInvoiceUseCase].
|
||||
ApproveInvoiceUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Params for [DisputeInvoiceUseCase].
|
||||
class DisputeInvoiceParams {
|
||||
/// Creates [DisputeInvoiceParams].
|
||||
const DisputeInvoiceParams({required this.id, required this.reason});
|
||||
|
||||
/// The invoice ID to dispute.
|
||||
final String id;
|
||||
|
||||
/// The reason for the dispute.
|
||||
final String reason;
|
||||
}
|
||||
|
||||
@@ -13,6 +19,7 @@ class DisputeInvoiceUseCase extends UseCase<DisputeInvoiceParams, void> {
|
||||
/// Creates a [DisputeInvoiceUseCase].
|
||||
DisputeInvoiceUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the bank accounts associated with the business.
|
||||
class GetBankAccountsUseCase extends NoInputUseCase<List<BusinessBankAccount>> {
|
||||
class GetBankAccountsUseCase extends NoInputUseCase<List<BillingAccount>> {
|
||||
/// Creates a [GetBankAccountsUseCase].
|
||||
GetBankAccountsUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
Future<List<BusinessBankAccount>> call() => _repository.getBankAccounts();
|
||||
Future<List<BillingAccount>> call() => _repository.getBankAccounts();
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the current bill amount.
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the current bill amount in cents.
|
||||
///
|
||||
/// This use case encapsulates the logic for retrieving the total amount due for the current billing period.
|
||||
/// It delegates the data retrieval to the [BillingRepository].
|
||||
class GetCurrentBillAmountUseCase extends NoInputUseCase<double> {
|
||||
/// Delegates data retrieval to the [BillingRepository].
|
||||
class GetCurrentBillAmountUseCase extends NoInputUseCase<int> {
|
||||
/// Creates a [GetCurrentBillAmountUseCase].
|
||||
GetCurrentBillAmountUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
Future<double> call() => _repository.getCurrentBillAmount();
|
||||
Future<int> call() => _repository.getCurrentBillCents();
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the invoice history.
|
||||
///
|
||||
/// This use case encapsulates the logic for retrieving the list of past paid invoices.
|
||||
/// It delegates the data retrieval to the [BillingRepository].
|
||||
/// Retrieves the list of past paid invoices.
|
||||
class GetInvoiceHistoryUseCase extends NoInputUseCase<List<Invoice>> {
|
||||
/// Creates a [GetInvoiceHistoryUseCase].
|
||||
GetInvoiceHistoryUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the pending invoices.
|
||||
///
|
||||
/// This use case encapsulates the logic for retrieving invoices that are currently open or disputed.
|
||||
/// It delegates the data retrieval to the [BillingRepository].
|
||||
/// Retrieves invoices that are currently open or disputed.
|
||||
class GetPendingInvoicesUseCase extends NoInputUseCase<List<Invoice>> {
|
||||
/// Creates a [GetPendingInvoicesUseCase].
|
||||
GetPendingInvoicesUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the savings amount.
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the savings amount in cents.
|
||||
///
|
||||
/// This use case encapsulates the logic for retrieving the estimated savings for the client.
|
||||
/// It delegates the data retrieval to the [BillingRepository].
|
||||
class GetSavingsAmountUseCase extends NoInputUseCase<double> {
|
||||
/// Delegates data retrieval to the [BillingRepository].
|
||||
class GetSavingsAmountUseCase extends NoInputUseCase<int> {
|
||||
/// Creates a [GetSavingsAmountUseCase].
|
||||
GetSavingsAmountUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
Future<double> call() => _repository.getSavingsAmount();
|
||||
Future<int> call() => _repository.getSavingsCents();
|
||||
}
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the spending breakdown items.
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Parameters for [GetSpendBreakdownUseCase].
|
||||
class SpendBreakdownParams {
|
||||
/// Creates [SpendBreakdownParams].
|
||||
const SpendBreakdownParams({
|
||||
required this.startDate,
|
||||
required this.endDate,
|
||||
});
|
||||
|
||||
/// ISO-8601 start date for the range.
|
||||
final String startDate;
|
||||
|
||||
/// ISO-8601 end date for the range.
|
||||
final String endDate;
|
||||
}
|
||||
|
||||
/// Use case for fetching the spending breakdown by category.
|
||||
///
|
||||
/// This use case encapsulates the logic for retrieving the spending breakdown by category or item.
|
||||
/// It delegates the data retrieval to the [BillingRepository].
|
||||
class GetSpendingBreakdownUseCase
|
||||
extends UseCase<BillingPeriod, List<InvoiceItem>> {
|
||||
/// Creates a [GetSpendingBreakdownUseCase].
|
||||
GetSpendingBreakdownUseCase(this._repository);
|
||||
/// Delegates data retrieval to the [BillingRepository].
|
||||
class GetSpendBreakdownUseCase
|
||||
extends UseCase<SpendBreakdownParams, List<SpendItem>> {
|
||||
/// Creates a [GetSpendBreakdownUseCase].
|
||||
GetSpendBreakdownUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
Future<List<InvoiceItem>> call(BillingPeriod period) =>
|
||||
_repository.getSpendingBreakdown(period);
|
||||
Future<List<SpendItem>> call(SpendBreakdownParams input) =>
|
||||
_repository.getSpendBreakdown(
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/usecases/get_bank_accounts.dart';
|
||||
import '../../domain/usecases/get_current_bill_amount.dart';
|
||||
import '../../domain/usecases/get_invoice_history.dart';
|
||||
import '../../domain/usecases/get_pending_invoices.dart';
|
||||
import '../../domain/usecases/get_savings_amount.dart';
|
||||
import '../../domain/usecases/get_spending_breakdown.dart';
|
||||
import '../models/billing_invoice_model.dart';
|
||||
import '../models/spending_breakdown_model.dart';
|
||||
import 'billing_event.dart';
|
||||
import 'billing_state.dart';
|
||||
|
||||
import 'package:billing/src/domain/usecases/get_bank_accounts.dart';
|
||||
import 'package:billing/src/domain/usecases/get_current_bill_amount.dart';
|
||||
import 'package:billing/src/domain/usecases/get_invoice_history.dart';
|
||||
import 'package:billing/src/domain/usecases/get_pending_invoices.dart';
|
||||
import 'package:billing/src/domain/usecases/get_savings_amount.dart';
|
||||
import 'package:billing/src/domain/usecases/get_spending_breakdown.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
|
||||
/// BLoC for managing billing state and data loading.
|
||||
class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||
@@ -23,14 +23,14 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||
required GetSavingsAmountUseCase getSavingsAmount,
|
||||
required GetPendingInvoicesUseCase getPendingInvoices,
|
||||
required GetInvoiceHistoryUseCase getInvoiceHistory,
|
||||
required GetSpendingBreakdownUseCase getSpendingBreakdown,
|
||||
}) : _getBankAccounts = getBankAccounts,
|
||||
_getCurrentBillAmount = getCurrentBillAmount,
|
||||
_getSavingsAmount = getSavingsAmount,
|
||||
_getPendingInvoices = getPendingInvoices,
|
||||
_getInvoiceHistory = getInvoiceHistory,
|
||||
_getSpendingBreakdown = getSpendingBreakdown,
|
||||
super(const BillingState()) {
|
||||
required GetSpendBreakdownUseCase getSpendBreakdown,
|
||||
}) : _getBankAccounts = getBankAccounts,
|
||||
_getCurrentBillAmount = getCurrentBillAmount,
|
||||
_getSavingsAmount = getSavingsAmount,
|
||||
_getPendingInvoices = getPendingInvoices,
|
||||
_getInvoiceHistory = getInvoiceHistory,
|
||||
_getSpendBreakdown = getSpendBreakdown,
|
||||
super(const BillingState()) {
|
||||
on<BillingLoadStarted>(_onLoadStarted);
|
||||
on<BillingPeriodChanged>(_onPeriodChanged);
|
||||
}
|
||||
@@ -40,61 +40,60 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||
final GetSavingsAmountUseCase _getSavingsAmount;
|
||||
final GetPendingInvoicesUseCase _getPendingInvoices;
|
||||
final GetInvoiceHistoryUseCase _getInvoiceHistory;
|
||||
final GetSpendingBreakdownUseCase _getSpendingBreakdown;
|
||||
final GetSpendBreakdownUseCase _getSpendBreakdown;
|
||||
|
||||
/// Executes [loader] and returns null on failure, logging the error.
|
||||
Future<T?> _loadSafe<T>(Future<T> Function() loader) async {
|
||||
try {
|
||||
return await loader();
|
||||
} catch (e, stackTrace) {
|
||||
developer.log(
|
||||
'Partial billing load failed: $e',
|
||||
name: 'BillingBloc',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLoadStarted(
|
||||
BillingLoadStarted event,
|
||||
Emitter<BillingState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: BillingStatus.loading));
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final List<dynamic> results =
|
||||
await Future.wait<dynamic>(<Future<dynamic>>[
|
||||
_getCurrentBillAmount.call(),
|
||||
_getSavingsAmount.call(),
|
||||
_getPendingInvoices.call(),
|
||||
_getInvoiceHistory.call(),
|
||||
_getSpendingBreakdown.call(state.period),
|
||||
_getBankAccounts.call(),
|
||||
]);
|
||||
|
||||
final double savings = results[1] as double;
|
||||
final List<Invoice> pendingInvoices = results[2] as List<Invoice>;
|
||||
final List<Invoice> invoiceHistory = results[3] as List<Invoice>;
|
||||
final List<InvoiceItem> spendingItems = results[4] as List<InvoiceItem>;
|
||||
final List<BusinessBankAccount> bankAccounts =
|
||||
results[5] as List<BusinessBankAccount>;
|
||||
final SpendBreakdownParams spendParams = _dateRangeFor(state.periodTab);
|
||||
|
||||
// Map Domain Entities to Presentation Models
|
||||
final List<BillingInvoice> uiPendingInvoices = pendingInvoices
|
||||
.map(_mapInvoiceToUiModel)
|
||||
.toList();
|
||||
final List<BillingInvoice> uiInvoiceHistory = invoiceHistory
|
||||
.map(_mapInvoiceToUiModel)
|
||||
.toList();
|
||||
final List<SpendingBreakdownItem> uiSpendingBreakdown =
|
||||
_mapSpendingItemsToUiModel(spendingItems);
|
||||
final double periodTotal = uiSpendingBreakdown.fold(
|
||||
0.0,
|
||||
(double sum, SpendingBreakdownItem item) => sum + item.amount,
|
||||
);
|
||||
final List<Object?> results = await Future.wait<Object?>(
|
||||
<Future<Object?>>[
|
||||
_loadSafe<int>(() => _getCurrentBillAmount.call()),
|
||||
_loadSafe<int>(() => _getSavingsAmount.call()),
|
||||
_loadSafe<List<Invoice>>(() => _getPendingInvoices.call()),
|
||||
_loadSafe<List<Invoice>>(() => _getInvoiceHistory.call()),
|
||||
_loadSafe<List<SpendItem>>(() => _getSpendBreakdown.call(spendParams)),
|
||||
_loadSafe<List<BillingAccount>>(() => _getBankAccounts.call()),
|
||||
],
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: BillingStatus.success,
|
||||
currentBill: periodTotal,
|
||||
savings: savings,
|
||||
pendingInvoices: uiPendingInvoices,
|
||||
invoiceHistory: uiInvoiceHistory,
|
||||
spendingBreakdown: uiSpendingBreakdown,
|
||||
bankAccounts: bankAccounts,
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) =>
|
||||
state.copyWith(status: BillingStatus.failure, errorMessage: errorKey),
|
||||
final int? currentBillCents = results[0] as int?;
|
||||
final int? savingsCents = results[1] as int?;
|
||||
final List<Invoice>? pendingInvoices = results[2] as List<Invoice>?;
|
||||
final List<Invoice>? invoiceHistory = results[3] as List<Invoice>?;
|
||||
final List<SpendItem>? spendBreakdown = results[4] as List<SpendItem>?;
|
||||
final List<BillingAccount>? bankAccounts =
|
||||
results[5] as List<BillingAccount>?;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: BillingStatus.success,
|
||||
currentBillCents: currentBillCents ?? state.currentBillCents,
|
||||
savingsCents: savingsCents ?? state.savingsCents,
|
||||
pendingInvoices: pendingInvoices ?? state.pendingInvoices,
|
||||
invoiceHistory: invoiceHistory ?? state.invoiceHistory,
|
||||
spendBreakdown: spendBreakdown ?? state.spendBreakdown,
|
||||
bankAccounts: bankAccounts ?? state.bankAccounts,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,19 +104,15 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final List<InvoiceItem> spendingItems = await _getSpendingBreakdown
|
||||
.call(event.period);
|
||||
final List<SpendingBreakdownItem> uiSpendingBreakdown =
|
||||
_mapSpendingItemsToUiModel(spendingItems);
|
||||
final double periodTotal = uiSpendingBreakdown.fold(
|
||||
0.0,
|
||||
(double sum, SpendingBreakdownItem item) => sum + item.amount,
|
||||
);
|
||||
final SpendBreakdownParams params =
|
||||
_dateRangeFor(event.periodTab);
|
||||
final List<SpendItem> spendBreakdown =
|
||||
await _getSpendBreakdown.call(params);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
period: event.period,
|
||||
spendingBreakdown: uiSpendingBreakdown,
|
||||
currentBill: periodTotal,
|
||||
periodTab: event.periodTab,
|
||||
spendBreakdown: spendBreakdown,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -126,98 +121,14 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||
);
|
||||
}
|
||||
|
||||
BillingInvoice _mapInvoiceToUiModel(Invoice invoice) {
|
||||
final DateFormat formatter = DateFormat('EEEE, MMMM d');
|
||||
final String dateLabel = invoice.issueDate == null
|
||||
? 'N/A'
|
||||
: formatter.format(invoice.issueDate!);
|
||||
|
||||
final List<BillingWorkerRecord> workers = invoice.workers.map((
|
||||
InvoiceWorker w,
|
||||
) {
|
||||
final DateFormat timeFormat = DateFormat('h:mm a');
|
||||
return BillingWorkerRecord(
|
||||
workerName: w.name,
|
||||
roleName: w.role,
|
||||
totalAmount: w.amount,
|
||||
hours: w.hours,
|
||||
rate: w.rate,
|
||||
startTime: w.checkIn != null ? timeFormat.format(w.checkIn!) : '--:--',
|
||||
endTime: w.checkOut != null ? timeFormat.format(w.checkOut!) : '--:--',
|
||||
breakMinutes: w.breakMinutes,
|
||||
workerAvatarUrl: w.avatarUrl,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
String? overallStart;
|
||||
String? overallEnd;
|
||||
|
||||
// Find valid times from actual DateTime checks to ensure chronological sorting
|
||||
final List<DateTime> validCheckIns = invoice.workers
|
||||
.where((InvoiceWorker w) => w.checkIn != null)
|
||||
.map((InvoiceWorker w) => w.checkIn!)
|
||||
.toList();
|
||||
final List<DateTime> validCheckOuts = invoice.workers
|
||||
.where((InvoiceWorker w) => w.checkOut != null)
|
||||
.map((InvoiceWorker w) => w.checkOut!)
|
||||
.toList();
|
||||
|
||||
final DateFormat timeFormat = DateFormat('h:mm a');
|
||||
|
||||
if (validCheckIns.isNotEmpty) {
|
||||
validCheckIns.sort();
|
||||
overallStart = timeFormat.format(validCheckIns.first);
|
||||
} else if (workers.isNotEmpty) {
|
||||
overallStart = workers.first.startTime;
|
||||
}
|
||||
|
||||
if (validCheckOuts.isNotEmpty) {
|
||||
validCheckOuts.sort();
|
||||
overallEnd = timeFormat.format(validCheckOuts.last);
|
||||
} else if (workers.isNotEmpty) {
|
||||
overallEnd = workers.first.endTime;
|
||||
}
|
||||
|
||||
return BillingInvoice(
|
||||
id: invoice.id,
|
||||
title: invoice.title ?? 'N/A',
|
||||
locationAddress: invoice.locationAddress ?? 'Remote',
|
||||
clientName: invoice.clientName ?? 'N/A',
|
||||
date: dateLabel,
|
||||
totalAmount: invoice.totalAmount,
|
||||
workersCount: invoice.staffCount ?? 0,
|
||||
totalHours: invoice.totalHours ?? 0.0,
|
||||
status: invoice.status.name.toUpperCase(),
|
||||
workers: workers,
|
||||
startTime: overallStart,
|
||||
endTime: overallEnd,
|
||||
/// Computes ISO-8601 date range for the selected period tab.
|
||||
SpendBreakdownParams _dateRangeFor(BillingPeriodTab tab) {
|
||||
final DateTime now = DateTime.now().toUtc();
|
||||
final int days = tab == BillingPeriodTab.week ? 7 : 30;
|
||||
final DateTime start = now.subtract(Duration(days: days));
|
||||
return SpendBreakdownParams(
|
||||
startDate: start.toIso8601String(),
|
||||
endDate: now.toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
List<SpendingBreakdownItem> _mapSpendingItemsToUiModel(
|
||||
List<InvoiceItem> items,
|
||||
) {
|
||||
final Map<String, SpendingBreakdownItem> aggregation =
|
||||
<String, SpendingBreakdownItem>{};
|
||||
|
||||
for (final InvoiceItem item in items) {
|
||||
final String category = item.staffId;
|
||||
final SpendingBreakdownItem? existing = aggregation[category];
|
||||
if (existing != null) {
|
||||
aggregation[category] = SpendingBreakdownItem(
|
||||
category: category,
|
||||
hours: existing.hours + item.workHours.round(),
|
||||
amount: existing.amount + item.amount,
|
||||
);
|
||||
} else {
|
||||
aggregation[category] = SpendingBreakdownItem(
|
||||
category: category,
|
||||
hours: item.workHours.round(),
|
||||
amount: item.amount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return aggregation.values.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
|
||||
/// Base class for all billing events.
|
||||
abstract class BillingEvent extends Equatable {
|
||||
@@ -16,11 +17,14 @@ class BillingLoadStarted extends BillingEvent {
|
||||
const BillingLoadStarted();
|
||||
}
|
||||
|
||||
/// Event triggered when the spend breakdown period tab changes.
|
||||
class BillingPeriodChanged extends BillingEvent {
|
||||
const BillingPeriodChanged(this.period);
|
||||
/// Creates a [BillingPeriodChanged] event.
|
||||
const BillingPeriodChanged(this.periodTab);
|
||||
|
||||
final BillingPeriod period;
|
||||
/// The selected period tab.
|
||||
final BillingPeriodTab periodTab;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[period];
|
||||
List<Object?> get props => <Object?>[periodTab];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../models/billing_invoice_model.dart';
|
||||
import '../models/spending_breakdown_model.dart';
|
||||
|
||||
/// The loading status of the billing feature.
|
||||
enum BillingStatus {
|
||||
@@ -18,83 +16,104 @@ enum BillingStatus {
|
||||
failure,
|
||||
}
|
||||
|
||||
/// Which period the spend breakdown covers.
|
||||
enum BillingPeriodTab {
|
||||
/// Last 7 days.
|
||||
week,
|
||||
|
||||
/// Last 30 days.
|
||||
month,
|
||||
}
|
||||
|
||||
/// Represents the state of the billing feature.
|
||||
class BillingState extends Equatable {
|
||||
/// Creates a [BillingState].
|
||||
const BillingState({
|
||||
this.status = BillingStatus.initial,
|
||||
this.currentBill = 0.0,
|
||||
this.savings = 0.0,
|
||||
this.pendingInvoices = const <BillingInvoice>[],
|
||||
this.invoiceHistory = const <BillingInvoice>[],
|
||||
this.spendingBreakdown = const <SpendingBreakdownItem>[],
|
||||
this.bankAccounts = const <BusinessBankAccount>[],
|
||||
this.period = BillingPeriod.week,
|
||||
this.currentBillCents = 0,
|
||||
this.savingsCents = 0,
|
||||
this.pendingInvoices = const <Invoice>[],
|
||||
this.invoiceHistory = const <Invoice>[],
|
||||
this.spendBreakdown = const <SpendItem>[],
|
||||
this.bankAccounts = const <BillingAccount>[],
|
||||
this.periodTab = BillingPeriodTab.week,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
/// The current feature status.
|
||||
final BillingStatus status;
|
||||
|
||||
/// The total amount for the current billing period.
|
||||
final double currentBill;
|
||||
/// The total amount for the current billing period in cents.
|
||||
final int currentBillCents;
|
||||
|
||||
/// Total savings achieved compared to traditional agencies.
|
||||
final double savings;
|
||||
/// Total savings in cents.
|
||||
final int savingsCents;
|
||||
|
||||
/// Invoices awaiting client approval.
|
||||
final List<BillingInvoice> pendingInvoices;
|
||||
final List<Invoice> pendingInvoices;
|
||||
|
||||
/// History of paid invoices.
|
||||
final List<BillingInvoice> invoiceHistory;
|
||||
final List<Invoice> invoiceHistory;
|
||||
|
||||
/// Breakdown of spending by category.
|
||||
final List<SpendingBreakdownItem> spendingBreakdown;
|
||||
final List<SpendItem> spendBreakdown;
|
||||
|
||||
/// Bank accounts associated with the business.
|
||||
final List<BusinessBankAccount> bankAccounts;
|
||||
final List<BillingAccount> bankAccounts;
|
||||
|
||||
/// Selected period for the breakdown.
|
||||
final BillingPeriod period;
|
||||
/// Selected period tab for the breakdown.
|
||||
final BillingPeriodTab periodTab;
|
||||
|
||||
/// Error message if loading failed.
|
||||
final String? errorMessage;
|
||||
|
||||
/// Current bill formatted as dollars.
|
||||
double get currentBillDollars => currentBillCents / 100.0;
|
||||
|
||||
/// Savings formatted as dollars.
|
||||
double get savingsDollars => savingsCents / 100.0;
|
||||
|
||||
/// Total spend across the breakdown in cents.
|
||||
int get spendTotalCents => spendBreakdown.fold(
|
||||
0,
|
||||
(int sum, SpendItem item) => sum + item.amountCents,
|
||||
);
|
||||
|
||||
/// Creates a copy of this state with updated fields.
|
||||
BillingState copyWith({
|
||||
BillingStatus? status,
|
||||
double? currentBill,
|
||||
double? savings,
|
||||
List<BillingInvoice>? pendingInvoices,
|
||||
List<BillingInvoice>? invoiceHistory,
|
||||
List<SpendingBreakdownItem>? spendingBreakdown,
|
||||
List<BusinessBankAccount>? bankAccounts,
|
||||
BillingPeriod? period,
|
||||
int? currentBillCents,
|
||||
int? savingsCents,
|
||||
List<Invoice>? pendingInvoices,
|
||||
List<Invoice>? invoiceHistory,
|
||||
List<SpendItem>? spendBreakdown,
|
||||
List<BillingAccount>? bankAccounts,
|
||||
BillingPeriodTab? periodTab,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return BillingState(
|
||||
status: status ?? this.status,
|
||||
currentBill: currentBill ?? this.currentBill,
|
||||
savings: savings ?? this.savings,
|
||||
currentBillCents: currentBillCents ?? this.currentBillCents,
|
||||
savingsCents: savingsCents ?? this.savingsCents,
|
||||
pendingInvoices: pendingInvoices ?? this.pendingInvoices,
|
||||
invoiceHistory: invoiceHistory ?? this.invoiceHistory,
|
||||
spendingBreakdown: spendingBreakdown ?? this.spendingBreakdown,
|
||||
spendBreakdown: spendBreakdown ?? this.spendBreakdown,
|
||||
bankAccounts: bankAccounts ?? this.bankAccounts,
|
||||
period: period ?? this.period,
|
||||
periodTab: periodTab ?? this.periodTab,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
status,
|
||||
currentBill,
|
||||
savings,
|
||||
pendingInvoices,
|
||||
invoiceHistory,
|
||||
spendingBreakdown,
|
||||
bankAccounts,
|
||||
period,
|
||||
errorMessage,
|
||||
];
|
||||
status,
|
||||
currentBillCents,
|
||||
savingsCents,
|
||||
pendingInvoices,
|
||||
invoiceHistory,
|
||||
spendBreakdown,
|
||||
bankAccounts,
|
||||
periodTab,
|
||||
errorMessage,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../../../domain/usecases/approve_invoice.dart';
|
||||
import '../../../domain/usecases/dispute_invoice.dart';
|
||||
import 'shift_completion_review_event.dart';
|
||||
import 'shift_completion_review_state.dart';
|
||||
|
||||
import 'package:billing/src/domain/usecases/approve_invoice.dart';
|
||||
import 'package:billing/src/domain/usecases/dispute_invoice.dart';
|
||||
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_state.dart';
|
||||
|
||||
/// BLoC for approving or disputing an invoice from the review page.
|
||||
class ShiftCompletionReviewBloc
|
||||
extends Bloc<ShiftCompletionReviewEvent, ShiftCompletionReviewState>
|
||||
with BlocErrorHandler<ShiftCompletionReviewState> {
|
||||
/// Creates a [ShiftCompletionReviewBloc].
|
||||
ShiftCompletionReviewBloc({
|
||||
required ApproveInvoiceUseCase approveInvoice,
|
||||
required DisputeInvoiceUseCase disputeInvoice,
|
||||
}) : _approveInvoice = approveInvoice,
|
||||
_disputeInvoice = disputeInvoice,
|
||||
super(const ShiftCompletionReviewState()) {
|
||||
}) : _approveInvoice = approveInvoice,
|
||||
_disputeInvoice = disputeInvoice,
|
||||
super(const ShiftCompletionReviewState()) {
|
||||
on<ShiftCompletionReviewApproved>(_onApproved);
|
||||
on<ShiftCompletionReviewDisputed>(_onDisputed);
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class BillingInvoice extends Equatable {
|
||||
const BillingInvoice({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.locationAddress,
|
||||
required this.clientName,
|
||||
required this.date,
|
||||
required this.totalAmount,
|
||||
required this.workersCount,
|
||||
required this.totalHours,
|
||||
required this.status,
|
||||
this.workers = const <BillingWorkerRecord>[],
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String locationAddress;
|
||||
final String clientName;
|
||||
final String date;
|
||||
final double totalAmount;
|
||||
final int workersCount;
|
||||
final double totalHours;
|
||||
final String status;
|
||||
final List<BillingWorkerRecord> workers;
|
||||
final String? startTime;
|
||||
final String? endTime;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
title,
|
||||
locationAddress,
|
||||
clientName,
|
||||
date,
|
||||
totalAmount,
|
||||
workersCount,
|
||||
totalHours,
|
||||
status,
|
||||
workers,
|
||||
startTime,
|
||||
endTime,
|
||||
];
|
||||
}
|
||||
|
||||
class BillingWorkerRecord extends Equatable {
|
||||
const BillingWorkerRecord({
|
||||
required this.workerName,
|
||||
required this.roleName,
|
||||
required this.totalAmount,
|
||||
required this.hours,
|
||||
required this.rate,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.breakMinutes,
|
||||
this.workerAvatarUrl,
|
||||
});
|
||||
|
||||
final String workerName;
|
||||
final String roleName;
|
||||
final double totalAmount;
|
||||
final double hours;
|
||||
final double rate;
|
||||
final String startTime;
|
||||
final String endTime;
|
||||
final int breakMinutes;
|
||||
final String? workerAvatarUrl;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
workerName,
|
||||
roleName,
|
||||
totalAmount,
|
||||
hours,
|
||||
rate,
|
||||
startTime,
|
||||
endTime,
|
||||
breakMinutes,
|
||||
workerAvatarUrl,
|
||||
];
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Represents a single item in the spending breakdown.
|
||||
class SpendingBreakdownItem extends Equatable {
|
||||
/// Creates a [SpendingBreakdownItem].
|
||||
const SpendingBreakdownItem({
|
||||
required this.category,
|
||||
required this.hours,
|
||||
required this.amount,
|
||||
});
|
||||
|
||||
/// The category name (e.g., "Server Staff").
|
||||
final String category;
|
||||
|
||||
/// The total hours worked in this category.
|
||||
final int hours;
|
||||
|
||||
/// The total amount spent in this category.
|
||||
final double amount;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[category, hours, amount];
|
||||
}
|
||||
@@ -5,13 +5,13 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_event.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
import '../widgets/billing_page_skeleton.dart';
|
||||
import '../widgets/invoice_history_section.dart';
|
||||
import '../widgets/pending_invoices_section.dart';
|
||||
import '../widgets/spending_breakdown_card.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
import 'package:billing/src/presentation/widgets/billing_page_skeleton.dart';
|
||||
import 'package:billing/src/presentation/widgets/invoice_history_section.dart';
|
||||
import 'package:billing/src/presentation/widgets/pending_invoices_section.dart';
|
||||
import 'package:billing/src/presentation/widgets/spending_breakdown_card.dart';
|
||||
|
||||
/// The entry point page for the client billing feature.
|
||||
///
|
||||
@@ -32,8 +32,7 @@ class BillingPage extends StatelessWidget {
|
||||
|
||||
/// The main view for the client billing feature.
|
||||
///
|
||||
/// This widget displays the billing dashboard content based on the current
|
||||
/// state of the [BillingBloc].
|
||||
/// Displays the billing dashboard content based on the current [BillingState].
|
||||
class BillingView extends StatefulWidget {
|
||||
/// Creates a [BillingView].
|
||||
const BillingView({super.key});
|
||||
@@ -125,7 +124,7 @@ class _BillingViewState extends State<BillingView> {
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
'\$${state.currentBill.toStringAsFixed(2)}',
|
||||
'\$${state.currentBillDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.displayM.copyWith(
|
||||
color: UiColors.white,
|
||||
fontSize: 40,
|
||||
@@ -152,7 +151,8 @@ class _BillingViewState extends State<BillingView> {
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
t.client_billing.saved_amount(
|
||||
amount: state.savings.toStringAsFixed(0),
|
||||
amount: state.savingsDollars
|
||||
.toStringAsFixed(0),
|
||||
),
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: UiColors.accentForeground,
|
||||
@@ -221,7 +221,6 @@ class _BillingViewState extends State<BillingView> {
|
||||
if (state.pendingInvoices.isNotEmpty) ...<Widget>[
|
||||
PendingInvoicesSection(invoices: state.pendingInvoices),
|
||||
],
|
||||
// const PaymentMethodCard(),
|
||||
const SpendingBreakdownCard(),
|
||||
if (state.invoiceHistory.isNotEmpty)
|
||||
InvoiceHistorySection(invoices: state.invoiceHistory),
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../models/billing_invoice_model.dart';
|
||||
import '../widgets/completion_review/completion_review_actions.dart';
|
||||
import '../widgets/completion_review/completion_review_amount.dart';
|
||||
import '../widgets/completion_review/completion_review_info.dart';
|
||||
import '../widgets/completion_review/completion_review_search_and_tabs.dart';
|
||||
import '../widgets/completion_review/completion_review_worker_card.dart';
|
||||
import '../widgets/completion_review/completion_review_workers_header.dart';
|
||||
import 'package:billing/src/presentation/widgets/completion_review/completion_review_actions.dart';
|
||||
import 'package:billing/src/presentation/widgets/completion_review/completion_review_amount.dart';
|
||||
import 'package:billing/src/presentation/widgets/completion_review/completion_review_info.dart';
|
||||
|
||||
/// Page for reviewing and approving/disputing an invoice.
|
||||
class ShiftCompletionReviewPage extends StatefulWidget {
|
||||
/// Creates a [ShiftCompletionReviewPage].
|
||||
const ShiftCompletionReviewPage({this.invoice, super.key});
|
||||
|
||||
final BillingInvoice? invoice;
|
||||
/// The invoice to review.
|
||||
final Invoice? invoice;
|
||||
|
||||
@override
|
||||
State<ShiftCompletionReviewPage> createState() =>
|
||||
@@ -21,31 +23,45 @@ class ShiftCompletionReviewPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
||||
late BillingInvoice invoice;
|
||||
String searchQuery = '';
|
||||
int selectedTab = 0; // 0: Needs Review (mocked as empty), 1: All
|
||||
/// The resolved invoice, or null if route data is missing/invalid.
|
||||
late final Invoice? invoice;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Use widget.invoice if provided, else try to get from arguments
|
||||
invoice = widget.invoice ?? Modular.args.data as BillingInvoice;
|
||||
invoice = widget.invoice ??
|
||||
(Modular.args.data is Invoice
|
||||
? Modular.args.data as Invoice
|
||||
: null);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<BillingWorkerRecord> filteredWorkers = invoice.workers.where((
|
||||
BillingWorkerRecord w,
|
||||
) {
|
||||
if (searchQuery.isEmpty) return true;
|
||||
return w.workerName.toLowerCase().contains(searchQuery.toLowerCase()) ||
|
||||
w.roleName.toLowerCase().contains(searchQuery.toLowerCase());
|
||||
}).toList();
|
||||
final Invoice? resolvedInvoice = invoice;
|
||||
if (resolvedInvoice == null) {
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
title: t.client_billing.review_and_approve,
|
||||
showBackButton: true,
|
||||
),
|
||||
body: Center(
|
||||
child: Text(
|
||||
t.errors.generic.unknown,
|
||||
style: UiTypography.body1m.textError,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final DateFormat formatter = DateFormat('EEEE, MMMM d');
|
||||
final String dateLabel = resolvedInvoice.dueDate != null
|
||||
? formatter.format(resolvedInvoice.dueDate!)
|
||||
: 'N/A';
|
||||
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
title: invoice.title,
|
||||
subtitle: invoice.clientName,
|
||||
title: resolvedInvoice.invoiceNumber,
|
||||
subtitle: resolvedInvoice.vendorName ?? '',
|
||||
showBackButton: true,
|
||||
),
|
||||
body: SafeArea(
|
||||
@@ -55,26 +71,13 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
CompletionReviewInfo(invoice: invoice),
|
||||
CompletionReviewInfo(
|
||||
dateLabel: dateLabel,
|
||||
vendorName: resolvedInvoice.vendorName,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
CompletionReviewAmount(invoice: invoice),
|
||||
CompletionReviewAmount(amountCents: resolvedInvoice.amountCents),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
// CompletionReviewWorkersHeader(workersCount: invoice.workersCount),
|
||||
// const SizedBox(height: UiConstants.space4),
|
||||
// CompletionReviewSearchAndTabs(
|
||||
// selectedTab: selectedTab,
|
||||
// workersCount: invoice.workersCount,
|
||||
// onTabChanged: (int index) =>
|
||||
// setState(() => selectedTab = index),
|
||||
// onSearchChanged: (String val) =>
|
||||
// setState(() => searchQuery = val),
|
||||
// ),
|
||||
// const SizedBox(height: UiConstants.space4),
|
||||
// ...filteredWorkers.map(
|
||||
// (BillingWorkerRecord worker) =>
|
||||
// CompletionReviewWorkerCard(worker: worker),
|
||||
// ),
|
||||
// const SizedBox(height: UiConstants.space4),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -87,7 +90,9 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
||||
top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
|
||||
),
|
||||
),
|
||||
child: SafeArea(child: CompletionReviewActions(invoiceId: invoice.id)),
|
||||
child: SafeArea(
|
||||
child: CompletionReviewActions(invoiceId: resolvedInvoice.invoiceId),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,17 @@ import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_event.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
import '../models/billing_invoice_model.dart';
|
||||
import '../widgets/invoices_list_skeleton.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
import 'package:billing/src/presentation/widgets/invoices_list_skeleton.dart';
|
||||
|
||||
/// Page displaying invoices that are ready.
|
||||
class InvoiceReadyPage extends StatelessWidget {
|
||||
/// Creates an [InvoiceReadyPage].
|
||||
const InvoiceReadyPage({super.key});
|
||||
|
||||
@override
|
||||
@@ -21,7 +24,9 @@ class InvoiceReadyPage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// View for the invoice ready page.
|
||||
class InvoiceReadyView extends StatelessWidget {
|
||||
/// Creates an [InvoiceReadyView].
|
||||
const InvoiceReadyView({super.key});
|
||||
|
||||
@override
|
||||
@@ -60,7 +65,7 @@ class InvoiceReadyView extends StatelessWidget {
|
||||
separatorBuilder: (BuildContext context, int index) =>
|
||||
const SizedBox(height: 16),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final BillingInvoice invoice = state.invoiceHistory[index];
|
||||
final Invoice invoice = state.invoiceHistory[index];
|
||||
return _InvoiceSummaryCard(invoice: invoice);
|
||||
},
|
||||
);
|
||||
@@ -72,10 +77,17 @@ class InvoiceReadyView extends StatelessWidget {
|
||||
|
||||
class _InvoiceSummaryCard extends StatelessWidget {
|
||||
const _InvoiceSummaryCard({required this.invoice});
|
||||
final BillingInvoice invoice;
|
||||
|
||||
final Invoice invoice;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final DateFormat formatter = DateFormat('MMM d, yyyy');
|
||||
final String dateLabel = invoice.dueDate != null
|
||||
? formatter.format(invoice.dueDate!)
|
||||
: 'N/A';
|
||||
final double amountDollars = invoice.amountCents / 100.0;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
@@ -106,22 +118,26 @@ class _InvoiceSummaryCard extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'READY',
|
||||
invoice.status.value.toUpperCase(),
|
||||
style: UiTypography.titleUppercase4b.copyWith(
|
||||
color: UiColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(invoice.date, style: UiTypography.footnote2r.textTertiary),
|
||||
Text(dateLabel, style: UiTypography.footnote2r.textTertiary),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(invoice.title, style: UiTypography.title2b.textPrimary),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
invoice.locationAddress,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
invoice.invoiceNumber,
|
||||
style: UiTypography.title2b.textPrimary,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (invoice.vendorName != null)
|
||||
Text(
|
||||
invoice.vendorName!,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
@@ -134,7 +150,7 @@ class _InvoiceSummaryCard extends StatelessWidget {
|
||||
style: UiTypography.titleUppercase4m.textSecondary,
|
||||
),
|
||||
Text(
|
||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
||||
'\$${amountDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.title2b.primary,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -5,12 +5,14 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
import '../widgets/invoices_list_skeleton.dart';
|
||||
import '../widgets/pending_invoices_section.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
import 'package:billing/src/presentation/widgets/invoices_list_skeleton.dart';
|
||||
import 'package:billing/src/presentation/widgets/pending_invoices_section.dart';
|
||||
|
||||
/// Page listing all invoices awaiting client approval.
|
||||
class PendingInvoicesPage extends StatelessWidget {
|
||||
/// Creates a [PendingInvoicesPage].
|
||||
const PendingInvoicesPage({super.key});
|
||||
|
||||
@override
|
||||
@@ -44,7 +46,7 @@ class PendingInvoicesPage extends StatelessWidget {
|
||||
UiConstants.space5,
|
||||
UiConstants.space5,
|
||||
UiConstants.space5,
|
||||
100, // Bottom padding for scroll clearance
|
||||
100,
|
||||
),
|
||||
itemCount: state.pendingInvoices.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
@@ -87,6 +89,3 @@ class PendingInvoicesPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// We need to export the card widget from the section file if we want to reuse it,
|
||||
// or move it to its own file. I'll move it to a shared file or just make it public in the section file.
|
||||
|
||||
@@ -6,23 +6,26 @@ import 'package:flutter/material.dart';
|
||||
class BillingHeader extends StatelessWidget {
|
||||
/// Creates a [BillingHeader].
|
||||
const BillingHeader({
|
||||
required this.currentBill,
|
||||
required this.savings,
|
||||
required this.currentBillCents,
|
||||
required this.savingsCents,
|
||||
required this.onBack,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The amount of the current bill.
|
||||
final double currentBill;
|
||||
/// The amount of the current bill in cents.
|
||||
final int currentBillCents;
|
||||
|
||||
/// The amount saved in the current period.
|
||||
final double savings;
|
||||
/// The savings amount in cents.
|
||||
final int savingsCents;
|
||||
|
||||
/// Callback when the back button is pressed.
|
||||
final VoidCallback onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double billDollars = currentBillCents / 100.0;
|
||||
final double savingsDollars = savingsCents / 100.0;
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
UiConstants.space5,
|
||||
@@ -54,10 +57,9 @@ class BillingHeader extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
'\$${currentBill.toStringAsFixed(2)}',
|
||||
'\$${billDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.display1b.copyWith(color: UiColors.white),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -79,7 +81,7 @@ class BillingHeader extends StatelessWidget {
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
t.client_billing.saved_amount(
|
||||
amount: savings.toStringAsFixed(0),
|
||||
amount: savingsDollars.toStringAsFixed(0),
|
||||
),
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: UiColors.foreground,
|
||||
|
||||
@@ -5,87 +5,91 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../../blocs/shift_completion_review/shift_completion_review_bloc.dart';
|
||||
import '../../blocs/shift_completion_review/shift_completion_review_event.dart';
|
||||
import '../../blocs/shift_completion_review/shift_completion_review_state.dart';
|
||||
import '../../blocs/billing_bloc.dart';
|
||||
import '../../blocs/billing_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_state.dart';
|
||||
|
||||
/// Action buttons (approve / flag) at the bottom of the review page.
|
||||
class CompletionReviewActions extends StatelessWidget {
|
||||
/// Creates a [CompletionReviewActions].
|
||||
const CompletionReviewActions({required this.invoiceId, super.key});
|
||||
|
||||
/// The invoice ID to act upon.
|
||||
final String invoiceId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<ShiftCompletionReviewBloc>.value(
|
||||
value: Modular.get<ShiftCompletionReviewBloc>(),
|
||||
return BlocProvider<ShiftCompletionReviewBloc>(
|
||||
create: (_) => Modular.get<ShiftCompletionReviewBloc>(),
|
||||
child:
|
||||
BlocConsumer<ShiftCompletionReviewBloc, ShiftCompletionReviewState>(
|
||||
listener: (BuildContext context, ShiftCompletionReviewState state) {
|
||||
if (state.status == ShiftCompletionReviewStatus.success) {
|
||||
final String message = state.message == 'approved'
|
||||
? t.client_billing.approved_success
|
||||
: t.client_billing.flagged_success;
|
||||
final UiSnackbarType type = state.message == 'approved'
|
||||
? UiSnackbarType.success
|
||||
: UiSnackbarType.warning;
|
||||
listener: (BuildContext context, ShiftCompletionReviewState state) {
|
||||
if (state.status == ShiftCompletionReviewStatus.success) {
|
||||
final String message = state.message == 'approved'
|
||||
? t.client_billing.approved_success
|
||||
: t.client_billing.flagged_success;
|
||||
final UiSnackbarType type = state.message == 'approved'
|
||||
? UiSnackbarType.success
|
||||
: UiSnackbarType.warning;
|
||||
|
||||
UiSnackbar.show(context, message: message, type: type);
|
||||
Modular.get<BillingBloc>().add(const BillingLoadStarted());
|
||||
Modular.to.toAwaitingApproval();
|
||||
} else if (state.status == ShiftCompletionReviewStatus.failure) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: state.errorMessage ?? t.errors.generic.unknown,
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, ShiftCompletionReviewState state) {
|
||||
final bool isLoading =
|
||||
state.status == ShiftCompletionReviewStatus.loading;
|
||||
UiSnackbar.show(context, message: message, type: type);
|
||||
Modular.get<BillingBloc>().add(const BillingLoadStarted());
|
||||
Modular.to.toAwaitingApproval();
|
||||
} else if (state.status == ShiftCompletionReviewStatus.failure) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: state.errorMessage ?? t.errors.generic.unknown,
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, ShiftCompletionReviewState state) {
|
||||
final bool isLoading =
|
||||
state.status == ShiftCompletionReviewStatus.loading;
|
||||
|
||||
return Row(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: UiButton.secondary(
|
||||
text: t.client_billing.actions.flag_review,
|
||||
leadingIcon: UiIcons.warning,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => _showFlagDialog(context, state),
|
||||
size: UiButtonSize.large,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: UiColors.destructive,
|
||||
side: BorderSide.none,
|
||||
),
|
||||
),
|
||||
return Row(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: UiButton.secondary(
|
||||
text: t.client_billing.actions.flag_review,
|
||||
leadingIcon: UiIcons.warning,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => _showFlagDialog(context, state),
|
||||
size: UiButtonSize.large,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: UiColors.destructive,
|
||||
side: BorderSide.none,
|
||||
),
|
||||
Expanded(
|
||||
child: UiButton.primary(
|
||||
text: t.client_billing.actions.approve_pay,
|
||||
leadingIcon: isLoading ? null : UiIcons.checkCircle,
|
||||
isLoading: isLoading,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () {
|
||||
BlocProvider.of<ShiftCompletionReviewBloc>(
|
||||
context,
|
||||
).add(ShiftCompletionReviewApproved(invoiceId));
|
||||
},
|
||||
size: UiButtonSize.large,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: UiButton.primary(
|
||||
text: t.client_billing.actions.approve_pay,
|
||||
leadingIcon: isLoading ? null : UiIcons.checkCircle,
|
||||
isLoading: isLoading,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () {
|
||||
BlocProvider.of<ShiftCompletionReviewBloc>(
|
||||
context,
|
||||
).add(ShiftCompletionReviewApproved(invoiceId));
|
||||
},
|
||||
size: UiButtonSize.large,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFlagDialog(BuildContext context, ShiftCompletionReviewState state) {
|
||||
void _showFlagDialog(
|
||||
BuildContext context, ShiftCompletionReviewState state) {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
|
||||
@@ -2,15 +2,18 @@ import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/billing_invoice_model.dart';
|
||||
|
||||
/// Displays the total invoice amount on the review page.
|
||||
class CompletionReviewAmount extends StatelessWidget {
|
||||
const CompletionReviewAmount({required this.invoice, super.key});
|
||||
/// Creates a [CompletionReviewAmount].
|
||||
const CompletionReviewAmount({required this.amountCents, super.key});
|
||||
|
||||
final BillingInvoice invoice;
|
||||
/// The invoice total in cents.
|
||||
final int amountCents;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double amountDollars = amountCents / 100.0;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
@@ -27,13 +30,9 @@ class CompletionReviewAmount extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
||||
'\$${amountDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.headline1b.textPrimary.copyWith(fontSize: 40),
|
||||
),
|
||||
Text(
|
||||
'${invoice.totalHours.toStringAsFixed(1)} ${t.client_billing.hours_suffix} • \$${(invoice.totalAmount / (invoice.totalHours > 0.1 ? invoice.totalHours : 1)).toStringAsFixed(2)}${t.client_billing.avg_rate_suffix}',
|
||||
style: UiTypography.footnote2b.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/billing_invoice_model.dart';
|
||||
|
||||
/// Displays invoice metadata (date, vendor) on the review page.
|
||||
class CompletionReviewInfo extends StatelessWidget {
|
||||
const CompletionReviewInfo({required this.invoice, super.key});
|
||||
/// Creates a [CompletionReviewInfo].
|
||||
const CompletionReviewInfo({
|
||||
required this.dateLabel,
|
||||
this.vendorName,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final BillingInvoice invoice;
|
||||
/// Formatted date string.
|
||||
final String dateLabel;
|
||||
|
||||
/// Vendor name, if available.
|
||||
final String? vendorName;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -14,12 +22,9 @@ class CompletionReviewInfo extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
_buildInfoRow(UiIcons.calendar, invoice.date),
|
||||
_buildInfoRow(
|
||||
UiIcons.clock,
|
||||
'${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}',
|
||||
),
|
||||
_buildInfoRow(UiIcons.mapPin, invoice.locationAddress),
|
||||
_buildInfoRow(UiIcons.calendar, dateLabel),
|
||||
if (vendorName != null)
|
||||
_buildInfoRow(UiIcons.building, vendorName!),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,126 +1,18 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/billing_invoice_model.dart';
|
||||
|
||||
/// Card showing a single worker's details in the completion review.
|
||||
///
|
||||
/// Currently unused -- the V2 Invoice entity does not include per-worker
|
||||
/// breakdown data. This widget is retained as a placeholder for when the
|
||||
/// backend adds worker-level invoice detail endpoints.
|
||||
class CompletionReviewWorkerCard extends StatelessWidget {
|
||||
const CompletionReviewWorkerCard({required this.worker, super.key});
|
||||
|
||||
final BillingWorkerRecord worker;
|
||||
/// Creates a [CompletionReviewWorkerCard].
|
||||
const CompletionReviewWorkerCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: UiColors.bgSecondary,
|
||||
backgroundImage: worker.workerAvatarUrl != null
|
||||
? NetworkImage(worker.workerAvatarUrl!)
|
||||
: null,
|
||||
child: worker.workerAvatarUrl == null
|
||||
? const Icon(
|
||||
UiIcons.user,
|
||||
size: 20,
|
||||
color: UiColors.iconSecondary,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
worker.workerName,
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
Text(
|
||||
worker.roleName,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'\$${worker.totalAmount.toStringAsFixed(2)}',
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
Text(
|
||||
'${worker.hours}h x \$${worker.rate.toStringAsFixed(2)}/hr',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Text(
|
||||
'${worker.startTime} - ${worker.endTime}',
|
||||
style: UiTypography.footnote2b.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.coffee,
|
||||
size: 12,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${worker.breakMinutes} ${t.client_billing.workers_tab.min_break}',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
UiIconButton.secondary(icon: UiIcons.edit, onTap: () {}),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
UiIconButton.secondary(icon: UiIcons.warning, onTap: () {}),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
// Placeholder until V2 API provides worker-level invoice data.
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/billing_invoice_model.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Section showing the history of paid invoices.
|
||||
class InvoiceHistorySection extends StatelessWidget {
|
||||
@@ -9,7 +10,7 @@ class InvoiceHistorySection extends StatelessWidget {
|
||||
const InvoiceHistorySection({required this.invoices, super.key});
|
||||
|
||||
/// The list of historical invoices.
|
||||
final List<BillingInvoice> invoices;
|
||||
final List<Invoice> invoices;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -36,10 +37,10 @@ class InvoiceHistorySection extends StatelessWidget {
|
||||
),
|
||||
child: Column(
|
||||
children: invoices.asMap().entries.map((
|
||||
MapEntry<int, BillingInvoice> entry,
|
||||
MapEntry<int, Invoice> entry,
|
||||
) {
|
||||
final int index = entry.key;
|
||||
final BillingInvoice invoice = entry.value;
|
||||
final Invoice invoice = entry.value;
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
if (index > 0)
|
||||
@@ -58,10 +59,18 @@ class InvoiceHistorySection extends StatelessWidget {
|
||||
class _InvoiceItem extends StatelessWidget {
|
||||
const _InvoiceItem({required this.invoice});
|
||||
|
||||
final BillingInvoice invoice;
|
||||
final Invoice invoice;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final DateFormat formatter = DateFormat('MMM d, yyyy');
|
||||
final String dateLabel = invoice.paymentDate != null
|
||||
? formatter.format(invoice.paymentDate!)
|
||||
: invoice.dueDate != null
|
||||
? formatter.format(invoice.dueDate!)
|
||||
: 'N/A';
|
||||
final double amountDollars = invoice.amountCents / 100.0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
@@ -86,11 +95,11 @@ class _InvoiceItem extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(invoice.title, style: UiTypography.body1r.textPrimary),
|
||||
Text(
|
||||
invoice.date,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
invoice.invoiceNumber,
|
||||
style: UiTypography.body1r.textPrimary,
|
||||
),
|
||||
Text(dateLabel, style: UiTypography.footnote2r.textSecondary),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -98,7 +107,7 @@ class _InvoiceItem extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
||||
'\$${amountDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15),
|
||||
),
|
||||
_StatusBadge(status: invoice.status),
|
||||
@@ -113,11 +122,11 @@ class _InvoiceItem extends StatelessWidget {
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.status});
|
||||
|
||||
final String status;
|
||||
final InvoiceStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isPaid = status.toUpperCase() == 'PAID';
|
||||
final bool isPaid = status == InvoiceStatus.paid;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space1 + 2,
|
||||
|
||||
@@ -3,8 +3,9 @@ import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
|
||||
/// Card showing the current payment method.
|
||||
class PaymentMethodCard extends StatelessWidget {
|
||||
@@ -15,8 +16,8 @@ class PaymentMethodCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<BillingBloc, BillingState>(
|
||||
builder: (BuildContext context, BillingState state) {
|
||||
final List<BusinessBankAccount> accounts = state.bankAccounts;
|
||||
final BusinessBankAccount? account =
|
||||
final List<BillingAccount> accounts = state.bankAccounts;
|
||||
final BillingAccount? account =
|
||||
accounts.isNotEmpty ? accounts.first : null;
|
||||
|
||||
if (account == null) {
|
||||
@@ -24,11 +25,10 @@ class PaymentMethodCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
final String bankLabel =
|
||||
account.bankName.isNotEmpty == true ? account.bankName : '----';
|
||||
account.bankName.isNotEmpty ? account.bankName : '----';
|
||||
final String last4 =
|
||||
account.last4.isNotEmpty == true ? account.last4 : '----';
|
||||
account.last4?.isNotEmpty == true ? account.last4! : '----';
|
||||
final bool isPrimary = account.isPrimary;
|
||||
final String expiryLabel = _formatExpiry(account.expiryTime);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
@@ -87,11 +87,11 @@ class PaymentMethodCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'•••• $last4',
|
||||
'\u2022\u2022\u2022\u2022 $last4',
|
||||
style: UiTypography.body2b.textPrimary,
|
||||
),
|
||||
Text(
|
||||
t.client_billing.expires(date: expiryLabel),
|
||||
account.accountType.name.toUpperCase(),
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
@@ -121,13 +121,4 @@ class PaymentMethodCard extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _formatExpiry(DateTime? expiryTime) {
|
||||
if (expiryTime == null) {
|
||||
return 'N/A';
|
||||
}
|
||||
final String month = expiryTime.month.toString().padLeft(2, '0');
|
||||
final String year = (expiryTime.year % 100).toString().padLeft(2, '0');
|
||||
return '$month/$year';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../models/billing_invoice_model.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Section showing a banner for invoices awaiting approval.
|
||||
class PendingInvoicesSection extends StatelessWidget {
|
||||
@@ -12,7 +12,7 @@ class PendingInvoicesSection extends StatelessWidget {
|
||||
const PendingInvoicesSection({required this.invoices, super.key});
|
||||
|
||||
/// The list of pending invoices.
|
||||
final List<BillingInvoice> invoices;
|
||||
final List<Invoice> invoices;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -93,10 +93,17 @@ class PendingInvoiceCard extends StatelessWidget {
|
||||
/// Creates a [PendingInvoiceCard].
|
||||
const PendingInvoiceCard({required this.invoice, super.key});
|
||||
|
||||
final BillingInvoice invoice;
|
||||
/// The invoice to display.
|
||||
final Invoice invoice;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final DateFormat formatter = DateFormat('EEEE, MMMM d');
|
||||
final String dateLabel = invoice.dueDate != null
|
||||
? formatter.format(invoice.dueDate!)
|
||||
: 'N/A';
|
||||
final double amountDollars = invoice.amountCents / 100.0;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
@@ -108,42 +115,33 @@ class PendingInvoiceCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(invoice.title, style: UiTypography.headline4b.textPrimary),
|
||||
Text(
|
||||
invoice.invoiceNumber,
|
||||
style: UiTypography.headline4b.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: 16,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: Text(
|
||||
invoice.locationAddress,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
if (invoice.vendorName != null) ...<Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.building,
|
||||
size: 16,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
invoice.clientName,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text('•', style: UiTypography.footnote2r.textInactive),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
invoice.date,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: Text(
|
||||
invoice.vendorName!,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
],
|
||||
Text(dateLabel, style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
@@ -157,7 +155,7 @@ class PendingInvoiceCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
t.client_billing.pending_badge.toUpperCase(),
|
||||
invoice.status.value.toUpperCase(),
|
||||
style: UiTypography.titleUppercase4b.copyWith(
|
||||
color: UiColors.textWarning,
|
||||
),
|
||||
@@ -168,40 +166,10 @@ class PendingInvoiceCard extends StatelessWidget {
|
||||
const Divider(height: 1, color: UiColors.border),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
UiIcons.dollar,
|
||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
||||
t.client_billing.stats.total,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 32,
|
||||
color: UiColors.border.withValues(alpha: 0.3),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
UiIcons.users,
|
||||
'${invoice.workersCount}',
|
||||
t.client_billing.stats.workers,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 32,
|
||||
color: UiColors.border.withValues(alpha: 0.3),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
UiIcons.clock,
|
||||
invoice.totalHours.toStringAsFixed(1),
|
||||
t.client_billing.stats.hrs,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: _buildStatItem(
|
||||
UiIcons.dollar,
|
||||
'\$${amountDollars.toStringAsFixed(2)}',
|
||||
t.client_billing.stats.total,
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, color: UiColors.border),
|
||||
|
||||
@@ -3,10 +3,10 @@ import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
import '../blocs/billing_event.dart';
|
||||
import '../models/spending_breakdown_model.dart';
|
||||
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
|
||||
/// Card showing the spending breakdown for the current period.
|
||||
class SpendingBreakdownCard extends StatefulWidget {
|
||||
@@ -37,10 +37,7 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<BillingBloc, BillingState>(
|
||||
builder: (BuildContext context, BillingState state) {
|
||||
final double total = state.spendingBreakdown.fold(
|
||||
0.0,
|
||||
(double sum, SpendingBreakdownItem item) => sum + item.amount,
|
||||
);
|
||||
final double totalDollars = state.spendTotalCents / 100.0;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
@@ -97,11 +94,12 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
||||
),
|
||||
dividerColor: UiColors.transparent,
|
||||
onTap: (int index) {
|
||||
final BillingPeriod period =
|
||||
index == 0 ? BillingPeriod.week : BillingPeriod.month;
|
||||
ReadContext(context).read<BillingBloc>().add(
|
||||
BillingPeriodChanged(period),
|
||||
);
|
||||
final BillingPeriodTab tab = index == 0
|
||||
? BillingPeriodTab.week
|
||||
: BillingPeriodTab.month;
|
||||
ReadContext(context)
|
||||
.read<BillingBloc>()
|
||||
.add(BillingPeriodChanged(tab));
|
||||
},
|
||||
tabs: <Widget>[
|
||||
Tab(text: t.client_billing.week),
|
||||
@@ -112,8 +110,8 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
...state.spendingBreakdown.map(
|
||||
(SpendingBreakdownItem item) => _buildBreakdownRow(item),
|
||||
...state.spendBreakdown.map(
|
||||
(SpendItem item) => _buildBreakdownRow(item),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: UiConstants.space2),
|
||||
@@ -127,7 +125,7 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
||||
style: UiTypography.body2b.textPrimary,
|
||||
),
|
||||
Text(
|
||||
'\$${total.toStringAsFixed(2)}',
|
||||
'\$${totalDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.body2b.textPrimary,
|
||||
),
|
||||
],
|
||||
@@ -139,7 +137,8 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBreakdownRow(SpendingBreakdownItem item) {
|
||||
Widget _buildBreakdownRow(SpendItem item) {
|
||||
final double amountDollars = item.amountCents / 100.0;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space2),
|
||||
child: Row(
|
||||
@@ -151,14 +150,14 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
||||
children: <Widget>[
|
||||
Text(item.category, style: UiTypography.body2r.textPrimary),
|
||||
Text(
|
||||
t.client_billing.hours(count: item.hours),
|
||||
'${item.percentage.toStringAsFixed(1)}%',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'\$${item.amount.toStringAsFixed(2)}',
|
||||
'\$${amountDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -10,12 +10,12 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
|
||||
# Architecture
|
||||
flutter_modular: ^6.3.2
|
||||
flutter_bloc: ^8.1.3
|
||||
equatable: ^2.0.5
|
||||
|
||||
|
||||
# Shared packages
|
||||
design_system:
|
||||
path: ../../../design_system
|
||||
@@ -25,12 +25,10 @@ dependencies:
|
||||
path: ../../../domain
|
||||
krow_core:
|
||||
path: ../../../core
|
||||
krow_data_connect:
|
||||
path: ../../../data_connect
|
||||
|
||||
|
||||
# UI
|
||||
intl: ^0.20.0
|
||||
firebase_data_connect: ^0.2.2+1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'data/repositories_impl/coverage_repository_impl.dart';
|
||||
import 'domain/repositories/coverage_repository.dart';
|
||||
import 'domain/usecases/get_coverage_stats_usecase.dart';
|
||||
import 'domain/usecases/get_shifts_for_date_usecase.dart';
|
||||
import 'presentation/blocs/coverage_bloc.dart';
|
||||
import 'presentation/pages/coverage_page.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:client_coverage/src/data/repositories_impl/coverage_repository_impl.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
import 'package:client_coverage/src/domain/usecases/cancel_late_worker_usecase.dart';
|
||||
import 'package:client_coverage/src/domain/usecases/get_coverage_stats_usecase.dart';
|
||||
import 'package:client_coverage/src/domain/usecases/get_shifts_for_date_usecase.dart';
|
||||
import 'package:client_coverage/src/domain/usecases/submit_worker_review_usecase.dart';
|
||||
import 'package:client_coverage/src/presentation/blocs/coverage_bloc.dart';
|
||||
import 'package:client_coverage/src/presentation/pages/coverage_page.dart';
|
||||
|
||||
/// Modular module for the coverage feature.
|
||||
///
|
||||
/// Uses the V2 REST API via [BaseApiService] for all backend access.
|
||||
class CoverageModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[DataConnectModule()];
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<CoverageRepository>(CoverageRepositoryImpl.new);
|
||||
i.addLazySingleton<CoverageRepository>(
|
||||
() => CoverageRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||
);
|
||||
|
||||
// Use Cases
|
||||
i.addLazySingleton(GetShiftsForDateUseCase.new);
|
||||
i.addLazySingleton(GetCoverageStatsUseCase.new);
|
||||
i.addLazySingleton(SubmitWorkerReviewUseCase.new);
|
||||
i.addLazySingleton(CancelLateWorkerUseCase.new);
|
||||
|
||||
// BLoCs
|
||||
i.addLazySingleton<CoverageBloc>(CoverageBloc.new);
|
||||
@@ -28,7 +37,9 @@ class CoverageModule extends Module {
|
||||
|
||||
@override
|
||||
void routes(RouteManager r) {
|
||||
r.child(ClientPaths.childRoute(ClientPaths.coverage, ClientPaths.coverage),
|
||||
child: (_) => const CoveragePage());
|
||||
r.child(
|
||||
ClientPaths.childRoute(ClientPaths.coverage, ClientPaths.coverage),
|
||||
child: (_) => const CoveragePage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +1,89 @@
|
||||
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/coverage_repository.dart';
|
||||
|
||||
/// Implementation of [CoverageRepository] that delegates to [dc.CoverageConnectorRepository].
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
|
||||
/// V2 API implementation of [CoverageRepository].
|
||||
///
|
||||
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
|
||||
/// connector repository from the data_connect package.
|
||||
/// Uses [BaseApiService] with [V2ApiEndpoints] for all backend access.
|
||||
class CoverageRepositoryImpl implements CoverageRepository {
|
||||
/// Creates a [CoverageRepositoryImpl].
|
||||
CoverageRepositoryImpl({required BaseApiService apiService})
|
||||
: _apiService = apiService;
|
||||
|
||||
CoverageRepositoryImpl({
|
||||
dc.CoverageConnectorRepository? connectorRepository,
|
||||
dc.DataConnectService? service,
|
||||
}) : _connectorRepository = connectorRepository ??
|
||||
dc.DataConnectService.instance.getCoverageRepository(),
|
||||
_service = service ?? dc.DataConnectService.instance;
|
||||
final dc.CoverageConnectorRepository _connectorRepository;
|
||||
final dc.DataConnectService _service;
|
||||
final BaseApiService _apiService;
|
||||
|
||||
@override
|
||||
Future<List<CoverageShift>> getShiftsForDate({required DateTime date}) async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.getShiftsForDate(
|
||||
businessId: businessId,
|
||||
date: date,
|
||||
Future<List<ShiftWithWorkers>> getShiftsForDate({
|
||||
required DateTime date,
|
||||
}) async {
|
||||
final String dateStr =
|
||||
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
final ApiResponse response = await _apiService.get(
|
||||
V2ApiEndpoints.clientCoverage,
|
||||
params: <String, dynamic>{'date': dateStr},
|
||||
);
|
||||
final List<dynamic> items = response.data['items'] as List<dynamic>;
|
||||
return items
|
||||
.map((dynamic e) =>
|
||||
ShiftWithWorkers.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CoverageStats> getCoverageStats({required DateTime date}) async {
|
||||
final List<CoverageShift> shifts = await getShiftsForDate(date: date);
|
||||
|
||||
final int totalNeeded = shifts.fold<int>(
|
||||
0,
|
||||
(int sum, CoverageShift shift) => sum + shift.workersNeeded,
|
||||
final String dateStr =
|
||||
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
final ApiResponse response = await _apiService.get(
|
||||
V2ApiEndpoints.clientCoverageStats,
|
||||
params: <String, dynamic>{'date': dateStr},
|
||||
);
|
||||
return CoverageStats.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
final List<CoverageWorker> allWorkers =
|
||||
shifts.expand((CoverageShift shift) => shift.workers).toList();
|
||||
final int totalConfirmed = allWorkers.length;
|
||||
final int checkedIn = allWorkers
|
||||
.where((CoverageWorker w) => w.status == CoverageWorkerStatus.checkedIn)
|
||||
.length;
|
||||
final int enRoute = allWorkers
|
||||
.where((CoverageWorker w) =>
|
||||
w.status == CoverageWorkerStatus.confirmed && w.checkInTime == null)
|
||||
.length;
|
||||
final int late = allWorkers
|
||||
.where((CoverageWorker w) => w.status == CoverageWorkerStatus.late)
|
||||
.length;
|
||||
@override
|
||||
Future<void> submitWorkerReview({
|
||||
required String staffId,
|
||||
required int rating,
|
||||
String? assignmentId,
|
||||
String? feedback,
|
||||
List<String>? issueFlags,
|
||||
bool? markAsFavorite,
|
||||
}) async {
|
||||
final Map<String, dynamic> body = <String, dynamic>{
|
||||
'staffId': staffId,
|
||||
'rating': rating,
|
||||
};
|
||||
if (assignmentId != null) {
|
||||
body['assignmentId'] = assignmentId;
|
||||
}
|
||||
if (feedback != null) {
|
||||
body['feedback'] = feedback;
|
||||
}
|
||||
if (issueFlags != null && issueFlags.isNotEmpty) {
|
||||
body['issueFlags'] = issueFlags;
|
||||
}
|
||||
if (markAsFavorite != null) {
|
||||
body['markAsFavorite'] = markAsFavorite;
|
||||
}
|
||||
await _apiService.post(
|
||||
V2ApiEndpoints.clientCoverageReviews,
|
||||
data: body,
|
||||
);
|
||||
}
|
||||
|
||||
return CoverageStats(
|
||||
totalNeeded: totalNeeded,
|
||||
totalConfirmed: totalConfirmed,
|
||||
checkedIn: checkedIn,
|
||||
enRoute: enRoute,
|
||||
late: late,
|
||||
@override
|
||||
Future<void> cancelLateWorker({
|
||||
required String assignmentId,
|
||||
String? reason,
|
||||
}) async {
|
||||
final Map<String, dynamic> body = <String, dynamic>{};
|
||||
if (reason != null) {
|
||||
body['reason'] = reason;
|
||||
}
|
||||
await _apiService.post(
|
||||
V2ApiEndpoints.clientCoverageCancelLateWorker(assignmentId),
|
||||
data: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Arguments for cancelling a late worker's assignment.
|
||||
class CancelLateWorkerArguments extends UseCaseArgument {
|
||||
/// Creates [CancelLateWorkerArguments].
|
||||
const CancelLateWorkerArguments({
|
||||
required this.assignmentId,
|
||||
this.reason,
|
||||
});
|
||||
|
||||
/// The assignment ID to cancel.
|
||||
final String assignmentId;
|
||||
|
||||
/// Optional cancellation reason.
|
||||
final String? reason;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[assignmentId, reason];
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Arguments for fetching coverage statistics for a specific date.
|
||||
///
|
||||
/// This argument class encapsulates the date parameter required by
|
||||
/// the [GetCoverageStatsUseCase].
|
||||
class GetCoverageStatsArguments extends UseCaseArgument {
|
||||
/// Creates [GetCoverageStatsArguments].
|
||||
const GetCoverageStatsArguments({required this.date});
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Arguments for fetching shifts for a specific date.
|
||||
///
|
||||
/// This argument class encapsulates the date parameter required by
|
||||
/// the [GetShiftsForDateUseCase].
|
||||
class GetShiftsForDateArguments extends UseCaseArgument {
|
||||
/// Creates [GetShiftsForDateArguments].
|
||||
const GetShiftsForDateArguments({required this.date});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Arguments for submitting a worker review from the coverage page.
|
||||
class SubmitWorkerReviewArguments extends UseCaseArgument {
|
||||
/// Creates [SubmitWorkerReviewArguments].
|
||||
const SubmitWorkerReviewArguments({
|
||||
required this.staffId,
|
||||
required this.rating,
|
||||
this.assignmentId,
|
||||
this.feedback,
|
||||
this.issueFlags,
|
||||
this.markAsFavorite,
|
||||
});
|
||||
|
||||
/// The ID of the worker being reviewed.
|
||||
final String staffId;
|
||||
|
||||
/// The rating value (1-5).
|
||||
final int rating;
|
||||
|
||||
/// The assignment ID, if reviewing for a specific assignment.
|
||||
final String? assignmentId;
|
||||
|
||||
/// Optional text feedback.
|
||||
final String? feedback;
|
||||
|
||||
/// Optional list of issue flag labels.
|
||||
final List<String>? issueFlags;
|
||||
|
||||
/// Whether to mark/unmark the worker as a favorite.
|
||||
final bool? markAsFavorite;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
staffId,
|
||||
rating,
|
||||
assignmentId,
|
||||
feedback,
|
||||
issueFlags,
|
||||
markAsFavorite,
|
||||
];
|
||||
}
|
||||
@@ -2,22 +2,35 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for coverage-related operations.
|
||||
///
|
||||
/// This interface defines the contract for accessing coverage data,
|
||||
/// Defines the contract for accessing coverage data via the V2 REST API,
|
||||
/// acting as a boundary between the Domain and Data layers.
|
||||
/// It allows the Domain layer to remain independent of specific data sources.
|
||||
///
|
||||
/// Implementation of this interface must delegate all data access through
|
||||
/// the `packages/data_connect` layer, ensuring compliance with Clean Architecture.
|
||||
abstract interface class CoverageRepository {
|
||||
/// Fetches shifts for a specific date.
|
||||
///
|
||||
/// Returns a list of [CoverageShift] entities representing all shifts
|
||||
/// scheduled for the given [date].
|
||||
Future<List<CoverageShift>> getShiftsForDate({required DateTime date});
|
||||
/// Fetches shifts with assigned workers for a specific [date].
|
||||
Future<List<ShiftWithWorkers>> getShiftsForDate({required DateTime date});
|
||||
|
||||
/// Fetches coverage statistics for a specific date.
|
||||
///
|
||||
/// Returns [CoverageStats] containing aggregated metrics including
|
||||
/// total workers needed, confirmed, checked in, en route, and late.
|
||||
/// Fetches aggregated coverage statistics for a specific [date].
|
||||
Future<CoverageStats> getCoverageStats({required DateTime date});
|
||||
|
||||
/// Submits a worker review from the coverage page.
|
||||
///
|
||||
/// [staffId] identifies the worker being reviewed.
|
||||
/// [rating] is an integer from 1 to 5.
|
||||
/// Optional fields: [assignmentId], [feedback], [issueFlags], [markAsFavorite].
|
||||
Future<void> submitWorkerReview({
|
||||
required String staffId,
|
||||
required int rating,
|
||||
String? assignmentId,
|
||||
String? feedback,
|
||||
List<String>? issueFlags,
|
||||
bool? markAsFavorite,
|
||||
});
|
||||
|
||||
/// Cancels a late worker's assignment.
|
||||
///
|
||||
/// [assignmentId] identifies the assignment to cancel.
|
||||
/// [reason] is an optional cancellation reason.
|
||||
Future<void> cancelLateWorker({
|
||||
required String assignmentId,
|
||||
String? reason,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'package:client_coverage/src/domain/arguments/cancel_late_worker_arguments.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
|
||||
/// Use case for cancelling a late worker's assignment.
|
||||
///
|
||||
/// Delegates to [CoverageRepository] to cancel the assignment via V2 API.
|
||||
class CancelLateWorkerUseCase
|
||||
implements UseCase<CancelLateWorkerArguments, void> {
|
||||
/// Creates a [CancelLateWorkerUseCase].
|
||||
CancelLateWorkerUseCase(this._repository);
|
||||
|
||||
final CoverageRepository _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(CancelLateWorkerArguments arguments) {
|
||||
return _repository.cancelLateWorker(
|
||||
assignmentId: arguments.assignmentId,
|
||||
reason: arguments.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,12 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../arguments/get_coverage_stats_arguments.dart';
|
||||
import '../repositories/coverage_repository.dart';
|
||||
import 'package:client_coverage/src/domain/arguments/get_coverage_stats_arguments.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
|
||||
/// Use case for fetching coverage statistics for a specific date.
|
||||
/// Use case for fetching aggregated coverage statistics for a specific date.
|
||||
///
|
||||
/// This use case encapsulates the logic for retrieving coverage metrics including
|
||||
/// total workers needed, confirmed, checked in, en route, and late.
|
||||
/// It delegates the data retrieval to the [CoverageRepository].
|
||||
///
|
||||
/// Follows the KROW Clean Architecture pattern by:
|
||||
/// - Extending from [UseCase] base class
|
||||
/// - Using [GetCoverageStatsArguments] for input
|
||||
/// - Returning domain entities ([CoverageStats])
|
||||
/// - Delegating to repository abstraction
|
||||
/// Delegates to [CoverageRepository] and returns a [CoverageStats] entity.
|
||||
class GetCoverageStatsUseCase
|
||||
implements UseCase<GetCoverageStatsArguments, CoverageStats> {
|
||||
/// Creates a [GetCoverageStatsUseCase].
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../arguments/get_shifts_for_date_arguments.dart';
|
||||
import '../repositories/coverage_repository.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Use case for fetching shifts for a specific date.
|
||||
import 'package:client_coverage/src/domain/arguments/get_shifts_for_date_arguments.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
|
||||
/// Use case for fetching shifts with workers for a specific date.
|
||||
///
|
||||
/// This use case encapsulates the logic for retrieving all shifts scheduled for a given date.
|
||||
/// It delegates the data retrieval to the [CoverageRepository].
|
||||
///
|
||||
/// Follows the KROW Clean Architecture pattern by:
|
||||
/// - Extending from [UseCase] base class
|
||||
/// - Using [GetShiftsForDateArguments] for input
|
||||
/// - Returning domain entities ([CoverageShift])
|
||||
/// - Delegating to repository abstraction
|
||||
/// Delegates to [CoverageRepository] and returns V2 [ShiftWithWorkers] entities.
|
||||
class GetShiftsForDateUseCase
|
||||
implements UseCase<GetShiftsForDateArguments, List<CoverageShift>> {
|
||||
implements UseCase<GetShiftsForDateArguments, List<ShiftWithWorkers>> {
|
||||
/// Creates a [GetShiftsForDateUseCase].
|
||||
GetShiftsForDateUseCase(this._repository);
|
||||
|
||||
final CoverageRepository _repository;
|
||||
|
||||
@override
|
||||
Future<List<CoverageShift>> call(GetShiftsForDateArguments arguments) {
|
||||
Future<List<ShiftWithWorkers>> call(GetShiftsForDateArguments arguments) {
|
||||
return _repository.getShiftsForDate(date: arguments.date);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'package:client_coverage/src/domain/arguments/submit_worker_review_arguments.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
|
||||
/// Use case for submitting a worker review from the coverage page.
|
||||
///
|
||||
/// Validates the rating range and delegates to [CoverageRepository].
|
||||
class SubmitWorkerReviewUseCase
|
||||
implements UseCase<SubmitWorkerReviewArguments, void> {
|
||||
/// Creates a [SubmitWorkerReviewUseCase].
|
||||
SubmitWorkerReviewUseCase(this._repository);
|
||||
|
||||
final CoverageRepository _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(SubmitWorkerReviewArguments arguments) async {
|
||||
if (arguments.rating < 1 || arguments.rating > 5) {
|
||||
throw ArgumentError('Rating must be between 1 and 5');
|
||||
}
|
||||
return _repository.submitWorkerReview(
|
||||
staffId: arguments.staffId,
|
||||
rating: arguments.rating,
|
||||
assignmentId: arguments.assignmentId,
|
||||
feedback: arguments.feedback,
|
||||
issueFlags: arguments.issueFlags,
|
||||
markAsFavorite: arguments.markAsFavorite,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user