diff --git a/.claude/agent-memory/mobile-feature-builder/MEMORY.md b/.claude/agent-memory/mobile-feature-builder/MEMORY.md new file mode 100644 index 00000000..f386504c --- /dev/null +++ b/.claude/agent-memory/mobile-feature-builder/MEMORY.md @@ -0,0 +1,3 @@ +# Mobile Feature Builder Memory Index + +- [firebase_auth_isolation.md](firebase_auth_isolation.md) - FirebaseAuthService in core abstracts all Firebase Auth operations; features must never import firebase_auth directly diff --git a/.claude/agent-memory/mobile-feature-builder/firebase_auth_isolation.md b/.claude/agent-memory/mobile-feature-builder/firebase_auth_isolation.md new file mode 100644 index 00000000..5f289095 --- /dev/null +++ b/.claude/agent-memory/mobile-feature-builder/firebase_auth_isolation.md @@ -0,0 +1,15 @@ +--- +name: Firebase Auth Isolation Pattern +description: FirebaseAuthService in core/lib/src/services/auth/ abstracts all Firebase Auth SDK operations so feature packages never import firebase_auth directly +type: project +--- + +`FirebaseAuthService` (interface) and `FirebaseAuthServiceImpl` live in `core/lib/src/services/auth/firebase_auth_service.dart`. + +Registered in `CoreModule` as `i.addLazySingleton(FirebaseAuthServiceImpl.new)`. + +Exported from `core.dart`. + +**Why:** Architecture rule requires firebase_auth only in core. Features inject `FirebaseAuthService` via DI. + +**How to apply:** Any new feature needing Firebase Auth operations (sign-in, sign-out, phone verification, get current user info) should depend on `FirebaseAuthService` from `krow_core`, not import `firebase_auth` directly. The service provides: `authStateChanges`, `currentUserPhoneNumber`, `currentUserUid`, `verifyPhoneNumber`, `signInWithPhoneCredential` (returns `PhoneSignInResult`), `signInWithEmailAndPassword`, `signOut`, `getIdToken`. diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index da88a048..57be3d54 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -44,6 +44,7 @@ export 'src/services/session/v2_session_service.dart'; // Auth export 'src/services/auth/auth_token_provider.dart'; +export 'src/services/auth/firebase_auth_service.dart'; // Device Services export 'src/services/device/camera/camera_service.dart'; diff --git a/apps/mobile/packages/core/lib/src/core_module.dart b/apps/mobile/packages/core/lib/src/core_module.dart index 529852c0..7499a553 100644 --- a/apps/mobile/packages/core/lib/src/core_module.dart +++ b/apps/mobile/packages/core/lib/src/core_module.dart @@ -4,6 +4,7 @@ import 'package:image_picker/image_picker.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:krow_core/src/services/auth/auth_token_provider.dart'; +import 'package:krow_core/src/services/auth/firebase_auth_service.dart'; import 'package:krow_core/src/services/auth/firebase_auth_token_provider.dart'; import '../core.dart'; @@ -63,7 +64,10 @@ class CoreModule extends Module { // 6. Auth Token Provider i.addLazySingleton(FirebaseAuthTokenProvider.new); - // 7. Register Geofence Device Services + // 7. Firebase Auth Service (so features never import firebase_auth) + i.addLazySingleton(FirebaseAuthServiceImpl.new); + + // 8. Register Geofence Device Services i.addLazySingleton(() => const LocationService()); i.addLazySingleton(() => NotificationService()); i.addLazySingleton(() => StorageService()); diff --git a/apps/mobile/packages/core/lib/src/services/auth/firebase_auth_service.dart b/apps/mobile/packages/core/lib/src/services/auth/firebase_auth_service.dart new file mode 100644 index 00000000..66063def --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/auth/firebase_auth_service.dart @@ -0,0 +1,258 @@ +import 'dart:async'; + +import 'package:firebase_auth/firebase_auth.dart' as firebase; + +import 'package:krow_domain/krow_domain.dart' + show + InvalidCredentialsException, + NetworkException, + SignInFailedException, + User, + UserStatus; + +/// Abstraction over Firebase Auth client-side operations. +/// +/// Provides phone-based and email-based authentication, sign-out, +/// auth state observation, and current user queries. Lives in core +/// so feature packages never import `firebase_auth` directly. +abstract interface class FirebaseAuthService { + /// Stream of the currently signed-in user mapped to a domain [User]. + /// + /// Emits `null` when the user signs out. + Stream get authStateChanges; + + /// Returns the current user's phone number, or `null` if unavailable. + String? get currentUserPhoneNumber; + + /// Returns the current user's UID, or `null` if not signed in. + String? get currentUserUid; + + /// Initiates phone number verification via Firebase Auth SDK. + /// + /// Returns a [Future] that completes with the verification ID when + /// the SMS code is sent. The [onAutoVerified] callback fires if the + /// device auto-retrieves the credential (Android only). + Future verifyPhoneNumber({ + required String phoneNumber, + void Function()? onAutoVerified, + }); + + /// Cancels any pending phone verification request. + void cancelPendingPhoneVerification(); + + /// Signs in with a phone auth credential built from + /// [verificationId] and [smsCode]. + /// + /// Returns the signed-in domain [User] or throws a domain exception. + Future signInWithPhoneCredential({ + required String verificationId, + required String smsCode, + }); + + /// Signs in with email and password via Firebase Auth SDK. + /// + /// Returns the Firebase UID on success or throws a domain exception. + Future signInWithEmailAndPassword({ + required String email, + required String password, + }); + + /// Signs out the current user from Firebase Auth locally. + Future signOut(); + + /// Returns the current user's Firebase ID token. + /// + /// Returns `null` if no user is signed in. + Future getIdToken(); +} + +/// Result of a phone credential sign-in. +/// +/// Contains the Firebase user's UID, phone number, and ID token +/// so the caller can proceed with V2 API verification without +/// importing `firebase_auth`. +class PhoneSignInResult { + /// Creates a [PhoneSignInResult]. + const PhoneSignInResult({ + required this.uid, + required this.phoneNumber, + required this.idToken, + }); + + /// The Firebase user UID. + final String uid; + + /// The phone number associated with the credential. + final String? phoneNumber; + + /// The Firebase ID token for the signed-in user. + final String? idToken; +} + +/// Firebase-backed implementation of [FirebaseAuthService]. +/// +/// Wraps the `firebase_auth` package so that feature packages +/// interact with Firebase Auth only through this core service. +class FirebaseAuthServiceImpl implements FirebaseAuthService { + /// Creates a [FirebaseAuthServiceImpl]. + /// + /// Optionally accepts a [firebase.FirebaseAuth] instance for testing. + FirebaseAuthServiceImpl({firebase.FirebaseAuth? auth}) + : _auth = auth ?? firebase.FirebaseAuth.instance; + + /// The Firebase Auth instance. + final firebase.FirebaseAuth _auth; + + /// Completer for the pending phone verification request. + Completer? _pendingVerification; + + @override + Stream get authStateChanges => + _auth.authStateChanges().map((firebase.User? firebaseUser) { + if (firebaseUser == null) { + return null; + } + return User( + id: firebaseUser.uid, + email: firebaseUser.email, + displayName: firebaseUser.displayName, + phone: firebaseUser.phoneNumber, + status: UserStatus.active, + ); + }); + + @override + String? get currentUserPhoneNumber => _auth.currentUser?.phoneNumber; + + @override + String? get currentUserUid => _auth.currentUser?.uid; + + @override + Future verifyPhoneNumber({ + required String phoneNumber, + void Function()? onAutoVerified, + }) async { + final Completer completer = Completer(); + _pendingVerification = completer; + + await _auth.verifyPhoneNumber( + phoneNumber: phoneNumber, + verificationCompleted: (firebase.PhoneAuthCredential credential) { + onAutoVerified?.call(); + }, + verificationFailed: (firebase.FirebaseAuthException e) { + if (!completer.isCompleted) { + if (e.code == 'network-request-failed' || + e.message?.contains('Unable to resolve host') == true) { + completer.completeError( + const NetworkException( + technicalMessage: 'Auth network failure', + ), + ); + } else { + completer.completeError( + SignInFailedException( + technicalMessage: 'Firebase ${e.code}: ${e.message}', + ), + ); + } + } + }, + codeSent: (String verificationId, _) { + if (!completer.isCompleted) { + completer.complete(verificationId); + } + }, + codeAutoRetrievalTimeout: (String verificationId) { + if (!completer.isCompleted) { + completer.complete(verificationId); + } + }, + ); + + return completer.future; + } + + @override + void cancelPendingPhoneVerification() { + final Completer? completer = _pendingVerification; + if (completer != null && !completer.isCompleted) { + completer.completeError(Exception('Phone verification cancelled.')); + } + _pendingVerification = null; + } + + @override + Future signInWithPhoneCredential({ + required String verificationId, + required String smsCode, + }) async { + final firebase.PhoneAuthCredential credential = + firebase.PhoneAuthProvider.credential( + verificationId: verificationId, + smsCode: smsCode, + ); + + final firebase.UserCredential userCredential; + try { + userCredential = await _auth.signInWithCredential(credential); + } on firebase.FirebaseAuthException catch (e) { + if (e.code == 'invalid-verification-code') { + throw const InvalidCredentialsException( + technicalMessage: 'Invalid OTP code entered.', + ); + } + rethrow; + } + + final firebase.User? firebaseUser = userCredential.user; + if (firebaseUser == null) { + throw const SignInFailedException( + technicalMessage: + 'Phone verification failed, no Firebase user received.', + ); + } + + final String? idToken = await firebaseUser.getIdToken(); + if (idToken == null) { + throw const SignInFailedException( + technicalMessage: 'Failed to obtain Firebase ID token.', + ); + } + + return PhoneSignInResult( + uid: firebaseUser.uid, + phoneNumber: firebaseUser.phoneNumber, + idToken: idToken, + ); + } + + @override + Future signInWithEmailAndPassword({ + required String email, + required String password, + }) async { + final firebase.UserCredential credential = + await _auth.signInWithEmailAndPassword(email: email, password: password); + + final firebase.User? firebaseUser = credential.user; + if (firebaseUser == null) { + throw const SignInFailedException( + technicalMessage: 'Local Firebase sign-in returned null user.', + ); + } + + return firebaseUser.uid; + } + + @override + Future signOut() async { + await _auth.signOut(); + } + + @override + Future getIdToken() async { + final firebase.User? user = _auth.currentUser; + return user?.getIdToken(); + } +} diff --git a/apps/mobile/packages/core/lib/src/utils/time_utils.dart b/apps/mobile/packages/core/lib/src/utils/time_utils.dart index 7340753c..0f5b7d8c 100644 --- a/apps/mobile/packages/core/lib/src/utils/time_utils.dart +++ b/apps/mobile/packages/core/lib/src/utils/time_utils.dart @@ -1,5 +1,45 @@ import 'package:intl/intl.dart'; +/// Converts a break duration label (e.g. `'MIN_30'`) to its value in minutes. +/// +/// Recognised labels: `MIN_10`, `MIN_15`, `MIN_30`, `MIN_45`, `MIN_60`. +/// Returns `0` for any unrecognised value (including `'NO_BREAK'`). +int breakMinutesFromLabel(String label) { + switch (label) { + case 'MIN_10': + return 10; + case 'MIN_15': + return 15; + case 'MIN_30': + return 30; + case 'MIN_45': + return 45; + case 'MIN_60': + return 60; + default: + return 0; + } +} + +/// Formats a [DateTime] to a `yyyy-MM-dd` date string. +/// +/// Example: `DateTime(2026, 3, 5)` -> `'2026-03-05'`. +String formatDateToIso(DateTime date) { + return '${date.year.toString().padLeft(4, '0')}-' + '${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}'; +} + +/// Formats a [DateTime] to `HH:mm` (24-hour) time string. +/// +/// Converts to local time before formatting. +/// Example: a UTC DateTime of 14:30 in UTC-5 -> `'09:30'`. +String formatTimeHHmm(DateTime dt) { + final DateTime local = dt.toLocal(); + return '${local.hour.toString().padLeft(2, '0')}:' + '${local.minute.toString().padLeft(2, '0')}'; +} + /// Formats a time string (ISO 8601 or HH:mm) into 12-hour format /// (e.g. "9:00 AM"). /// diff --git a/apps/mobile/packages/features/client/authentication/lib/client_authentication.dart b/apps/mobile/packages/features/client/authentication/lib/client_authentication.dart index 9a7a5670..d54e17bd 100644 --- a/apps/mobile/packages/features/client/authentication/lib/client_authentication.dart +++ b/apps/mobile/packages/features/client/authentication/lib/client_authentication.dart @@ -33,7 +33,10 @@ class ClientAuthenticationModule extends Module { void binds(Injector i) { // Repositories i.addLazySingleton( - () => AuthRepositoryImpl(apiService: i.get()), + () => AuthRepositoryImpl( + apiService: i.get(), + firebaseAuthService: i.get(), + ), ); // UseCases diff --git a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index 09dcb87f..a09a78fc 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -1,7 +1,6 @@ import 'dart:developer' as developer; import 'package:client_authentication/src/domain/repositories/auth_repository_interface.dart'; -import 'package:firebase_auth/firebase_auth.dart' as firebase; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart' show @@ -20,20 +19,23 @@ import 'package:krow_domain/krow_domain.dart' /// Production implementation of the [AuthRepositoryInterface] for the client app. /// -/// 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. +/// Uses [FirebaseAuthService] from core for local Firebase 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 given [BaseApiService]. - AuthRepositoryImpl({required BaseApiService apiService}) - : _apiService = apiService; + /// Creates an [AuthRepositoryImpl] with the given dependencies. + AuthRepositoryImpl({ + required BaseApiService apiService, + required FirebaseAuthService firebaseAuthService, + }) : _apiService = apiService, + _firebaseAuthService = firebaseAuthService; /// 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; + /// Core Firebase Auth service abstraction. + final FirebaseAuthService _firebaseAuthService; @override Future signInWithEmail({ @@ -41,7 +43,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { required String password, }) async { try { - // Step 1: Call V2 sign-in endpoint — server handles Firebase Auth + // 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( AuthEndpoints.clientSignIn, @@ -53,19 +55,14 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // 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: 'Local Firebase sign-in failed after V2 sign-in', - ); - } + await _firebaseAuthService.signInWithEmailAndPassword( + email: email, + password: password, + ); // 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); + return _populateStoreFromAuthEnvelope(body, email); } on AppException { rethrow; } catch (e) { @@ -98,35 +95,34 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // 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) { + try { + await _firebaseAuthService.signInWithEmailAndPassword( + email: email, + password: password, + ); + } on SignInFailedException { throw const SignUpFailedException( technicalMessage: 'Local Firebase sign-in failed after V2 sign-up', ); } // Step 3: Populate store from the sign-up response envelope. - return _populateStoreFromAuthEnvelope(body, firebaseUser, email); - } on firebase.FirebaseAuthException catch (e) { - 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 { - throw SignUpFailedException( - technicalMessage: 'Firebase auth error: ${e.message}', - ); - } + return _populateStoreFromAuthEnvelope(body, email); } on AppException { rethrow; } catch (e) { + // Map common Firebase-originated errors from the V2 API response + // to domain exceptions. + final String errorMessage = e.toString(); + if (errorMessage.contains('EMAIL_EXISTS') || + errorMessage.contains('email-already-in-use')) { + throw AccountExistsException(technicalMessage: errorMessage); + } else if (errorMessage.contains('WEAK_PASSWORD') || + errorMessage.contains('weak-password')) { + throw WeakPasswordException(technicalMessage: errorMessage); + } else if (errorMessage.contains('network-request-failed')) { + throw NetworkException(technicalMessage: errorMessage); + } throw SignUpFailedException(technicalMessage: 'Unexpected error: $e'); } } @@ -149,8 +145,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { } try { - // Step 2: Sign out from local Firebase Auth. - await _auth.signOut(); + // Step 2: Sign out from local Firebase Auth via core service. + await _firebaseAuthService.signOut(); } catch (e) { throw Exception('Error signing out locally: $e'); } @@ -167,7 +163,6 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { /// returns a domain [User]. User _populateStoreFromAuthEnvelope( Map envelope, - firebase.User firebaseUser, String fallbackEmail, ) { final Map? userJson = @@ -194,7 +189,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { ClientSessionStore.instance.setSession(clientSession); } - final String userId = userJson?['id'] as String? ?? firebaseUser.uid; + final String userId = userJson?['id'] as String? ?? + (_firebaseAuthService.currentUserUid ?? ''); final String email = userJson?['email'] as String? ?? fallbackEmail; return User( diff --git a/apps/mobile/packages/features/client/authentication/pubspec.yaml b/apps/mobile/packages/features/client/authentication/pubspec.yaml index 4db9ded0..cfe77594 100644 --- a/apps/mobile/packages/features/client/authentication/pubspec.yaml +++ b/apps/mobile/packages/features/client/authentication/pubspec.yaml @@ -14,7 +14,6 @@ dependencies: flutter_bloc: ^8.1.0 flutter_modular: ^6.3.0 equatable: ^2.0.5 - firebase_auth: ^6.1.2 # Architecture Packages design_system: diff --git a/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart b/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart index 02a4bb6c..052648ea 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart @@ -3,7 +3,7 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.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/repositories/billing_repository_interface.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'; @@ -29,8 +29,8 @@ class BillingModule extends Module { @override void binds(Injector i) { // Repositories - i.addLazySingleton( - () => BillingRepositoryImpl(apiService: i.get()), + i.addLazySingleton( + () => BillingRepositoryInterfaceImpl(apiService: i.get()), ); // Use Cases diff --git a/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart b/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart index 64027bd7..ba3dd517 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart @@ -1,14 +1,14 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:billing/src/domain/repositories/billing_repository.dart'; +import 'package:billing/src/domain/repositories/billing_repository_interface.dart'; -/// Implementation of [BillingRepository] using the V2 REST API. +/// Implementation of [BillingRepositoryInterface] using the V2 REST API. /// /// All backend calls go through [BaseApiService] with [ClientEndpoints]. -class BillingRepositoryImpl implements BillingRepository { - /// Creates a [BillingRepositoryImpl]. - BillingRepositoryImpl({required BaseApiService apiService}) +class BillingRepositoryInterfaceImpl implements BillingRepositoryInterface { + /// Creates a [BillingRepositoryInterfaceImpl]. + BillingRepositoryInterfaceImpl({required BaseApiService apiService}) : _apiService = apiService; /// The API service used for all HTTP requests. diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository_interface.dart similarity index 96% rename from apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart rename to apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository_interface.dart index 4a229926..53f98c0e 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository_interface.dart @@ -5,7 +5,7 @@ import 'package:krow_domain/krow_domain.dart'; /// This interface defines the contract for accessing billing-related data, /// acting as a boundary between the Domain and Data layers. /// It allows the Domain layer to remain independent of specific data sources. -abstract class BillingRepository { +abstract class BillingRepositoryInterface { /// Fetches bank accounts associated with the business. Future> getBankAccounts(); diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/approve_invoice.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/approve_invoice.dart index 7da6b1e0..5c3c6575 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/approve_invoice.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/approve_invoice.dart @@ -1,6 +1,6 @@ import 'package:krow_core/core.dart'; -import 'package:billing/src/domain/repositories/billing_repository.dart'; +import 'package:billing/src/domain/repositories/billing_repository_interface.dart'; /// Use case for approving an invoice. class ApproveInvoiceUseCase extends UseCase { @@ -8,7 +8,7 @@ class ApproveInvoiceUseCase extends UseCase { ApproveInvoiceUseCase(this._repository); /// The billing repository. - final BillingRepository _repository; + final BillingRepositoryInterface _repository; @override Future call(String input) => _repository.approveInvoice(input); diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/dispute_invoice.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/dispute_invoice.dart index baac7e47..b1bc7979 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/dispute_invoice.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/dispute_invoice.dart @@ -1,6 +1,6 @@ import 'package:krow_core/core.dart'; -import 'package:billing/src/domain/repositories/billing_repository.dart'; +import 'package:billing/src/domain/repositories/billing_repository_interface.dart'; /// Params for [DisputeInvoiceUseCase]. class DisputeInvoiceParams { @@ -20,7 +20,7 @@ class DisputeInvoiceUseCase extends UseCase { DisputeInvoiceUseCase(this._repository); /// The billing repository. - final BillingRepository _repository; + final BillingRepositoryInterface _repository; @override Future call(DisputeInvoiceParams input) => diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_bank_accounts.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_bank_accounts.dart index 39ffba24..5cc64584 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_bank_accounts.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_bank_accounts.dart @@ -1,7 +1,7 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:billing/src/domain/repositories/billing_repository.dart'; +import 'package:billing/src/domain/repositories/billing_repository_interface.dart'; /// Use case for fetching the bank accounts associated with the business. class GetBankAccountsUseCase extends NoInputUseCase> { @@ -9,7 +9,7 @@ class GetBankAccountsUseCase extends NoInputUseCase> { GetBankAccountsUseCase(this._repository); /// The billing repository. - final BillingRepository _repository; + final BillingRepositoryInterface _repository; @override Future> call() => _repository.getBankAccounts(); diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_current_bill_amount.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_current_bill_amount.dart index 39f4737b..9a7e5543 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_current_bill_amount.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_current_bill_amount.dart @@ -1,16 +1,16 @@ import 'package:krow_core/core.dart'; -import 'package:billing/src/domain/repositories/billing_repository.dart'; +import 'package:billing/src/domain/repositories/billing_repository_interface.dart'; /// Use case for fetching the current bill amount in cents. /// -/// Delegates data retrieval to the [BillingRepository]. +/// Delegates data retrieval to the [BillingRepositoryInterface]. class GetCurrentBillAmountUseCase extends NoInputUseCase { /// Creates a [GetCurrentBillAmountUseCase]. GetCurrentBillAmountUseCase(this._repository); /// The billing repository. - final BillingRepository _repository; + final BillingRepositoryInterface _repository; @override Future call() => _repository.getCurrentBillCents(); diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_invoice_history.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_invoice_history.dart index ab84cf5d..a156ef6f 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_invoice_history.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_invoice_history.dart @@ -1,7 +1,7 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:billing/src/domain/repositories/billing_repository.dart'; +import 'package:billing/src/domain/repositories/billing_repository_interface.dart'; /// Use case for fetching the invoice history. /// @@ -11,7 +11,7 @@ class GetInvoiceHistoryUseCase extends NoInputUseCase> { GetInvoiceHistoryUseCase(this._repository); /// The billing repository. - final BillingRepository _repository; + final BillingRepositoryInterface _repository; @override Future> call() => _repository.getInvoiceHistory(); diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_pending_invoices.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_pending_invoices.dart index fb8a7e9d..ea5fed85 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_pending_invoices.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_pending_invoices.dart @@ -1,7 +1,7 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:billing/src/domain/repositories/billing_repository.dart'; +import 'package:billing/src/domain/repositories/billing_repository_interface.dart'; /// Use case for fetching the pending invoices. /// @@ -11,7 +11,7 @@ class GetPendingInvoicesUseCase extends NoInputUseCase> { GetPendingInvoicesUseCase(this._repository); /// The billing repository. - final BillingRepository _repository; + final BillingRepositoryInterface _repository; @override Future> call() => _repository.getPendingInvoices(); diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_savings_amount.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_savings_amount.dart index baedf222..68b622ae 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_savings_amount.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_savings_amount.dart @@ -1,16 +1,16 @@ import 'package:krow_core/core.dart'; -import 'package:billing/src/domain/repositories/billing_repository.dart'; +import 'package:billing/src/domain/repositories/billing_repository_interface.dart'; /// Use case for fetching the savings amount in cents. /// -/// Delegates data retrieval to the [BillingRepository]. +/// Delegates data retrieval to the [BillingRepositoryInterface]. class GetSavingsAmountUseCase extends NoInputUseCase { /// Creates a [GetSavingsAmountUseCase]. GetSavingsAmountUseCase(this._repository); /// The billing repository. - final BillingRepository _repository; + final BillingRepositoryInterface _repository; @override Future call() => _repository.getSavingsCents(); diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_spending_breakdown.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_spending_breakdown.dart index 0e01534a..4a244818 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_spending_breakdown.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_spending_breakdown.dart @@ -1,7 +1,7 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:billing/src/domain/repositories/billing_repository.dart'; +import 'package:billing/src/domain/repositories/billing_repository_interface.dart'; /// Parameters for [GetSpendBreakdownUseCase]. class SpendBreakdownParams { @@ -20,14 +20,14 @@ class SpendBreakdownParams { /// Use case for fetching the spending breakdown by category. /// -/// Delegates data retrieval to the [BillingRepository]. +/// Delegates data retrieval to the [BillingRepositoryInterface]. class GetSpendBreakdownUseCase extends UseCase> { /// Creates a [GetSpendBreakdownUseCase]. GetSpendBreakdownUseCase(this._repository); /// The billing repository. - final BillingRepository _repository; + final BillingRepositoryInterface _repository; @override Future> call(SpendBreakdownParams input) => diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart index 3543571a..0f5aa09a 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart @@ -1,5 +1,3 @@ -import 'dart:developer' as developer; - import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -14,6 +12,9 @@ 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. +/// +/// Fetches billing summary data (current bill, savings, invoices, +/// spend breakdown, bank accounts) and manages period tab selection. class BillingBloc extends Bloc with BlocErrorHandler { /// Creates a [BillingBloc] with the given use cases. @@ -35,64 +36,97 @@ class BillingBloc extends Bloc on(_onPeriodChanged); } + /// Use case for fetching bank accounts. final GetBankAccountsUseCase _getBankAccounts; + + /// Use case for fetching the current bill amount. final GetCurrentBillAmountUseCase _getCurrentBillAmount; + + /// Use case for fetching the savings amount. final GetSavingsAmountUseCase _getSavingsAmount; + + /// Use case for fetching pending invoices. final GetPendingInvoicesUseCase _getPendingInvoices; + + /// Use case for fetching invoice history. final GetInvoiceHistoryUseCase _getInvoiceHistory; + + /// Use case for fetching spending breakdown. final GetSpendBreakdownUseCase _getSpendBreakdown; - /// Executes [loader] and returns null on failure, logging the error. - Future _loadSafe(Future 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; - } - } - + /// Loads all billing data concurrently. + /// + /// Uses [handleError] to surface errors to the UI via state + /// instead of silently swallowing them. Individual data fetches + /// use [handleErrorWithResult] so partial failures populate + /// with defaults rather than failing the entire load. Future _onLoadStarted( BillingLoadStarted event, Emitter emit, ) async { - emit(state.copyWith(status: BillingStatus.loading)); + await handleError( + emit: emit.call, + action: () async { + emit(state.copyWith(status: BillingStatus.loading)); - final SpendBreakdownParams spendParams = _dateRangeFor(state.periodTab); + final SpendBreakdownParams spendParams = + _dateRangeFor(state.periodTab); - final List results = await Future.wait( - >[ - _loadSafe(() => _getCurrentBillAmount.call()), - _loadSafe(() => _getSavingsAmount.call()), - _loadSafe>(() => _getPendingInvoices.call()), - _loadSafe>(() => _getInvoiceHistory.call()), - _loadSafe>(() => _getSpendBreakdown.call(spendParams)), - _loadSafe>(() => _getBankAccounts.call()), - ], - ); + final List results = await Future.wait( + >[ + handleErrorWithResult( + action: () => _getCurrentBillAmount.call(), + onError: (_) {}, + ), + handleErrorWithResult( + action: () => _getSavingsAmount.call(), + onError: (_) {}, + ), + handleErrorWithResult>( + action: () => _getPendingInvoices.call(), + onError: (_) {}, + ), + handleErrorWithResult>( + action: () => _getInvoiceHistory.call(), + onError: (_) {}, + ), + handleErrorWithResult>( + action: () => _getSpendBreakdown.call(spendParams), + onError: (_) {}, + ), + handleErrorWithResult>( + action: () => _getBankAccounts.call(), + onError: (_) {}, + ), + ], + ); - final int? currentBillCents = results[0] as int?; - final int? savingsCents = results[1] as int?; - final List? pendingInvoices = results[2] as List?; - final List? invoiceHistory = results[3] as List?; - final List? spendBreakdown = results[4] as List?; - final List? bankAccounts = - results[5] as List?; + final int? currentBillCents = results[0] as int?; + final int? savingsCents = results[1] as int?; + final List? pendingInvoices = + results[2] as List?; + final List? invoiceHistory = + results[3] as List?; + final List? spendBreakdown = + results[4] as List?; + final List? bankAccounts = + results[5] as List?; - 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, + 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, + ), + ); + }, + onError: (String errorKey) => state.copyWith( + status: BillingStatus.failure, + errorMessage: errorKey, ), ); } diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart index 542ebc28..b68fc7e6 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart @@ -56,7 +56,7 @@ class _ShiftCompletionReviewPageState extends State { final DateFormat formatter = DateFormat('EEEE, MMMM d'); final String dateLabel = resolvedInvoice.dueDate != null ? formatter.format(resolvedInvoice.dueDate!) - : 'N/A'; + : 'N/A'; // TODO: localize return Scaffold( appBar: UiAppBar( @@ -85,7 +85,7 @@ class _ShiftCompletionReviewPageState extends State { bottomNavigationBar: Container( padding: const EdgeInsets.all(UiConstants.space5), decoration: BoxDecoration( - color: Colors.white, + color: UiColors.primaryForeground, border: Border( top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)), ), diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart index 475bd5bb..c1a65fc6 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_actions.dart @@ -95,8 +95,8 @@ class CompletionReviewActions extends StatelessWidget { context: context, builder: (BuildContext dialogContext) => AlertDialog( title: Text(t.client_billing.flag_dialog.title), - surfaceTintColor: Colors.white, - backgroundColor: Colors.white, + surfaceTintColor: UiColors.primaryForeground, + backgroundColor: UiColors.primaryForeground, content: TextField( controller: controller, decoration: InputDecoration( diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_search_and_tabs.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_search_and_tabs.dart index eca816a3..89968e09 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_search_and_tabs.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/completion_review/completion_review_search_and_tabs.dart @@ -23,7 +23,7 @@ class CompletionReviewSearchAndTabs extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4), decoration: BoxDecoration( - color: const Color(0xFFF1F5F9), + color: UiColors.muted, borderRadius: UiConstants.radiusMd, ), child: TextField( @@ -69,17 +69,17 @@ class CompletionReviewSearchAndTabs extends StatelessWidget { child: Container( height: 40, decoration: BoxDecoration( - color: isSelected ? const Color(0xFF2563EB) : Colors.white, + color: isSelected ? UiColors.primary : UiColors.white, borderRadius: UiConstants.radiusMd, border: Border.all( - color: isSelected ? const Color(0xFF2563EB) : UiColors.border, + color: isSelected ? UiColors.primary : UiColors.border, ), ), child: Center( child: Text( text, style: UiTypography.body2b.copyWith( - color: isSelected ? Colors.white : UiColors.textSecondary, + color: isSelected ? UiColors.primaryForeground : UiColors.textSecondary, ), ), ), diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart index 3b594017..4f457954 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart @@ -33,7 +33,7 @@ class PendingInvoicesSection extends StatelessWidget { width: 8, height: 8, decoration: const BoxDecoration( - color: Colors.orange, + color: UiColors.textWarning, shape: BoxShape.circle, ), ), @@ -101,7 +101,7 @@ class PendingInvoiceCard extends StatelessWidget { final DateFormat formatter = DateFormat('EEEE, MMMM d'); final String dateLabel = invoice.dueDate != null ? formatter.format(invoice.dueDate!) - : 'N/A'; + : 'N/A'; // TODO: localize final double amountDollars = invoice.amountCents / 100.0; return Container( diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/coverage_module.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/coverage_module.dart index 3d7e2db1..0e4d08f9 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/coverage_module.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/coverage_module.dart @@ -3,7 +3,7 @@ import 'package:krow_core/core.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/repositories/coverage_repository_interface.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'; @@ -21,8 +21,8 @@ class CoverageModule extends Module { @override void binds(Injector i) { // Repositories - i.addLazySingleton( - () => CoverageRepositoryImpl(apiService: i.get()), + i.addLazySingleton( + () => CoverageRepositoryInterfaceImpl(apiService: i.get()), ); // Use Cases diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart index 2010cec5..a105d241 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart @@ -1,14 +1,14 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:client_coverage/src/domain/repositories/coverage_repository.dart'; +import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart'; -/// V2 API implementation of [CoverageRepository]. +/// V2 API implementation of [CoverageRepositoryInterface]. /// /// Uses [BaseApiService] with [ClientEndpoints] for all backend access. -class CoverageRepositoryImpl implements CoverageRepository { - /// Creates a [CoverageRepositoryImpl]. - CoverageRepositoryImpl({required BaseApiService apiService}) +class CoverageRepositoryInterfaceImpl implements CoverageRepositoryInterface { + /// Creates a [CoverageRepositoryInterfaceImpl]. + CoverageRepositoryInterfaceImpl({required BaseApiService apiService}) : _apiService = apiService; final BaseApiService _apiService; diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository_interface.dart similarity index 95% rename from apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository.dart rename to apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository_interface.dart index c82bd45a..dac6ecd4 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository_interface.dart @@ -4,7 +4,7 @@ import 'package:krow_domain/krow_domain.dart'; /// /// Defines the contract for accessing coverage data via the V2 REST API, /// acting as a boundary between the Domain and Data layers. -abstract interface class CoverageRepository { +abstract interface class CoverageRepositoryInterface { /// Fetches shifts with assigned workers for a specific [date]. Future> getShiftsForDate({required DateTime date}); diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/cancel_late_worker_usecase.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/cancel_late_worker_usecase.dart index 2cc4e509..51c984f6 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/cancel_late_worker_usecase.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/cancel_late_worker_usecase.dart @@ -1,17 +1,17 @@ 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'; +import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart'; /// Use case for cancelling a late worker's assignment. /// -/// Delegates to [CoverageRepository] to cancel the assignment via V2 API. +/// Delegates to [CoverageRepositoryInterface] to cancel the assignment via V2 API. class CancelLateWorkerUseCase implements UseCase { /// Creates a [CancelLateWorkerUseCase]. CancelLateWorkerUseCase(this._repository); - final CoverageRepository _repository; + final CoverageRepositoryInterface _repository; @override Future call(CancelLateWorkerArguments arguments) { diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_coverage_stats_usecase.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_coverage_stats_usecase.dart index b26034aa..24b7f77e 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_coverage_stats_usecase.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_coverage_stats_usecase.dart @@ -2,17 +2,17 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:client_coverage/src/domain/arguments/get_coverage_stats_arguments.dart'; -import 'package:client_coverage/src/domain/repositories/coverage_repository.dart'; +import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart'; /// Use case for fetching aggregated coverage statistics for a specific date. /// -/// Delegates to [CoverageRepository] and returns a [CoverageStats] entity. +/// Delegates to [CoverageRepositoryInterface] and returns a [CoverageStats] entity. class GetCoverageStatsUseCase implements UseCase { /// Creates a [GetCoverageStatsUseCase]. GetCoverageStatsUseCase(this._repository); - final CoverageRepository _repository; + final CoverageRepositoryInterface _repository; @override Future call(GetCoverageStatsArguments arguments) { diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_shifts_for_date_usecase.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_shifts_for_date_usecase.dart index 7e021a18..67ef35df 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_shifts_for_date_usecase.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_shifts_for_date_usecase.dart @@ -2,17 +2,17 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:client_coverage/src/domain/arguments/get_shifts_for_date_arguments.dart'; -import 'package:client_coverage/src/domain/repositories/coverage_repository.dart'; +import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart'; /// Use case for fetching shifts with workers for a specific date. /// -/// Delegates to [CoverageRepository] and returns V2 [ShiftWithWorkers] entities. +/// Delegates to [CoverageRepositoryInterface] and returns V2 [ShiftWithWorkers] entities. class GetShiftsForDateUseCase implements UseCase> { /// Creates a [GetShiftsForDateUseCase]. GetShiftsForDateUseCase(this._repository); - final CoverageRepository _repository; + final CoverageRepositoryInterface _repository; @override Future> call(GetShiftsForDateArguments arguments) { diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/submit_worker_review_usecase.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/submit_worker_review_usecase.dart index be9a17d1..4e3d094d 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/submit_worker_review_usecase.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/submit_worker_review_usecase.dart @@ -1,17 +1,17 @@ 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'; +import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart'; /// Use case for submitting a worker review from the coverage page. /// -/// Validates the rating range and delegates to [CoverageRepository]. +/// Validates the rating range and delegates to [CoverageRepositoryInterface]. class SubmitWorkerReviewUseCase implements UseCase { /// Creates a [SubmitWorkerReviewUseCase]. SubmitWorkerReviewUseCase(this._repository); - final CoverageRepository _repository; + final CoverageRepositoryInterface _repository; @override Future call(SubmitWorkerReviewArguments arguments) async { diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart index 79650827..61e79132 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart @@ -128,6 +128,9 @@ class _CoveragePageState extends State { ], flexibleSpace: Container( decoration: const BoxDecoration( + // Intentional gradient: the second stop is a darker + // variant of UiColors.primary used only for the + // coverage header visual effect. gradient: LinearGradient( colors: [ UiColors.primary, diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_review_sheet.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_review_sheet.dart index 8508fdae..f9246cd1 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_review_sheet.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_review_sheet.dart @@ -8,6 +8,11 @@ import 'package:client_coverage/src/presentation/blocs/coverage_bloc.dart'; import 'package:client_coverage/src/presentation/blocs/coverage_event.dart'; import 'package:client_coverage/src/presentation/blocs/coverage_state.dart'; +/// Semantic color for the "favorite" toggle, representing a pink/heart accent. +/// No matching token in [UiColors] — kept as a local constant intentionally. +const Color _kFavoriteColor = Color(0xFFE91E63); + +/// Bottom sheet for submitting a worker review with rating, feedback, and flags. class WorkerReviewSheet extends StatefulWidget { const WorkerReviewSheet({required this.worker, super.key}); @@ -201,7 +206,7 @@ class _WorkerReviewSheetState extends State { icon: Icons.favorite, label: l10n.favorite_label, isActive: _isFavorite, - activeColor: const Color(0xFFE91E63), + activeColor: _kFavoriteColor, onTap: () => setState(() => _isFavorite = !_isFavorite), ), ), diff --git a/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_cubit.dart b/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_cubit.dart index 98a8b2a9..95a4d547 100644 --- a/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_cubit.dart +++ b/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_cubit.dart @@ -1,20 +1,34 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; -import 'client_main_state.dart'; +import 'package:client_main/src/presentation/blocs/client_main_state.dart'; -class ClientMainCubit extends Cubit implements Disposable { +/// Cubit that manages the client app's main navigation state. +/// +/// Tracks the active bottom bar tab and controls tab visibility +/// based on the current route. +class ClientMainCubit extends Cubit + with BlocErrorHandler + implements Disposable { + /// Creates a [ClientMainCubit] and starts listening for route changes. ClientMainCubit() : super(const ClientMainState()) { Modular.to.addListener(_onRouteChanged); _onRouteChanged(); } + /// Routes that should hide the bottom navigation bar. static const List _hideBottomBarPaths = [ ClientPaths.completionReview, ClientPaths.awaitingApproval, ]; + /// Updates state when the current route changes. + /// + /// Detects the active tab from the route path and determines + /// whether the bottom bar should be visible. void _onRouteChanged() { + if (isClosed) return; + final String path = Modular.to.path; int newIndex = state.currentIndex; @@ -41,6 +55,9 @@ class ClientMainCubit extends Cubit implements Disposable { } } + /// Navigates to the tab at [index] via Modular safe navigation. + /// + /// State update happens automatically via [_onRouteChanged]. void navigateToTab(int index) { if (index == state.currentIndex) return; @@ -61,7 +78,6 @@ class ClientMainCubit extends Cubit implements Disposable { Modular.to.toClientReports(); break; } - // State update will happen via _onRouteChanged } @override diff --git a/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_state.dart b/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_state.dart index 78650cae..669e316d 100644 --- a/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_state.dart +++ b/apps/mobile/packages/features/client/client_main/lib/src/presentation/blocs/client_main_state.dart @@ -1,14 +1,20 @@ import 'package:equatable/equatable.dart'; +/// State for [ClientMainCubit] representing bottom navigation status. class ClientMainState extends Equatable { + /// Creates a [ClientMainState] with the given tab index and bar visibility. const ClientMainState({ this.currentIndex = 2, // Default to Home this.showBottomBar = true, }); + /// Index of the currently active bottom navigation tab. final int currentIndex; + + /// Whether the bottom navigation bar should be visible. final bool showBottomBar; + /// Creates a copy of this state with updated fields. ClientMainState copyWith({int? currentIndex, bool? showBottomBar}) { return ClientMainState( currentIndex: currentIndex ?? this.currentIndex, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart index 83b669c6..f4a219ae 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart @@ -3,6 +3,7 @@ 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:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:client_hubs/src/presentation/blocs/edit_hub/edit_hub_bloc.dart'; @@ -38,7 +39,7 @@ class EditHubPage extends StatelessWidget { message: message, type: UiSnackbarType.success, ); - Modular.to.pop(true); + Modular.to.popSafe(true); } if (state.status == EditHubStatus.failure && state.errorMessage != null) { @@ -65,7 +66,7 @@ class EditHubPage extends StatelessWidget { child: HubForm( hub: hub, costCenters: state.costCenters, - onCancel: () => Modular.to.pop(), + onCancel: () => Modular.to.popSafe(), onSave: ({ required String name, required String fullAddress, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart index 63fa93f6..8404caeb 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart @@ -38,7 +38,7 @@ class HubDetailsPage extends StatelessWidget { message: message, type: UiSnackbarType.success, ); - Modular.to.pop(true); // Return true to indicate change + Modular.to.popSafe(true); // Return true to indicate change } if (state.status == HubDetailsStatus.failure && state.errorMessage != null) { @@ -117,7 +117,7 @@ class HubDetailsPage extends StatelessWidget { Future _navigateToEditPage(BuildContext context) async { final bool? saved = await Modular.to.toEditHub(hub: hub); if (saved == true && context.mounted) { - Modular.to.pop(true); // Return true to indicate change + Modular.to.popSafe(true); // Return true to indicate change } } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form.dart index eafeef01..5e141b0c 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form.dart @@ -112,7 +112,7 @@ class _HubFormState extends State { vertical: 16, ), decoration: BoxDecoration( - color: const Color(0xFFF8FAFD), + color: UiColors.muted, borderRadius: BorderRadius.circular( UiConstants.radiusBase * 1.5, ), @@ -225,7 +225,7 @@ class _HubFormState extends State { color: UiColors.textSecondary.withValues(alpha: 0.5), ), filled: true, - fillColor: const Color(0xFFF8FAFD), + fillColor: UiColors.muted, contentPadding: const EdgeInsets.symmetric( horizontal: UiConstants.space4, vertical: 16, diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart index 7a3203c2..95eaf507 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart @@ -11,7 +11,11 @@ import 'domain/usecases/create_one_time_order_usecase.dart'; import 'domain/usecases/create_permanent_order_usecase.dart'; import 'domain/usecases/create_rapid_order_usecase.dart'; import 'domain/usecases/create_recurring_order_usecase.dart'; +import 'domain/usecases/get_hubs_usecase.dart'; +import 'domain/usecases/get_managers_by_hub_usecase.dart'; import 'domain/usecases/get_order_details_for_reorder_usecase.dart'; +import 'domain/usecases/get_roles_by_vendor_usecase.dart'; +import 'domain/usecases/get_vendors_usecase.dart'; import 'domain/usecases/parse_rapid_order_usecase.dart'; import 'domain/usecases/transcribe_rapid_order_usecase.dart'; import 'presentation/blocs/index.dart'; @@ -46,7 +50,7 @@ class ClientCreateOrderModule extends Module { ), ); - // UseCases + // Command UseCases (order creation) i.addLazySingleton(CreateOneTimeOrderUseCase.new); i.addLazySingleton(CreatePermanentOrderUseCase.new); i.addLazySingleton(CreateRecurringOrderUseCase.new); @@ -55,6 +59,12 @@ class ClientCreateOrderModule extends Module { i.addLazySingleton(ParseRapidOrderTextToOrderUseCase.new); i.addLazySingleton(GetOrderDetailsForReorderUseCase.new); + // Query UseCases (reference data loading) + i.addLazySingleton(GetVendorsUseCase.new); + i.addLazySingleton(GetRolesByVendorUseCase.new); + i.addLazySingleton(GetHubsUseCase.new); + i.addLazySingleton(GetManagersByHubUseCase.new); + // BLoCs i.add( () => RapidOrderBloc( @@ -63,15 +73,36 @@ class ClientCreateOrderModule extends Module { i.get(), ), ); - i.add(OneTimeOrderBloc.new); + i.add( + () => OneTimeOrderBloc( + i.get(), + i.get(), + i.get(), + i.get(), + i.get(), + i.get(), + ), + ); i.add( () => PermanentOrderBloc( i.get(), i.get(), - i.get(), + i.get(), + i.get(), + i.get(), + i.get(), + ), + ); + i.add( + () => RecurringOrderBloc( + i.get(), + i.get(), + i.get(), + i.get(), + i.get(), + i.get(), ), ); - i.add(RecurringOrderBloc.new); } @override diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart index 0093a45e..890fbeaf 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/one_time_order_arguments.dart @@ -1,15 +1,69 @@ import 'package:krow_core/core.dart'; -/// Arguments for the [CreateOneTimeOrderUseCase]. -/// -/// Wraps the V2 API payload map for a one-time order. -class OneTimeOrderArguments extends UseCaseArgument { - /// Creates a [OneTimeOrderArguments] with the given [payload]. - const OneTimeOrderArguments({required this.payload}); +/// A single position entry for a one-time order submission. +class OneTimeOrderPositionArgument extends UseCaseArgument { + /// Creates a [OneTimeOrderPositionArgument]. + const OneTimeOrderPositionArgument({ + required this.roleId, + required this.workerCount, + required this.startTime, + required this.endTime, + this.roleName, + this.lunchBreak, + }); - /// The V2 API payload map. - final Map payload; + /// The role ID for this position. + final String roleId; + + /// Human-readable role name, if available. + final String? roleName; + + /// Number of workers needed for this position. + final int workerCount; + + /// Shift start time in HH:mm format. + final String startTime; + + /// Shift end time in HH:mm format. + final String endTime; + + /// Break duration label (e.g. `'MIN_30'`, `'NO_BREAK'`), if set. + final String? lunchBreak; @override - List get props => [payload]; + List get props => + [roleId, roleName, workerCount, startTime, endTime, lunchBreak]; +} + +/// Typed arguments for [CreateOneTimeOrderUseCase]. +/// +/// Carries structured form data so the use case can build the V2 API payload. +class OneTimeOrderArguments extends UseCaseArgument { + /// Creates a [OneTimeOrderArguments] with the given structured fields. + const OneTimeOrderArguments({ + required this.hubId, + required this.eventName, + required this.orderDate, + required this.positions, + this.vendorId, + }); + + /// The selected hub ID. + final String hubId; + + /// The order event name / title. + final String eventName; + + /// The order date. + final DateTime orderDate; + + /// The list of position entries. + final List positions; + + /// The selected vendor ID, if applicable. + final String? vendorId; + + @override + List get props => + [hubId, eventName, orderDate, positions, vendorId]; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart index e552278f..fb19864e 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/permanent_order_arguments.dart @@ -1,10 +1,75 @@ -/// Arguments for the [CreatePermanentOrderUseCase]. -/// -/// Wraps the V2 API payload map for a permanent order. -class PermanentOrderArguments { - /// Creates a [PermanentOrderArguments] with the given [payload]. - const PermanentOrderArguments({required this.payload}); +import 'package:krow_core/core.dart'; - /// The V2 API payload map. - final Map payload; +/// A single position entry for a permanent order submission. +class PermanentOrderPositionArgument extends UseCaseArgument { + /// Creates a [PermanentOrderPositionArgument]. + const PermanentOrderPositionArgument({ + required this.roleId, + required this.workerCount, + required this.startTime, + required this.endTime, + this.roleName, + }); + + /// The role ID for this position. + final String roleId; + + /// Human-readable role name, if available. + final String? roleName; + + /// Number of workers needed for this position. + final int workerCount; + + /// Shift start time in HH:mm format. + final String startTime; + + /// Shift end time in HH:mm format. + final String endTime; + + @override + List get props => + [roleId, roleName, workerCount, startTime, endTime]; +} + +/// Typed arguments for [CreatePermanentOrderUseCase]. +/// +/// Carries structured form data so the use case can build the V2 API payload. +class PermanentOrderArguments extends UseCaseArgument { + /// Creates a [PermanentOrderArguments] with the given structured fields. + const PermanentOrderArguments({ + required this.hubId, + required this.eventName, + required this.startDate, + required this.daysOfWeek, + required this.positions, + this.vendorId, + }); + + /// The selected hub ID. + final String hubId; + + /// The order event name / title. + final String eventName; + + /// The start date of the permanent order. + final DateTime startDate; + + /// Day-of-week labels (e.g. `['MON', 'WED', 'FRI']`). + final List daysOfWeek; + + /// The list of position entries. + final List positions; + + /// The selected vendor ID, if applicable. + final String? vendorId; + + @override + List get props => [ + hubId, + eventName, + startDate, + daysOfWeek, + positions, + vendorId, + ]; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart index 25e8df02..01999078 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/arguments/recurring_order_arguments.dart @@ -1,10 +1,80 @@ -/// Arguments for the [CreateRecurringOrderUseCase]. -/// -/// Wraps the V2 API payload map for a recurring order. -class RecurringOrderArguments { - /// Creates a [RecurringOrderArguments] with the given [payload]. - const RecurringOrderArguments({required this.payload}); +import 'package:krow_core/core.dart'; - /// The V2 API payload map. - final Map payload; +/// A single position entry for a recurring order submission. +class RecurringOrderPositionArgument extends UseCaseArgument { + /// Creates a [RecurringOrderPositionArgument]. + const RecurringOrderPositionArgument({ + required this.roleId, + required this.workerCount, + required this.startTime, + required this.endTime, + this.roleName, + }); + + /// The role ID for this position. + final String roleId; + + /// Human-readable role name, if available. + final String? roleName; + + /// Number of workers needed for this position. + final int workerCount; + + /// Shift start time in HH:mm format. + final String startTime; + + /// Shift end time in HH:mm format. + final String endTime; + + @override + List get props => + [roleId, roleName, workerCount, startTime, endTime]; +} + +/// Typed arguments for [CreateRecurringOrderUseCase]. +/// +/// Carries structured form data so the use case can build the V2 API payload. +class RecurringOrderArguments extends UseCaseArgument { + /// Creates a [RecurringOrderArguments] with the given structured fields. + const RecurringOrderArguments({ + required this.hubId, + required this.eventName, + required this.startDate, + required this.endDate, + required this.recurringDays, + required this.positions, + this.vendorId, + }); + + /// The selected hub ID. + final String hubId; + + /// The order event name / title. + final String eventName; + + /// The start date of the recurring order period. + final DateTime startDate; + + /// The end date of the recurring order period. + final DateTime endDate; + + /// Day-of-week labels (e.g. `['MON', 'WED', 'FRI']`). + final List recurringDays; + + /// The list of position entries. + final List positions; + + /// The selected vendor ID, if applicable. + final String? vendorId; + + @override + List get props => [ + hubId, + eventName, + startDate, + endDate, + recurringDays, + positions, + vendorId, + ]; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart index 948f0c2c..f74c4b63 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_one_time_order_usecase.dart @@ -5,16 +5,45 @@ import '../repositories/client_create_order_repository_interface.dart'; /// Use case for creating a one-time staffing order. /// -/// Delegates the V2 API payload to the repository. +/// Builds the V2 API payload from typed [OneTimeOrderArguments] and +/// delegates submission to the repository. Payload construction (date +/// formatting, position mapping, break-minutes conversion) is business +/// logic that belongs here, not in the BLoC. class CreateOneTimeOrderUseCase implements UseCase { /// Creates a [CreateOneTimeOrderUseCase]. const CreateOneTimeOrderUseCase(this._repository); + /// The create-order repository. final ClientCreateOrderRepositoryInterface _repository; @override Future call(OneTimeOrderArguments input) { - return _repository.createOneTimeOrder(input.payload); + final String orderDate = formatDateToIso(input.orderDate); + + final List> positions = + input.positions.map((OneTimeOrderPositionArgument p) { + return { + if (p.roleName != null) 'roleName': p.roleName, + if (p.roleId.isNotEmpty) 'roleId': p.roleId, + 'workerCount': p.workerCount, + 'startTime': p.startTime, + 'endTime': p.endTime, + if (p.lunchBreak != null && + p.lunchBreak != 'NO_BREAK' && + p.lunchBreak!.isNotEmpty) + 'lunchBreakMinutes': breakMinutesFromLabel(p.lunchBreak!), + }; + }).toList(); + + final Map payload = { + 'hubId': input.hubId, + 'eventName': input.eventName, + 'orderDate': orderDate, + 'positions': positions, + if (input.vendorId != null) 'vendorId': input.vendorId, + }; + + return _repository.createOneTimeOrder(payload); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart index 0734f1ba..e33163d9 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart @@ -1,17 +1,61 @@ +import 'package:krow_core/core.dart'; + import '../arguments/permanent_order_arguments.dart'; import '../repositories/client_create_order_repository_interface.dart'; +/// Day-of-week labels in Sunday-first order, matching the V2 API convention. +const List _dayLabels = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', +]; + /// Use case for creating a permanent staffing order. /// -/// Delegates the V2 API payload to the repository. -class CreatePermanentOrderUseCase { +/// Builds the V2 API payload from typed [PermanentOrderArguments] and +/// delegates submission to the repository. Payload construction (date +/// formatting, day-of-week mapping, position mapping) is business +/// logic that belongs here, not in the BLoC. +class CreatePermanentOrderUseCase + implements UseCase { /// Creates a [CreatePermanentOrderUseCase]. const CreatePermanentOrderUseCase(this._repository); + /// The create-order repository. final ClientCreateOrderRepositoryInterface _repository; - /// Executes the use case with the given [args]. - Future call(PermanentOrderArguments args) { - return _repository.createPermanentOrder(args.payload); + @override + Future call(PermanentOrderArguments input) { + final String startDate = formatDateToIso(input.startDate); + + final List daysOfWeek = input.daysOfWeek + .map((String day) => _dayLabels.indexOf(day) % 7) + .toList(); + + final List> positions = + input.positions.map((PermanentOrderPositionArgument p) { + return { + if (p.roleName != null) 'roleName': p.roleName, + if (p.roleId.isNotEmpty) 'roleId': p.roleId, + 'workerCount': p.workerCount, + 'startTime': p.startTime, + 'endTime': p.endTime, + }; + }).toList(); + + final Map payload = { + 'hubId': input.hubId, + 'eventName': input.eventName, + 'startDate': startDate, + 'daysOfWeek': daysOfWeek, + 'positions': positions, + if (input.vendorId != null) 'vendorId': input.vendorId, + }; + + return _repository.createPermanentOrder(payload); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart index 69462073..7bd1232f 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart @@ -1,17 +1,63 @@ +import 'package:krow_core/core.dart'; + import '../arguments/recurring_order_arguments.dart'; import '../repositories/client_create_order_repository_interface.dart'; +/// Day-of-week labels in Sunday-first order, matching the V2 API convention. +const List _dayLabels = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', +]; + /// Use case for creating a recurring staffing order. /// -/// Delegates the V2 API payload to the repository. -class CreateRecurringOrderUseCase { +/// Builds the V2 API payload from typed [RecurringOrderArguments] and +/// delegates submission to the repository. Payload construction (date +/// formatting, recurrence-day mapping, position mapping) is business +/// logic that belongs here, not in the BLoC. +class CreateRecurringOrderUseCase + implements UseCase { /// Creates a [CreateRecurringOrderUseCase]. const CreateRecurringOrderUseCase(this._repository); + /// The create-order repository. final ClientCreateOrderRepositoryInterface _repository; - /// Executes the use case with the given [args]. - Future call(RecurringOrderArguments args) { - return _repository.createRecurringOrder(args.payload); + @override + Future call(RecurringOrderArguments input) { + final String startDate = formatDateToIso(input.startDate); + final String endDate = formatDateToIso(input.endDate); + + final List recurrenceDays = input.recurringDays + .map((String day) => _dayLabels.indexOf(day) % 7) + .toList(); + + final List> positions = + input.positions.map((RecurringOrderPositionArgument p) { + return { + if (p.roleName != null) 'roleName': p.roleName, + if (p.roleId.isNotEmpty) 'roleId': p.roleId, + 'workerCount': p.workerCount, + 'startTime': p.startTime, + 'endTime': p.endTime, + }; + }).toList(); + + final Map payload = { + 'hubId': input.hubId, + 'eventName': input.eventName, + 'startDate': startDate, + 'endDate': endDate, + 'recurrenceDays': recurrenceDays, + 'positions': positions, + if (input.vendorId != null) 'vendorId': input.vendorId, + }; + + return _repository.createRecurringOrder(payload); } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_hubs_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_hubs_usecase.dart new file mode 100644 index 00000000..c5fc378e --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_hubs_usecase.dart @@ -0,0 +1,20 @@ +import 'package:krow_core/core.dart'; + +import '../models/order_hub.dart'; +import '../repositories/client_order_query_repository_interface.dart'; + +/// Use case for fetching team hubs for the current business. +/// +/// Returns the list of [OrderHub] instances available for order assignment. +class GetHubsUseCase implements NoInputUseCase> { + /// Creates a [GetHubsUseCase]. + const GetHubsUseCase(this._repository); + + /// The query repository for order reference data. + final ClientOrderQueryRepositoryInterface _repository; + + @override + Future> call() { + return _repository.getHubs(); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_managers_by_hub_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_managers_by_hub_usecase.dart new file mode 100644 index 00000000..d8f42de1 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_managers_by_hub_usecase.dart @@ -0,0 +1,21 @@ +import 'package:krow_core/core.dart'; + +import '../models/order_manager.dart'; +import '../repositories/client_order_query_repository_interface.dart'; + +/// Use case for fetching managers assigned to a specific hub. +/// +/// Takes a hub ID and returns the list of [OrderManager] instances +/// for that hub. +class GetManagersByHubUseCase implements UseCase> { + /// Creates a [GetManagersByHubUseCase]. + const GetManagersByHubUseCase(this._repository); + + /// The query repository for order reference data. + final ClientOrderQueryRepositoryInterface _repository; + + @override + Future> call(String hubId) { + return _repository.getManagersByHub(hubId); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_roles_by_vendor_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_roles_by_vendor_usecase.dart new file mode 100644 index 00000000..1d99bb92 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_roles_by_vendor_usecase.dart @@ -0,0 +1,21 @@ +import 'package:krow_core/core.dart'; + +import '../models/order_role.dart'; +import '../repositories/client_order_query_repository_interface.dart'; + +/// Use case for fetching roles offered by a specific vendor. +/// +/// Takes a vendor ID and returns the list of [OrderRole] instances +/// available from that vendor. +class GetRolesByVendorUseCase implements UseCase> { + /// Creates a [GetRolesByVendorUseCase]. + const GetRolesByVendorUseCase(this._repository); + + /// The query repository for order reference data. + final ClientOrderQueryRepositoryInterface _repository; + + @override + Future> call(String vendorId) { + return _repository.getRolesByVendor(vendorId); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_vendors_usecase.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_vendors_usecase.dart new file mode 100644 index 00000000..72c91f4d --- /dev/null +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/domain/usecases/get_vendors_usecase.dart @@ -0,0 +1,21 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../repositories/client_order_query_repository_interface.dart'; + +/// Use case for fetching the list of available vendors. +/// +/// Wraps the query repository to enforce the use-case boundary between +/// presentation and data layers. +class GetVendorsUseCase implements NoInputUseCase> { + /// Creates a [GetVendorsUseCase]. + const GetVendorsUseCase(this._repository); + + /// The query repository for order reference data. + final ClientOrderQueryRepositoryInterface _repository; + + @override + Future> call() { + return _repository.getVendors(); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart index e6efa3af..e40aa20f 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart @@ -2,9 +2,12 @@ import 'package:client_create_order/src/domain/arguments/one_time_order_argument import 'package:client_create_order/src/domain/models/order_hub.dart'; import 'package:client_create_order/src/domain/models/order_manager.dart'; import 'package:client_create_order/src/domain/models/order_role.dart'; -import 'package:client_create_order/src/domain/repositories/client_order_query_repository_interface.dart'; import 'package:client_create_order/src/domain/usecases/create_one_time_order_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_hubs_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_managers_by_hub_usecase.dart'; import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_roles_by_vendor_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_vendors_usecase.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -14,16 +17,20 @@ import 'one_time_order_state.dart'; /// BLoC for managing the multi-step one-time order creation form. /// -/// Builds V2 API payloads and uses [OrderPreview] for reorder. +/// Delegates all data fetching to query use cases and order submission +/// to [CreateOneTimeOrderUseCase]. Uses [OrderPreview] for reorder. class OneTimeOrderBloc extends Bloc with BlocErrorHandler, SafeBloc { - /// Creates the BLoC with required dependencies. + /// Creates the BLoC with required use case dependencies. OneTimeOrderBloc( this._createOneTimeOrderUseCase, this._getOrderDetailsForReorderUseCase, - this._queryRepository, + this._getVendorsUseCase, + this._getRolesByVendorUseCase, + this._getHubsUseCase, + this._getManagersByHubUseCase, ) : super(OneTimeOrderState.initial()) { on(_onVendorsLoaded); on(_onVendorChanged); @@ -45,16 +52,21 @@ class OneTimeOrderBloc extends Bloc final CreateOneTimeOrderUseCase _createOneTimeOrderUseCase; final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase; - final ClientOrderQueryRepositoryInterface _queryRepository; + final GetVendorsUseCase _getVendorsUseCase; + final GetRolesByVendorUseCase _getRolesByVendorUseCase; + final GetHubsUseCase _getHubsUseCase; + final GetManagersByHubUseCase _getManagersByHubUseCase; + /// Loads available vendors via the use case. Future _loadVendors() async { final List? vendors = await handleErrorWithResult( - action: () => _queryRepository.getVendors(), + action: () => _getVendorsUseCase(), onError: (_) => add(const OneTimeOrderVendorsLoaded([])), ); if (vendors != null) add(OneTimeOrderVendorsLoaded(vendors)); } + /// Loads roles for [vendorId] and maps them to presentation option models. Future _loadRolesForVendor( String vendorId, Emitter emit, @@ -62,7 +74,7 @@ class OneTimeOrderBloc extends Bloc final List? roles = await handleErrorWithResult( action: () async { final List result = - await _queryRepository.getRolesByVendor(vendorId); + await _getRolesByVendorUseCase(vendorId); return result .map((OrderRole r) => OneTimeOrderRoleOption( id: r.id, name: r.name, costPerHour: r.costPerHour)) @@ -74,10 +86,11 @@ class OneTimeOrderBloc extends Bloc if (roles != null) emit(state.copyWith(roles: roles)); } + /// Loads hubs via the use case and maps to presentation option models. Future _loadHubs() async { final List? hubs = await handleErrorWithResult( action: () async { - final List result = await _queryRepository.getHubs(); + final List result = await _getHubsUseCase(); return result .map((OrderHub h) => OneTimeOrderHubOption( id: h.id, @@ -100,12 +113,13 @@ class OneTimeOrderBloc extends Bloc if (hubs != null) add(OneTimeOrderHubsLoaded(hubs)); } + /// Loads managers for [hubId] via the use case. Future _loadManagersForHub(String hubId) async { final List? managers = await handleErrorWithResult( action: () async { final List result = - await _queryRepository.getManagersByHub(hubId); + await _getManagersByHubUseCase(hubId); return result .map((OrderManager m) => OneTimeOrderManagerOption(id: m.id, name: m.name)) @@ -224,7 +238,7 @@ class OneTimeOrderBloc extends Bloc emit(state.copyWith(positions: newPositions)); } - /// Builds a V2 API payload and submits the one-time order. + /// Builds typed arguments from form state and submits via the use case. Future _onSubmitted( OneTimeOrderSubmitted event, Emitter emit, @@ -236,12 +250,7 @@ class OneTimeOrderBloc extends Bloc final OneTimeOrderHubOption? selectedHub = state.selectedHub; if (selectedHub == null) throw const OrderMissingHubException(); - final String orderDate = - '${state.date.year.toString().padLeft(4, '0')}-' - '${state.date.month.toString().padLeft(2, '0')}-' - '${state.date.day.toString().padLeft(2, '0')}'; - - final List> positions = + final List positionArgs = state.positions.map((OneTimeOrderPosition p) { final OneTimeOrderRoleOption? role = state.roles .cast() @@ -249,28 +258,24 @@ class OneTimeOrderBloc extends Bloc (OneTimeOrderRoleOption? r) => r != null && r.id == p.role, orElse: () => null, ); - return { - if (role != null) 'roleName': role.name, - if (p.role.isNotEmpty) 'roleId': p.role, - 'workerCount': p.count, - 'startTime': p.startTime, - 'endTime': p.endTime, - if (p.lunchBreak != 'NO_BREAK' && p.lunchBreak.isNotEmpty) - 'lunchBreakMinutes': _breakMinutes(p.lunchBreak), - }; + return OneTimeOrderPositionArgument( + roleId: p.role, + roleName: role?.name, + workerCount: p.count, + startTime: p.startTime, + endTime: p.endTime, + lunchBreak: p.lunchBreak, + ); }).toList(); - final Map payload = { - 'hubId': selectedHub.id, - 'eventName': state.eventName, - 'orderDate': orderDate, - 'positions': positions, - if (state.selectedVendor != null) - 'vendorId': state.selectedVendor!.id, - }; - await _createOneTimeOrderUseCase( - OneTimeOrderArguments(payload: payload), + OneTimeOrderArguments( + hubId: selectedHub.id, + eventName: state.eventName, + orderDate: state.date, + positions: positionArgs, + vendorId: state.selectedVendor?.id, + ), ); emit(state.copyWith(status: OneTimeOrderStatus.success)); }, @@ -339,8 +344,8 @@ class OneTimeOrderBloc extends Bloc positions.add(OneTimeOrderPosition( role: role.roleId, count: role.workersNeeded, - startTime: _formatTime(shift.startsAt), - endTime: _formatTime(shift.endsAt), + startTime: formatTimeHHmm(shift.startsAt), + endTime: formatTimeHHmm(shift.endsAt), )); } } @@ -357,29 +362,4 @@ class OneTimeOrderBloc extends Bloc ), ); } - - /// Formats a [DateTime] to HH:mm string. - String _formatTime(DateTime dt) { - final DateTime local = dt.toLocal(); - return '${local.hour.toString().padLeft(2, '0')}:' - '${local.minute.toString().padLeft(2, '0')}'; - } - - /// Converts a break duration string to minutes. - int _breakMinutes(String value) { - switch (value) { - case 'MIN_10': - return 10; - case 'MIN_15': - return 15; - case 'MIN_30': - return 30; - case 'MIN_45': - return 45; - case 'MIN_60': - return 60; - default: - return 0; - } - } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart index 9115c729..fae8ee4d 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart @@ -1,26 +1,36 @@ +import 'package:client_create_order/src/domain/arguments/permanent_order_arguments.dart'; import 'package:client_create_order/src/domain/models/order_hub.dart'; import 'package:client_create_order/src/domain/models/order_manager.dart'; import 'package:client_create_order/src/domain/models/order_role.dart'; -import 'package:client_create_order/src/domain/repositories/client_order_query_repository_interface.dart'; import 'package:client_create_order/src/domain/usecases/create_permanent_order_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_hubs_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_managers_by_hub_usecase.dart'; import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_roles_by_vendor_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_vendors_usecase.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; -import 'package:client_create_order/src/domain/arguments/permanent_order_arguments.dart'; import 'package:krow_domain/krow_domain.dart' as domain; import 'permanent_order_event.dart'; import 'permanent_order_state.dart'; /// BLoC for managing the permanent order creation form. +/// +/// Delegates all data fetching to query use cases and order submission +/// to [CreatePermanentOrderUseCase]. class PermanentOrderBloc extends Bloc with BlocErrorHandler, SafeBloc { + /// Creates a BLoC with required use case dependencies. PermanentOrderBloc( this._createPermanentOrderUseCase, this._getOrderDetailsForReorderUseCase, - this._queryRepository, + this._getVendorsUseCase, + this._getRolesByVendorUseCase, + this._getHubsUseCase, + this._getManagersByHubUseCase, ) : super(PermanentOrderState.initial()) { on(_onVendorsLoaded); on(_onVendorChanged); @@ -43,7 +53,10 @@ class PermanentOrderBloc extends Bloc final CreatePermanentOrderUseCase _createPermanentOrderUseCase; final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase; - final ClientOrderQueryRepositoryInterface _queryRepository; + final GetVendorsUseCase _getVendorsUseCase; + final GetRolesByVendorUseCase _getRolesByVendorUseCase; + final GetHubsUseCase _getHubsUseCase; + final GetManagersByHubUseCase _getManagersByHubUseCase; static const List _dayLabels = [ 'SUN', @@ -55,9 +68,10 @@ class PermanentOrderBloc extends Bloc 'SAT', ]; + /// Loads available vendors via the use case. Future _loadVendors() async { final List? vendors = await handleErrorWithResult( - action: () => _queryRepository.getVendors(), + action: () => _getVendorsUseCase(), onError: (_) => add(const PermanentOrderVendorsLoaded([])), ); @@ -66,6 +80,8 @@ class PermanentOrderBloc extends Bloc } } + /// Loads roles for [vendorId] via the use case and maps them to + /// presentation option models. Future _loadRolesForVendor( String vendorId, Emitter emit, @@ -73,7 +89,7 @@ class PermanentOrderBloc extends Bloc final List? roles = await handleErrorWithResult( action: () async { final List orderRoles = - await _queryRepository.getRolesByVendor(vendorId); + await _getRolesByVendorUseCase(vendorId); return orderRoles .map( (OrderRole r) => PermanentOrderRoleOption( @@ -93,10 +109,11 @@ class PermanentOrderBloc extends Bloc } } + /// Loads hubs via the use case and maps them to presentation option models. Future _loadHubs() async { final List? hubs = await handleErrorWithResult( action: () async { - final List orderHubs = await _queryRepository.getHubs(); + final List orderHubs = await _getHubsUseCase(); return orderHubs .map( (OrderHub hub) => PermanentOrderHubOption( @@ -193,6 +210,7 @@ class PermanentOrderBloc extends Bloc emit(state.copyWith(managers: event.managers)); } + /// Loads managers for [hubId] via the use case. Future _loadManagersForHub( String hubId, Emitter emit, @@ -201,7 +219,7 @@ class PermanentOrderBloc extends Bloc await handleErrorWithResult( action: () async { final List orderManagers = - await _queryRepository.getManagersByHub(hubId); + await _getManagersByHubUseCase(hubId); return orderManagers .map( (OrderManager m) => PermanentOrderManagerOption( @@ -221,7 +239,6 @@ class PermanentOrderBloc extends Bloc } } - void _onEventNameChanged( PermanentOrderEventNameChanged event, Emitter emit, @@ -315,6 +332,7 @@ class PermanentOrderBloc extends Bloc emit(state.copyWith(positions: newPositions)); } + /// Builds typed arguments from form state and submits via the use case. Future _onSubmitted( PermanentOrderSubmitted event, Emitter emit, @@ -328,16 +346,7 @@ class PermanentOrderBloc extends Bloc throw const domain.OrderMissingHubException(); } - final String startDate = - '${state.startDate.year.toString().padLeft(4, '0')}-' - '${state.startDate.month.toString().padLeft(2, '0')}-' - '${state.startDate.day.toString().padLeft(2, '0')}'; - - final List daysOfWeek = state.permanentDays - .map((String day) => _dayLabels.indexOf(day) % 7) - .toList(); - - final List> positions = + final List positionArgs = state.positions.map((PermanentOrderPosition p) { final PermanentOrderRoleOption? role = state.roles .cast() @@ -345,27 +354,24 @@ class PermanentOrderBloc extends Bloc (PermanentOrderRoleOption? r) => r != null && r.id == p.role, orElse: () => null, ); - return { - if (role != null) 'roleName': role.name, - if (p.role.isNotEmpty) 'roleId': p.role, - 'workerCount': p.count, - 'startTime': p.startTime, - 'endTime': p.endTime, - }; + return PermanentOrderPositionArgument( + roleId: p.role, + roleName: role?.name, + workerCount: p.count, + startTime: p.startTime, + endTime: p.endTime, + ); }).toList(); - final Map payload = { - 'hubId': selectedHub.id, - 'eventName': state.eventName, - 'startDate': startDate, - 'daysOfWeek': daysOfWeek, - 'positions': positions, - if (state.selectedVendor != null) - 'vendorId': state.selectedVendor!.id, - }; - await _createPermanentOrderUseCase( - PermanentOrderArguments(payload: payload), + PermanentOrderArguments( + hubId: selectedHub.id, + eventName: state.eventName, + startDate: state.startDate, + daysOfWeek: state.permanentDays, + positions: positionArgs, + vendorId: state.selectedVendor?.id, + ), ); emit(state.copyWith(status: PermanentOrderStatus.success)); }, @@ -376,6 +382,7 @@ class PermanentOrderBloc extends Bloc ); } + /// Initializes the form from route arguments or reorder preview data. Future _onInitialized( PermanentOrderInitialized event, Emitter emit, @@ -406,8 +413,8 @@ class PermanentOrderBloc extends Bloc positions.add(PermanentOrderPosition( role: role.roleId, count: role.workersNeeded, - startTime: _formatTime(shift.startsAt), - endTime: _formatTime(shift.endsAt), + startTime: formatTimeHHmm(shift.startsAt), + endTime: formatTimeHHmm(shift.endsAt), )); } } @@ -430,13 +437,6 @@ class PermanentOrderBloc extends Bloc ); } - /// Formats a [DateTime] to HH:mm string. - String _formatTime(DateTime dt) { - final DateTime local = dt.toLocal(); - return '${local.hour.toString().padLeft(2, '0')}:' - '${local.minute.toString().padLeft(2, '0')}'; - } - static List _sortDays(List days) { days.sort( (String a, String b) => diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart index 6154dc0c..ce226789 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart @@ -1,12 +1,15 @@ +import 'package:client_create_order/src/domain/arguments/recurring_order_arguments.dart'; import 'package:client_create_order/src/domain/models/order_hub.dart'; import 'package:client_create_order/src/domain/models/order_manager.dart'; import 'package:client_create_order/src/domain/models/order_role.dart'; -import 'package:client_create_order/src/domain/repositories/client_order_query_repository_interface.dart'; import 'package:client_create_order/src/domain/usecases/create_recurring_order_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_hubs_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_managers_by_hub_usecase.dart'; import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_roles_by_vendor_usecase.dart'; +import 'package:client_create_order/src/domain/usecases/get_vendors_usecase.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; -import 'package:client_create_order/src/domain/arguments/recurring_order_arguments.dart'; import 'package:krow_domain/krow_domain.dart' as domain; import 'recurring_order_event.dart'; @@ -14,19 +17,20 @@ import 'recurring_order_state.dart'; /// BLoC for managing the recurring order creation form. /// -/// Delegates all backend queries to [ClientOrderQueryRepositoryInterface] -/// and order submission to [CreateRecurringOrderUseCase]. -/// Builds V2 API payloads from form state. +/// Delegates all data fetching to query use cases and order submission +/// to [CreateRecurringOrderUseCase]. Builds V2 API payloads from form state. class RecurringOrderBloc extends Bloc with BlocErrorHandler, SafeBloc { - /// Creates a [RecurringOrderBloc] with the required use cases and - /// query repository. + /// Creates a [RecurringOrderBloc] with the required use case dependencies. RecurringOrderBloc( this._createRecurringOrderUseCase, this._getOrderDetailsForReorderUseCase, - this._queryRepository, + this._getVendorsUseCase, + this._getRolesByVendorUseCase, + this._getHubsUseCase, + this._getManagersByHubUseCase, ) : super(RecurringOrderState.initial()) { on(_onVendorsLoaded); on(_onVendorChanged); @@ -50,7 +54,10 @@ class RecurringOrderBloc extends Bloc final CreateRecurringOrderUseCase _createRecurringOrderUseCase; final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase; - final ClientOrderQueryRepositoryInterface _queryRepository; + final GetVendorsUseCase _getVendorsUseCase; + final GetRolesByVendorUseCase _getRolesByVendorUseCase; + final GetHubsUseCase _getHubsUseCase; + final GetManagersByHubUseCase _getManagersByHubUseCase; static const List _dayLabels = [ 'SUN', @@ -62,12 +69,10 @@ class RecurringOrderBloc extends Bloc 'SAT', ]; - /// Loads the list of available vendors from the query repository. + /// Loads the list of available vendors via the use case. Future _loadVendors() async { final List? vendors = await handleErrorWithResult( - action: () async { - return _queryRepository.getVendors(); - }, + action: () => _getVendorsUseCase(), onError: (_) => add(const RecurringOrderVendorsLoaded([])), ); @@ -77,8 +82,8 @@ class RecurringOrderBloc extends Bloc } } - /// Loads roles for the given [vendorId] and maps them to presentation - /// option models. + /// Loads roles for [vendorId] via the use case and maps them to + /// presentation option models. Future _loadRolesForVendor( String vendorId, Emitter emit, @@ -86,7 +91,7 @@ class RecurringOrderBloc extends Bloc final List? roles = await handleErrorWithResult( action: () async { final List orderRoles = - await _queryRepository.getRolesByVendor(vendorId); + await _getRolesByVendorUseCase(vendorId); return orderRoles .map( (OrderRole r) => RecurringOrderRoleOption( @@ -106,12 +111,12 @@ class RecurringOrderBloc extends Bloc } } - /// Loads team hubs for the current business owner and maps them to - /// presentation option models. + /// Loads team hubs via the use case and maps them to presentation + /// option models. Future _loadHubs() async { final List? hubs = await handleErrorWithResult( action: () async { - final List orderHubs = await _queryRepository.getHubs(); + final List orderHubs = await _getHubsUseCase(); return orderHubs .map( (OrderHub hub) => RecurringOrderHubOption( @@ -208,8 +213,8 @@ class RecurringOrderBloc extends Bloc emit(state.copyWith(managers: event.managers)); } - /// Loads managers for the given [hubId] and maps them to presentation - /// option models. + /// Loads managers for [hubId] via the use case and maps them to + /// presentation option models. Future _loadManagersForHub( String hubId, Emitter emit, @@ -218,7 +223,7 @@ class RecurringOrderBloc extends Bloc await handleErrorWithResult( action: () async { final List orderManagers = - await _queryRepository.getManagersByHub(hubId); + await _getManagersByHubUseCase(hubId); return orderManagers .map( (OrderManager m) => RecurringOrderManagerOption( @@ -347,6 +352,7 @@ class RecurringOrderBloc extends Bloc emit(state.copyWith(positions: newPositions)); } + /// Builds typed arguments from form state and submits via the use case. Future _onSubmitted( RecurringOrderSubmitted event, Emitter emit, @@ -360,21 +366,7 @@ class RecurringOrderBloc extends Bloc throw const domain.OrderMissingHubException(); } - final String startDate = - '${state.startDate.year.toString().padLeft(4, '0')}-' - '${state.startDate.month.toString().padLeft(2, '0')}-' - '${state.startDate.day.toString().padLeft(2, '0')}'; - final String endDate = - '${state.endDate.year.toString().padLeft(4, '0')}-' - '${state.endDate.month.toString().padLeft(2, '0')}-' - '${state.endDate.day.toString().padLeft(2, '0')}'; - - // Map day labels (MON=1, TUE=2, ..., SUN=0) to V2 int format - final List recurrenceDays = state.recurringDays - .map((String day) => _dayLabels.indexOf(day) % 7) - .toList(); - - final List> positions = + final List positionArgs = state.positions.map((RecurringOrderPosition p) { final RecurringOrderRoleOption? role = state.roles .cast() @@ -382,28 +374,25 @@ class RecurringOrderBloc extends Bloc (RecurringOrderRoleOption? r) => r != null && r.id == p.role, orElse: () => null, ); - return { - if (role != null) 'roleName': role.name, - if (p.role.isNotEmpty) 'roleId': p.role, - 'workerCount': p.count, - 'startTime': p.startTime, - 'endTime': p.endTime, - }; + return RecurringOrderPositionArgument( + roleId: p.role, + roleName: role?.name, + workerCount: p.count, + startTime: p.startTime, + endTime: p.endTime, + ); }).toList(); - final Map payload = { - 'hubId': selectedHub.id, - 'eventName': state.eventName, - 'startDate': startDate, - 'endDate': endDate, - 'recurrenceDays': recurrenceDays, - 'positions': positions, - if (state.selectedVendor != null) - 'vendorId': state.selectedVendor!.id, - }; - await _createRecurringOrderUseCase( - RecurringOrderArguments(payload: payload), + RecurringOrderArguments( + hubId: selectedHub.id, + eventName: state.eventName, + startDate: state.startDate, + endDate: state.endDate, + recurringDays: state.recurringDays, + positions: positionArgs, + vendorId: state.selectedVendor?.id, + ), ); emit(state.copyWith(status: RecurringOrderStatus.success)); }, @@ -414,6 +403,7 @@ class RecurringOrderBloc extends Bloc ); } + /// Initializes the form from route arguments or reorder preview data. Future _onInitialized( RecurringOrderInitialized event, Emitter emit, @@ -445,8 +435,8 @@ class RecurringOrderBloc extends Bloc positions.add(RecurringOrderPosition( role: role.roleId, count: role.workersNeeded, - startTime: _formatTime(shift.startsAt), - endTime: _formatTime(shift.endsAt), + startTime: formatTimeHHmm(shift.startsAt), + endTime: formatTimeHHmm(shift.endsAt), )); } } @@ -470,13 +460,6 @@ class RecurringOrderBloc extends Bloc ); } - /// Formats a [DateTime] to HH:mm string. - String _formatTime(DateTime dt) { - final DateTime local = dt.toLocal(); - return '${local.hour.toString().padLeft(2, '0')}:' - '${local.minute.toString().padLeft(2, '0')}'; - } - static List _sortDays(List days) { days.sort( (String a, String b) => diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart index 2f656e39..192b4384 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/data/repositories/view_orders_repository_impl.dart @@ -1,13 +1,13 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/i_view_orders_repository.dart'; +import '../../domain/repositories/view_orders_repository_interface.dart'; -/// V2 API implementation of [IViewOrdersRepository]. +/// V2 API implementation of [ViewOrdersRepositoryInterface]. /// /// Replaces the old Data Connect implementation with [BaseApiService] calls /// to the V2 query and command API endpoints. -class ViewOrdersRepositoryImpl implements IViewOrdersRepository { +class ViewOrdersRepositoryImpl implements ViewOrdersRepositoryInterface { /// Creates an instance backed by the given [apiService]. ViewOrdersRepositoryImpl({required BaseApiService apiService}) : _api = apiService; diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/repositories/i_view_orders_repository.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/repositories/view_orders_repository_interface.dart similarity index 96% rename from apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/repositories/i_view_orders_repository.dart rename to apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/repositories/view_orders_repository_interface.dart index a2b86ccf..ecbc1bb0 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/repositories/i_view_orders_repository.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/repositories/view_orders_repository_interface.dart @@ -4,7 +4,7 @@ import 'package:krow_domain/krow_domain.dart'; /// /// V2 API returns workers inline with order items, so the separate /// accepted-applications method is no longer needed. -abstract class IViewOrdersRepository { +abstract class ViewOrdersRepositoryInterface { /// Fetches [OrderItem] list for the given date range via the V2 API. Future> getOrdersForRange({ required DateTime start, diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/usecases/get_orders_use_case.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/usecases/get_orders_use_case.dart index e8e9152f..9ba0b2aa 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/usecases/get_orders_use_case.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/domain/usecases/get_orders_use_case.dart @@ -1,18 +1,18 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../repositories/i_view_orders_repository.dart'; +import '../repositories/view_orders_repository_interface.dart'; import '../arguments/orders_range_arguments.dart'; /// Use case for retrieving the list of client orders. /// /// This use case encapsulates the business rule of fetching orders -/// and delegates the data retrieval to the [IViewOrdersRepository]. +/// and delegates the data retrieval to the [ViewOrdersRepositoryInterface]. class GetOrdersUseCase implements UseCase> { - /// Creates a [GetOrdersUseCase] with the required [IViewOrdersRepository]. + /// Creates a [GetOrdersUseCase] with the required [ViewOrdersRepositoryInterface]. GetOrdersUseCase(this._repository); - final IViewOrdersRepository _repository; + final ViewOrdersRepositoryInterface _repository; @override Future> call(OrdersRangeArguments input) { diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart index fdb1cbc8..44add689 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart @@ -4,11 +4,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/repositories/i_view_orders_repository.dart'; +import '../../domain/repositories/view_orders_repository_interface.dart'; /// Bottom sheet for editing an existing order via the V2 API. /// -/// Delegates all backend calls through [IViewOrdersRepository]. +/// Delegates all backend calls through [ViewOrdersRepositoryInterface]. /// The V2 `clientOrderEdit` endpoint creates an edited copy. class OrderEditSheet extends StatefulWidget { /// Creates an [OrderEditSheet] for the given [order]. @@ -39,12 +39,12 @@ class OrderEditSheetState extends State { List> _hubs = const >[]; Map? _selectedHub; - late IViewOrdersRepository _repository; + late ViewOrdersRepositoryInterface _repository; @override void initState() { super.initState(); - _repository = Modular.get(); + _repository = Modular.get(); _orderNameController = TextEditingController(text: widget.order.roleName); final String startHH = @@ -441,9 +441,9 @@ class OrderEditSheetState extends State { const SizedBox(height: UiConstants.space3), // Role selector - _buildSectionHeader('ROLE'), + _buildSectionHeader('ROLE'), // TODO: localize _buildDropdown( - hint: 'Select role', + hint: 'Select role', // TODO: localize value: roleName.isNotEmpty ? roleName : null, items: _roles .map((Map r) => r['roleName'] as String? ?? r['name'] as String? ?? '') @@ -495,7 +495,7 @@ class OrderEditSheetState extends State { children: [ Expanded( child: _buildInlineTimeInput( - label: 'Start Time', + label: 'Start Time', // TODO: localize value: pos['startTime'] as String? ?? '09:00', onTap: () async { final TimeOfDay? picked = await showTimePicker( @@ -513,7 +513,7 @@ class OrderEditSheetState extends State { const SizedBox(width: UiConstants.space3), Expanded( child: _buildInlineTimeInput( - label: 'End Time', + label: 'End Time', // TODO: localize value: pos['endTime'] as String? ?? '17:00', onTap: () async { final TimeOfDay? picked = await showTimePicker( @@ -825,6 +825,7 @@ class OrderEditSheetState extends State { style: UiTypography.body2b.textPrimary, ), Text( + // TODO: localize '${pos['workerCount']} worker${(pos['workerCount'] as int? ?? 1) > 1 ? 's' : ''}', style: UiTypography.footnote2r.textSecondary, ), diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/view_orders_module.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/view_orders_module.dart index 7d56d1c2..bc6c8c76 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/view_orders_module.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/view_orders_module.dart @@ -4,7 +4,7 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'data/repositories/view_orders_repository_impl.dart'; -import 'domain/repositories/i_view_orders_repository.dart'; +import 'domain/repositories/view_orders_repository_interface.dart'; import 'domain/usecases/get_orders_use_case.dart'; import 'presentation/blocs/view_orders_cubit.dart'; import 'presentation/pages/view_orders_page.dart'; @@ -20,7 +20,7 @@ class ViewOrdersModule extends Module { @override void binds(Injector i) { // Repositories - i.add( + i.add( () => ViewOrdersRepositoryImpl( apiService: i.get(), ), diff --git a/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart b/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart index 40853550..d64857b8 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart @@ -3,12 +3,12 @@ import 'package:krow_domain/krow_domain.dart'; import 'package:client_reports/src/domain/repositories/reports_repository.dart'; -/// V2 API implementation of [ReportsRepository]. +/// V2 API implementation of [ReportsRepositoryInterface]. /// /// Each method hits its corresponding `ClientEndpoints.reports*` endpoint, /// passing date-range query parameters, and deserialises the JSON response /// into the relevant domain entity. -class ReportsRepositoryImpl implements ReportsRepository { +class ReportsRepositoryImpl implements ReportsRepositoryInterface { /// Creates a [ReportsRepositoryImpl]. ReportsRepositoryImpl({required BaseApiService apiService}) : _apiService = apiService; diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/arguments/daily_ops_arguments.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/arguments/daily_ops_arguments.dart new file mode 100644 index 00000000..08d65941 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/arguments/daily_ops_arguments.dart @@ -0,0 +1,13 @@ +import 'package:krow_core/core.dart'; + +/// Arguments for the daily operations report use case. +class DailyOpsArguments extends UseCaseArgument { + /// Creates [DailyOpsArguments]. + const DailyOpsArguments({required this.date}); + + /// The date to fetch the daily operations report for. + final DateTime date; + + @override + List get props => [date]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/arguments/date_range_arguments.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/arguments/date_range_arguments.dart new file mode 100644 index 00000000..82543820 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/arguments/date_range_arguments.dart @@ -0,0 +1,19 @@ +import 'package:krow_core/core.dart'; + +/// Arguments for use cases that require a date range (start and end dates). +class DateRangeArguments extends UseCaseArgument { + /// Creates [DateRangeArguments]. + const DateRangeArguments({ + required this.startDate, + required this.endDate, + }); + + /// Start of the reporting period. + final DateTime startDate; + + /// End of the reporting period. + final DateTime endDate; + + @override + List get props => [startDate, endDate]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/repositories/reports_repository.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/repositories/reports_repository.dart index aa096c67..195fa062 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/domain/repositories/reports_repository.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/repositories/reports_repository.dart @@ -1,7 +1,7 @@ import 'package:krow_domain/krow_domain.dart'; /// Contract for fetching report data from the V2 API. -abstract class ReportsRepository { +abstract class ReportsRepositoryInterface { /// Fetches the daily operations report for a given [date]. Future getDailyOpsReport({ required DateTime date, diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_coverage_report_usecase.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_coverage_report_usecase.dart new file mode 100644 index 00000000..43ed574e --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_coverage_report_usecase.dart @@ -0,0 +1,23 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; + +/// Fetches the coverage report for a date range. +class GetCoverageReportUseCase + implements UseCase { + /// Creates a [GetCoverageReportUseCase]. + GetCoverageReportUseCase(this._repository); + + /// The repository providing report data. + final ReportsRepositoryInterface _repository; + + @override + Future call(DateRangeArguments input) { + return _repository.getCoverageReport( + startDate: input.startDate, + endDate: input.endDate, + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_daily_ops_report_usecase.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_daily_ops_report_usecase.dart new file mode 100644 index 00000000..f22fbe94 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_daily_ops_report_usecase.dart @@ -0,0 +1,20 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:client_reports/src/domain/arguments/daily_ops_arguments.dart'; +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; + +/// Fetches the daily operations report for a single date. +class GetDailyOpsReportUseCase + implements UseCase { + /// Creates a [GetDailyOpsReportUseCase]. + GetDailyOpsReportUseCase(this._repository); + + /// The repository providing report data. + final ReportsRepositoryInterface _repository; + + @override + Future call(DailyOpsArguments input) { + return _repository.getDailyOpsReport(date: input.date); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_forecast_report_usecase.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_forecast_report_usecase.dart new file mode 100644 index 00000000..458e9955 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_forecast_report_usecase.dart @@ -0,0 +1,23 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; + +/// Fetches the forecast report for a date range. +class GetForecastReportUseCase + implements UseCase { + /// Creates a [GetForecastReportUseCase]. + GetForecastReportUseCase(this._repository); + + /// The repository providing report data. + final ReportsRepositoryInterface _repository; + + @override + Future call(DateRangeArguments input) { + return _repository.getForecastReport( + startDate: input.startDate, + endDate: input.endDate, + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_no_show_report_usecase.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_no_show_report_usecase.dart new file mode 100644 index 00000000..08ad052d --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_no_show_report_usecase.dart @@ -0,0 +1,23 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; + +/// Fetches the no-show report for a date range. +class GetNoShowReportUseCase + implements UseCase { + /// Creates a [GetNoShowReportUseCase]. + GetNoShowReportUseCase(this._repository); + + /// The repository providing report data. + final ReportsRepositoryInterface _repository; + + @override + Future call(DateRangeArguments input) { + return _repository.getNoShowReport( + startDate: input.startDate, + endDate: input.endDate, + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_performance_report_usecase.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_performance_report_usecase.dart new file mode 100644 index 00000000..0845e454 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_performance_report_usecase.dart @@ -0,0 +1,23 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; + +/// Fetches the performance report for a date range. +class GetPerformanceReportUseCase + implements UseCase { + /// Creates a [GetPerformanceReportUseCase]. + GetPerformanceReportUseCase(this._repository); + + /// The repository providing report data. + final ReportsRepositoryInterface _repository; + + @override + Future call(DateRangeArguments input) { + return _repository.getPerformanceReport( + startDate: input.startDate, + endDate: input.endDate, + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_reports_summary_usecase.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_reports_summary_usecase.dart new file mode 100644 index 00000000..b270506b --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_reports_summary_usecase.dart @@ -0,0 +1,23 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; + +/// Fetches the high-level report summary for a date range. +class GetReportsSummaryUseCase + implements UseCase { + /// Creates a [GetReportsSummaryUseCase]. + GetReportsSummaryUseCase(this._repository); + + /// The repository providing report data. + final ReportsRepositoryInterface _repository; + + @override + Future call(DateRangeArguments input) { + return _repository.getReportsSummary( + startDate: input.startDate, + endDate: input.endDate, + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_spend_report_usecase.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_spend_report_usecase.dart new file mode 100644 index 00000000..b1783972 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/usecases/get_spend_report_usecase.dart @@ -0,0 +1,23 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; + +/// Fetches the spend report for a date range. +class GetSpendReportUseCase + implements UseCase { + /// Creates a [GetSpendReportUseCase]. + GetSpendReportUseCase(this._repository); + + /// The repository providing report data. + final ReportsRepositoryInterface _repository; + + @override + Future call(DateRangeArguments input) { + return _repository.getSpendReport( + startDate: input.startDate, + endDate: input.endDate, + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_bloc.dart index 7745e970..2eb9cc9f 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_bloc.dart @@ -1,22 +1,23 @@ -import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/usecases/get_coverage_report_usecase.dart'; import 'package:client_reports/src/presentation/blocs/coverage/coverage_event.dart'; import 'package:client_reports/src/presentation/blocs/coverage/coverage_state.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -/// BLoC that loads the [CoverageReport]. +/// BLoC that loads the [CoverageReport] via [GetCoverageReportUseCase]. class CoverageBloc extends Bloc with BlocErrorHandler { /// Creates a [CoverageBloc]. - CoverageBloc({required ReportsRepository reportsRepository}) - : _reportsRepository = reportsRepository, + CoverageBloc({required GetCoverageReportUseCase getCoverageReportUseCase}) + : _getCoverageReportUseCase = getCoverageReportUseCase, super(CoverageInitial()) { on(_onLoadCoverageReport); } - /// The repository used to fetch report data. - final ReportsRepository _reportsRepository; + /// The use case for fetching the coverage report. + final GetCoverageReportUseCase _getCoverageReportUseCase; Future _onLoadCoverageReport( LoadCoverageReport event, @@ -26,10 +27,11 @@ class CoverageBloc extends Bloc emit: emit, action: () async { emit(CoverageLoading()); - final CoverageReport report = - await _reportsRepository.getCoverageReport( - startDate: event.startDate, - endDate: event.endDate, + final CoverageReport report = await _getCoverageReportUseCase.call( + DateRangeArguments( + startDate: event.startDate, + endDate: event.endDate, + ), ); emit(CoverageLoaded(report)); }, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_state.dart index 109a0c4c..381ac3e0 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_state.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/coverage/coverage_state.dart @@ -1,30 +1,39 @@ -// 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:equatable/equatable.dart'; +import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Base state for the coverage report BLoC. abstract class CoverageState extends Equatable { + /// Creates a [CoverageState]. const CoverageState(); @override List get props => []; } +/// Initial state before any coverage report has been requested. class CoverageInitial extends CoverageState {} +/// State while the coverage report is loading. class CoverageLoading extends CoverageState {} +/// State when the coverage report has loaded successfully. class CoverageLoaded extends CoverageState { - + /// Creates a [CoverageLoaded] with the given [report]. const CoverageLoaded(this.report); + + /// The loaded coverage report data. final CoverageReport report; @override List get props => [report]; } +/// State when loading the coverage report has failed. class CoverageError extends CoverageState { - + /// Creates a [CoverageError] with the given error [message]. const CoverageError(this.message); + + /// The error message describing the failure. final String message; @override diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_bloc.dart index 511a2344..3190999d 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_bloc.dart @@ -1,22 +1,23 @@ -import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/domain/arguments/daily_ops_arguments.dart'; +import 'package:client_reports/src/domain/usecases/get_daily_ops_report_usecase.dart'; import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_event.dart'; import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_state.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -/// BLoC that loads the [DailyOpsReport]. +/// BLoC that loads the [DailyOpsReport] via [GetDailyOpsReportUseCase]. class DailyOpsBloc extends Bloc with BlocErrorHandler { /// Creates a [DailyOpsBloc]. - DailyOpsBloc({required ReportsRepository reportsRepository}) - : _reportsRepository = reportsRepository, + DailyOpsBloc({required GetDailyOpsReportUseCase getDailyOpsReportUseCase}) + : _getDailyOpsReportUseCase = getDailyOpsReportUseCase, super(DailyOpsInitial()) { on(_onLoadDailyOpsReport); } - /// The repository used to fetch report data. - final ReportsRepository _reportsRepository; + /// The use case for fetching the daily operations report. + final GetDailyOpsReportUseCase _getDailyOpsReportUseCase; Future _onLoadDailyOpsReport( LoadDailyOpsReport event, @@ -26,9 +27,8 @@ class DailyOpsBloc extends Bloc emit: emit, action: () async { emit(DailyOpsLoading()); - final DailyOpsReport report = - await _reportsRepository.getDailyOpsReport( - date: event.date, + final DailyOpsReport report = await _getDailyOpsReportUseCase.call( + DailyOpsArguments(date: event.date), ); emit(DailyOpsLoaded(report)); }, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_state.dart index 85fa3fee..3063b192 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_state.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_state.dart @@ -1,30 +1,39 @@ -// 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:equatable/equatable.dart'; +import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Base state for the daily operations report BLoC. abstract class DailyOpsState extends Equatable { + /// Creates a [DailyOpsState]. const DailyOpsState(); @override List get props => []; } +/// Initial state before any report has been requested. class DailyOpsInitial extends DailyOpsState {} +/// State while the daily operations report is loading. class DailyOpsLoading extends DailyOpsState {} +/// State when the daily operations report has loaded successfully. class DailyOpsLoaded extends DailyOpsState { - + /// Creates a [DailyOpsLoaded] with the given [report]. const DailyOpsLoaded(this.report); + + /// The loaded daily operations report data. final DailyOpsReport report; @override List get props => [report]; } +/// State when loading the daily operations report has failed. class DailyOpsError extends DailyOpsState { - + /// Creates a [DailyOpsError] with the given error [message]. const DailyOpsError(this.message); + + /// The error message describing the failure. final String message; @override diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_bloc.dart index cc985817..83d99323 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_bloc.dart @@ -1,22 +1,23 @@ -import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/usecases/get_forecast_report_usecase.dart'; import 'package:client_reports/src/presentation/blocs/forecast/forecast_event.dart'; import 'package:client_reports/src/presentation/blocs/forecast/forecast_state.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -/// BLoC that loads the [ForecastReport]. +/// BLoC that loads the [ForecastReport] via [GetForecastReportUseCase]. class ForecastBloc extends Bloc with BlocErrorHandler { /// Creates a [ForecastBloc]. - ForecastBloc({required ReportsRepository reportsRepository}) - : _reportsRepository = reportsRepository, + ForecastBloc({required GetForecastReportUseCase getForecastReportUseCase}) + : _getForecastReportUseCase = getForecastReportUseCase, super(ForecastInitial()) { on(_onLoadForecastReport); } - /// The repository used to fetch report data. - final ReportsRepository _reportsRepository; + /// The use case for fetching the forecast report. + final GetForecastReportUseCase _getForecastReportUseCase; Future _onLoadForecastReport( LoadForecastReport event, @@ -26,10 +27,11 @@ class ForecastBloc extends Bloc emit: emit, action: () async { emit(ForecastLoading()); - final ForecastReport report = - await _reportsRepository.getForecastReport( - startDate: event.startDate, - endDate: event.endDate, + final ForecastReport report = await _getForecastReportUseCase.call( + DateRangeArguments( + startDate: event.startDate, + endDate: event.endDate, + ), ); emit(ForecastLoaded(report)); }, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_state.dart index ae252a4e..53ac6dbe 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_state.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_state.dart @@ -1,30 +1,39 @@ -// 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:equatable/equatable.dart'; +import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Base state for the forecast report BLoC. abstract class ForecastState extends Equatable { + /// Creates a [ForecastState]. const ForecastState(); @override List get props => []; } +/// Initial state before any forecast has been requested. class ForecastInitial extends ForecastState {} +/// State while the forecast report is loading. class ForecastLoading extends ForecastState {} +/// State when the forecast report has loaded successfully. class ForecastLoaded extends ForecastState { - + /// Creates a [ForecastLoaded] with the given [report]. const ForecastLoaded(this.report); + + /// The loaded forecast report data. final ForecastReport report; @override List get props => [report]; } +/// State when loading the forecast report has failed. class ForecastError extends ForecastState { - + /// Creates a [ForecastError] with the given error [message]. const ForecastError(this.message); + + /// The error message describing the failure. final String message; @override diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_bloc.dart index 000ada91..00092c71 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_bloc.dart @@ -1,22 +1,23 @@ -import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/usecases/get_no_show_report_usecase.dart'; import 'package:client_reports/src/presentation/blocs/no_show/no_show_event.dart'; import 'package:client_reports/src/presentation/blocs/no_show/no_show_state.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -/// BLoC that loads the [NoShowReport]. +/// BLoC that loads the [NoShowReport] via [GetNoShowReportUseCase]. class NoShowBloc extends Bloc with BlocErrorHandler { /// Creates a [NoShowBloc]. - NoShowBloc({required ReportsRepository reportsRepository}) - : _reportsRepository = reportsRepository, + NoShowBloc({required GetNoShowReportUseCase getNoShowReportUseCase}) + : _getNoShowReportUseCase = getNoShowReportUseCase, super(NoShowInitial()) { on(_onLoadNoShowReport); } - /// The repository used to fetch report data. - final ReportsRepository _reportsRepository; + /// The use case for fetching the no-show report. + final GetNoShowReportUseCase _getNoShowReportUseCase; Future _onLoadNoShowReport( LoadNoShowReport event, @@ -26,9 +27,11 @@ class NoShowBloc extends Bloc emit: emit, action: () async { emit(NoShowLoading()); - final NoShowReport report = await _reportsRepository.getNoShowReport( - startDate: event.startDate, - endDate: event.endDate, + final NoShowReport report = await _getNoShowReportUseCase.call( + DateRangeArguments( + startDate: event.startDate, + endDate: event.endDate, + ), ); emit(NoShowLoaded(report)); }, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_state.dart index 8e286465..c761dea0 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_state.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_state.dart @@ -1,30 +1,39 @@ -// 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:equatable/equatable.dart'; +import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Base state for the no-show report BLoC. abstract class NoShowState extends Equatable { + /// Creates a [NoShowState]. const NoShowState(); @override List get props => []; } +/// Initial state before any no-show report has been requested. class NoShowInitial extends NoShowState {} +/// State while the no-show report is loading. class NoShowLoading extends NoShowState {} +/// State when the no-show report has loaded successfully. class NoShowLoaded extends NoShowState { - + /// Creates a [NoShowLoaded] with the given [report]. const NoShowLoaded(this.report); + + /// The loaded no-show report data. final NoShowReport report; @override List get props => [report]; } +/// State when loading the no-show report has failed. class NoShowError extends NoShowState { - + /// Creates a [NoShowError] with the given error [message]. const NoShowError(this.message); + + /// The error message describing the failure. final String message; @override diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_bloc.dart index b64f09ef..cab097a2 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_bloc.dart @@ -1,22 +1,24 @@ -import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/usecases/get_performance_report_usecase.dart'; import 'package:client_reports/src/presentation/blocs/performance/performance_event.dart'; import 'package:client_reports/src/presentation/blocs/performance/performance_state.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -/// BLoC that loads the [PerformanceReport]. +/// BLoC that loads the [PerformanceReport] via [GetPerformanceReportUseCase]. class PerformanceBloc extends Bloc with BlocErrorHandler { /// Creates a [PerformanceBloc]. - PerformanceBloc({required ReportsRepository reportsRepository}) - : _reportsRepository = reportsRepository, + PerformanceBloc({ + required GetPerformanceReportUseCase getPerformanceReportUseCase, + }) : _getPerformanceReportUseCase = getPerformanceReportUseCase, super(PerformanceInitial()) { on(_onLoadPerformanceReport); } - /// The repository used to fetch report data. - final ReportsRepository _reportsRepository; + /// The use case for fetching the performance report. + final GetPerformanceReportUseCase _getPerformanceReportUseCase; Future _onLoadPerformanceReport( LoadPerformanceReport event, @@ -26,10 +28,11 @@ class PerformanceBloc extends Bloc emit: emit, action: () async { emit(PerformanceLoading()); - final PerformanceReport report = - await _reportsRepository.getPerformanceReport( - startDate: event.startDate, - endDate: event.endDate, + final PerformanceReport report = await _getPerformanceReportUseCase.call( + DateRangeArguments( + startDate: event.startDate, + endDate: event.endDate, + ), ); emit(PerformanceLoaded(report)); }, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_state.dart index e6ca9527..bc47680b 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_state.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_state.dart @@ -1,30 +1,39 @@ -// 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:equatable/equatable.dart'; +import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Base state for the performance report BLoC. abstract class PerformanceState extends Equatable { + /// Creates a [PerformanceState]. const PerformanceState(); @override List get props => []; } +/// Initial state before any performance report has been requested. class PerformanceInitial extends PerformanceState {} +/// State while the performance report is loading. class PerformanceLoading extends PerformanceState {} +/// State when the performance report has loaded successfully. class PerformanceLoaded extends PerformanceState { - + /// Creates a [PerformanceLoaded] with the given [report]. const PerformanceLoaded(this.report); + + /// The loaded performance report data. final PerformanceReport report; @override List get props => [report]; } +/// State when loading the performance report has failed. class PerformanceError extends PerformanceState { - + /// Creates a [PerformanceError] with the given error [message]. const PerformanceError(this.message); + + /// The error message describing the failure. final String message; @override diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_bloc.dart index e64c04cf..e2efff90 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_bloc.dart @@ -1,22 +1,23 @@ -import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/usecases/get_spend_report_usecase.dart'; import 'package:client_reports/src/presentation/blocs/spend/spend_event.dart'; import 'package:client_reports/src/presentation/blocs/spend/spend_state.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -/// BLoC that loads the [SpendReport]. +/// BLoC that loads the [SpendReport] via [GetSpendReportUseCase]. class SpendBloc extends Bloc with BlocErrorHandler { /// Creates a [SpendBloc]. - SpendBloc({required ReportsRepository reportsRepository}) - : _reportsRepository = reportsRepository, + SpendBloc({required GetSpendReportUseCase getSpendReportUseCase}) + : _getSpendReportUseCase = getSpendReportUseCase, super(SpendInitial()) { on(_onLoadSpendReport); } - /// The repository used to fetch report data. - final ReportsRepository _reportsRepository; + /// The use case for fetching the spend report. + final GetSpendReportUseCase _getSpendReportUseCase; Future _onLoadSpendReport( LoadSpendReport event, @@ -26,9 +27,11 @@ class SpendBloc extends Bloc emit: emit, action: () async { emit(SpendLoading()); - final SpendReport report = await _reportsRepository.getSpendReport( - startDate: event.startDate, - endDate: event.endDate, + final SpendReport report = await _getSpendReportUseCase.call( + DateRangeArguments( + startDate: event.startDate, + endDate: event.endDate, + ), ); emit(SpendLoaded(report)); }, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_state.dart index f8c949cd..011f1b66 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_state.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_state.dart @@ -1,30 +1,39 @@ -// 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:equatable/equatable.dart'; +import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; +/// Base state for the spend report BLoC. abstract class SpendState extends Equatable { + /// Creates a [SpendState]. const SpendState(); @override List get props => []; } +/// Initial state before any spend report has been requested. class SpendInitial extends SpendState {} +/// State while the spend report is loading. class SpendLoading extends SpendState {} +/// State when the spend report has loaded successfully. class SpendLoaded extends SpendState { - + /// Creates a [SpendLoaded] with the given [report]. const SpendLoaded(this.report); + + /// The loaded spend report data. final SpendReport report; @override List get props => [report]; } +/// State when loading the spend report has failed. class SpendError extends SpendState { - + /// Creates a [SpendError] with the given error [message]. const SpendError(this.message); + + /// The error message describing the failure. final String message; @override diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_bloc.dart index 4456877f..3f46e9b2 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_bloc.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_bloc.dart @@ -1,23 +1,25 @@ -import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/domain/arguments/date_range_arguments.dart'; +import 'package:client_reports/src/domain/usecases/get_reports_summary_usecase.dart'; import 'package:client_reports/src/presentation/blocs/summary/reports_summary_event.dart'; import 'package:client_reports/src/presentation/blocs/summary/reports_summary_state.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -/// BLoC that loads the high-level [ReportSummary] for the reports dashboard. +/// BLoC that loads the high-level [ReportSummary] via [GetReportsSummaryUseCase]. class ReportsSummaryBloc extends Bloc with BlocErrorHandler { /// Creates a [ReportsSummaryBloc]. - ReportsSummaryBloc({required ReportsRepository reportsRepository}) - : _reportsRepository = reportsRepository, + ReportsSummaryBloc({ + required GetReportsSummaryUseCase getReportsSummaryUseCase, + }) : _getReportsSummaryUseCase = getReportsSummaryUseCase, super(ReportsSummaryInitial()) { on(_onLoadReportsSummary); } - /// The repository used to fetch summary data. - final ReportsRepository _reportsRepository; + /// The use case for fetching the report summary. + final GetReportsSummaryUseCase _getReportsSummaryUseCase; Future _onLoadReportsSummary( LoadReportsSummary event, @@ -27,10 +29,11 @@ class ReportsSummaryBloc emit: emit, action: () async { emit(ReportsSummaryLoading()); - final ReportSummary summary = - await _reportsRepository.getReportsSummary( - startDate: event.startDate, - endDate: event.endDate, + final ReportSummary summary = await _getReportsSummaryUseCase.call( + DateRangeArguments( + startDate: event.startDate, + endDate: event.endDate, + ), ); emit(ReportsSummaryLoaded(summary)); }, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart index 35b7784f..0f80583c 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart @@ -1,6 +1,7 @@ -import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart'; import 'package:client_reports/src/presentation/blocs/coverage/coverage_event.dart'; import 'package:client_reports/src/presentation/blocs/coverage/coverage_state.dart'; +import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; @@ -10,9 +11,9 @@ import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; - +/// Page displaying the coverage report with summary and daily breakdown. class CoverageReportPage extends StatefulWidget { + /// Creates a [CoverageReportPage]. const CoverageReportPage({super.key}); @override @@ -86,17 +87,14 @@ class _CoverageReportPageState extends State { children: [ Text( context.t.client_reports.coverage_report.title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, + style: UiTypography.title1b.copyWith( color: UiColors.white, ), ), Text( context.t.client_reports.coverage_report .subtitle, - style: TextStyle( - fontSize: 12, + style: UiTypography.body3r.copyWith( color: UiColors.white.withOpacity(0.7), ), ), @@ -143,9 +141,7 @@ class _CoverageReportPageState extends State { // Daily List Text( context.t.client_reports.coverage_report.next_7_days, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, + style: UiTypography.body3b.copyWith( color: UiColors.textSecondary, letterSpacing: 1.2, ), @@ -177,17 +173,25 @@ class _CoverageReportPageState extends State { } } +/// Summary card for coverage metrics with icon and value. class _CoverageSummaryCard extends StatelessWidget { - const _CoverageSummaryCard({ required this.label, required this.value, required this.icon, required this.color, }); + + /// The metric label text. final String label; + + /// The metric value text. final String value; + + /// The icon to display. final IconData icon; + + /// The icon and accent color. final Color color; @override @@ -216,26 +220,42 @@ class _CoverageSummaryCard extends StatelessWidget { child: Icon(icon, size: 16, color: color), ), const SizedBox(height: 12), - Text(label, style: const TextStyle(fontSize: 12, color: UiColors.textSecondary)), + Text( + label, + style: UiTypography.body3r.copyWith( + color: UiColors.textSecondary, + ), + ), const SizedBox(height: 4), - Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + Text( + value, + style: UiTypography.headline3b, + ), ], ), ); } } +/// List item showing daily coverage with progress bar. class _CoverageListItem extends StatelessWidget { - const _CoverageListItem({ required this.date, required this.needed, required this.filled, required this.percentage, }); + + /// The formatted date string. final String date; + + /// The number of workers needed. final int needed; + + /// The number of workers filled. final int filled; + + /// The coverage percentage. final double percentage; @override @@ -262,7 +282,10 @@ class _CoverageListItem extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(date, style: const TextStyle(fontWeight: FontWeight.bold)), + Text( + date, + style: UiTypography.body2b, + ), const SizedBox(height: 4), // Progress Bar ClipRRect( @@ -283,13 +306,11 @@ class _CoverageListItem extends StatelessWidget { children: [ Text( '$filled/$needed', - style: const TextStyle(fontWeight: FontWeight.bold), + style: UiTypography.body2b, ), Text( '${percentage.toStringAsFixed(0)}%', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, + style: UiTypography.body3b.copyWith( color: statusColor, ), ), @@ -300,4 +321,3 @@ class _CoverageListItem extends StatelessWidget { ); } } - diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart index 062e03ee..7910c1f0 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart @@ -1,6 +1,7 @@ -import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_bloc.dart'; import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_event.dart'; import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_state.dart'; +import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; @@ -10,9 +11,9 @@ import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; - +/// Page displaying the daily operations report with shift stats and list. class DailyOpsReportPage extends StatefulWidget { + /// Creates a [DailyOpsReportPage]. const DailyOpsReportPage({super.key}); @override @@ -117,17 +118,14 @@ class _DailyOpsReportPageState extends State { Text( context.t.client_reports.daily_ops_report .title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, + style: UiTypography.title1b.copyWith( color: UiColors.white, ), ), Text( context.t.client_reports.daily_ops_report .subtitle, - style: TextStyle( - fontSize: 12, + style: UiTypography.body3r.copyWith( color: UiColors.white.withOpacity(0.7), ), ), @@ -135,52 +133,6 @@ class _DailyOpsReportPageState extends State { ), ], ), -/* - GestureDetector( - onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.t.client_reports.daily_ops_report - .placeholders.export_message, - ), - duration: const Duration(seconds: 2), - ), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - const Icon( - UiIcons.download, - size: 14, - color: UiColors.primary, - ), - const SizedBox(width: 6), - Text( - context.t.client_reports.quick_reports - .export_all - .split(' ') - .first, - style: const TextStyle( - color: UiColors.primary, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), -*/ ], ), ), @@ -223,10 +175,7 @@ class _DailyOpsReportPageState extends State { Text( DateFormat('MMM dd, yyyy') .format(_selectedDate), - style: const TextStyle( - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), + style: UiTypography.body2b, ), ], ), @@ -325,10 +274,7 @@ class _DailyOpsReportPageState extends State { context.t.client_reports.daily_ops_report .all_shifts_title .toUpperCase(), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, + style: UiTypography.body2b.copyWith( letterSpacing: 0.5, ), ), @@ -377,8 +323,8 @@ class _DailyOpsReportPageState extends State { } } +/// Stat card showing a metric with icon, value, and colored badge. class _OpsStatCard extends StatelessWidget { - const _OpsStatCard({ required this.label, required this.value, @@ -386,10 +332,20 @@ class _OpsStatCard extends StatelessWidget { required this.color, required this.icon, }); + + /// The metric label text. final String label; + + /// The metric value text. final String value; + + /// The badge sub-value text. final String subValue; + + /// The theme color for icon and badge. final Color color; + + /// The icon to display. final IconData icon; @override @@ -412,10 +368,8 @@ class _OpsStatCard extends StatelessWidget { Expanded( child: Text( label, - style: const TextStyle( - fontSize: 12, + style: UiTypography.body3m.copyWith( color: UiColors.textSecondary, - fontWeight: FontWeight.w600, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -428,15 +382,8 @@ class _OpsStatCard extends StatelessWidget { children: [ Text( value, - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), + style: UiTypography.display1b, ), - - //UiChip(label: subValue), - // Colored pill badge (matches prototype) Container( padding: const EdgeInsets.symmetric( horizontal: 8, @@ -448,9 +395,7 @@ class _OpsStatCard extends StatelessWidget { ), child: Text( subValue, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, + style: UiTypography.body3b.copyWith( color: color, ), ), @@ -463,8 +408,8 @@ class _OpsStatCard extends StatelessWidget { } } +/// A single shift row in the daily operations list. class _ShiftListItem extends StatelessWidget { - const _ShiftListItem({ required this.title, required this.location, @@ -474,12 +419,26 @@ class _ShiftListItem extends StatelessWidget { required this.status, required this.statusColor, }); + + /// The shift role name. final String title; + + /// The shift location or ID. final String location; + + /// The formatted time range string. final String time; + + /// The workers ratio string (e.g. "3/5"). final String workers; + + /// The rate string. final String rate; + + /// The status label text. final String status; + + /// The color for the status badge. final Color statusColor; @override @@ -508,11 +467,7 @@ class _ShiftListItem extends StatelessWidget { children: [ Text( title, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - color: UiColors.textPrimary, - ), + style: UiTypography.body2b, ), const SizedBox(height: 4), Row( @@ -526,8 +481,7 @@ class _ShiftListItem extends StatelessWidget { Expanded( child: Text( location, - style: const TextStyle( - fontSize: 11, + style: UiTypography.titleUppercase4m.copyWith( color: UiColors.textSecondary, ), maxLines: 1, @@ -548,10 +502,8 @@ class _ShiftListItem extends StatelessWidget { ), child: Text( status.toUpperCase(), - style: TextStyle( + style: UiTypography.footnote2b.copyWith( color: statusColor, - fontSize: 10, - fontWeight: FontWeight.bold, ), ), ), @@ -585,6 +537,7 @@ class _ShiftListItem extends StatelessWidget { ); } + /// Builds a small info item with icon, label, and value. Widget _infoItem( BuildContext context, IconData icon, String label, String value) { return Row( @@ -596,13 +549,13 @@ class _ShiftListItem extends StatelessWidget { children: [ Text( label, - style: const TextStyle(fontSize: 10, color: UiColors.pinInactive), + style: UiTypography.footnote2r.copyWith( + color: UiColors.textInactive, + ), ), Text( value, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, + style: UiTypography.titleUppercase4b.copyWith( color: UiColors.textDescription, ), ), @@ -612,4 +565,3 @@ class _ShiftListItem extends StatelessWidget { ); } } - diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart index 5856b82e..eef44b28 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart @@ -100,12 +100,13 @@ class _ForecastReportPageState extends State { ); } + /// Builds the gradient header with back button and title. Widget _buildHeader(BuildContext context) { return Container( padding: const EdgeInsets.only(top: 60, left: 20, right: 20, bottom: 40), decoration: const BoxDecoration( gradient: LinearGradient( - colors: [UiColors.primary, Color(0xFF0020A0)], + colors: [UiColors.primary, UiColors.buttonPrimaryHover], begin: Alignment.topLeft, end: Alignment.bottomRight, ), @@ -150,6 +151,7 @@ class _ForecastReportPageState extends State { ); } + /// Builds the 2x2 metrics grid. Widget _buildMetricsGrid(BuildContext context, ForecastReport report) { final TranslationsClientReportsForecastReportEn t = context.t.client_reports.forecast_report; @@ -186,8 +188,8 @@ class _ForecastReportPageState extends State { label: t.metrics.total_shifts, value: report.totalShifts.toString(), badgeText: t.badges.scheduled, - iconColor: const Color(0xFF9333EA), - badgeColor: const Color(0xFFF3E8FF), + iconColor: UiColors.primary, + badgeColor: UiColors.tagInProgress, ), _MetricCard( icon: UiIcons.users, @@ -201,6 +203,7 @@ class _ForecastReportPageState extends State { ); } + /// Builds the chart section with weekly spend trend. Widget _buildChartSection(BuildContext context, ForecastReport report) { return Container( height: 320, @@ -231,13 +234,14 @@ class _ForecastReportPageState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ for (int i = 0; i < report.weeks.length; i++) ...[ - Text('W${i + 1}', - style: const TextStyle( - color: UiColors.textSecondary, fontSize: 12)), + Text( + 'W${i + 1}', + style: UiTypography.body3r.copyWith( + color: UiColors.textSecondary, + ), + ), if (i < report.weeks.length - 1) - const Text('', - style: TextStyle( - color: UiColors.transparent, fontSize: 12)), + const SizedBox.shrink(), ], ], ), @@ -247,6 +251,7 @@ class _ForecastReportPageState extends State { } } +/// Metric card widget for the forecast grid. class _MetricCard extends StatelessWidget { const _MetricCard({ required this.icon, @@ -257,11 +262,22 @@ class _MetricCard extends StatelessWidget { required this.badgeColor, }); + /// The metric icon. final IconData icon; + + /// The metric label text. final String label; + + /// The metric value text. final String value; + + /// The badge text. final String badgeText; + + /// The icon tint color. final Color iconColor; + + /// The badge background color. final Color badgeColor; @override @@ -308,11 +324,7 @@ class _MetricCard extends StatelessWidget { ), child: Text( badgeText, - style: UiTypography.footnote1r.copyWith( - color: UiColors.textPrimary, - fontSize: 10, - fontWeight: FontWeight.w600, - ), + style: UiTypography.footnote2b, ), ), ], @@ -328,7 +340,10 @@ class _WeeklyBreakdownItem extends StatelessWidget { required this.weekIndex, }); + /// The forecast week data. final ForecastWeek week; + + /// The 1-based week index. final int weekIndex; @override @@ -386,6 +401,7 @@ class _WeeklyBreakdownItem extends StatelessWidget { ); } + /// Builds a label/value stat column. Widget _buildStat(String label, String value) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -402,6 +418,7 @@ class _WeeklyBreakdownItem extends StatelessWidget { class _ForecastChart extends StatelessWidget { const _ForecastChart({required this.weeks}); + /// The weekly forecast data points. final List weeks; @override diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart index 0f731caf..2cdd6c52 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart @@ -1,19 +1,19 @@ -// 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_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; import 'package:client_reports/src/presentation/blocs/no_show/no_show_bloc.dart'; import 'package:client_reports/src/presentation/blocs/no_show/no_show_event.dart'; import 'package:client_reports/src/presentation/blocs/no_show/no_show_state.dart'; +import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:intl/intl.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; -import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; - +/// Page displaying the no-show report with summary metrics and worker cards. class NoShowReportPage extends StatefulWidget { + /// Creates a [NoShowReportPage]. const NoShowReportPage({super.key}); @override @@ -26,7 +26,7 @@ class _NoShowReportPageState extends State { @override Widget build(BuildContext context) { - return BlocProvider( + return BlocProvider( create: (BuildContext context) => Modular.get() ..add(LoadNoShowReport(startDate: _startDate, endDate: _endDate)), child: Scaffold( @@ -90,16 +90,13 @@ class _NoShowReportPageState extends State { children: [ Text( context.t.client_reports.no_show_report.title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, + style: UiTypography.title1b.copyWith( color: UiColors.white, ), ), Text( context.t.client_reports.no_show_report.subtitle, - style: TextStyle( - fontSize: 12, + style: UiTypography.body3r.copyWith( color: UiColors.white.withOpacity(0.6), ), ), @@ -107,47 +104,6 @@ class _NoShowReportPageState extends State { ), ], ), - // Export button -/* - GestureDetector( - onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Export coming soon'), - duration: Duration(seconds: 2), - ), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(8), - ), - child: const Row( - children: [ - Icon( - UiIcons.download, - size: 14, - color: Color(0xFF1A1A2E), - ), - SizedBox(width: 6), - Text( - 'Export', - style: TextStyle( - color: Color(0xFF1A1A2E), - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), -*/ ], ), ), @@ -159,7 +115,7 @@ class _NoShowReportPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 3-chip summary row (matches prototype) + // 3-chip summary row Row( children: [ Expanded( @@ -198,9 +154,7 @@ class _NoShowReportPageState extends State { Text( context.t.client_reports.no_show_report .workers_list_title, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, + style: UiTypography.body3b.copyWith( color: UiColors.textSecondary, letterSpacing: 1.2, ), @@ -214,7 +168,7 @@ class _NoShowReportPageState extends State { alignment: Alignment.center, child: Text( context.t.client_reports.no_show_report.empty_state, - style: const TextStyle( + style: UiTypography.body2r.copyWith( color: UiColors.textSecondary, ), ), @@ -241,18 +195,25 @@ class _NoShowReportPageState extends State { } } -// Summary chip (top 3 stats) +/// Summary chip showing a single metric with icon. class _SummaryChip extends StatelessWidget { - const _SummaryChip({ required this.icon, required this.iconColor, required this.label, required this.value, }); + + /// The icon to display. final IconData icon; + + /// The icon and label color. final Color iconColor; + + /// The metric label text. final String label; + + /// The metric value text. final String value; @override @@ -280,10 +241,8 @@ class _SummaryChip extends StatelessWidget { Expanded( child: Text( label, - style: TextStyle( - fontSize: 10, + style: UiTypography.footnote2b.copyWith( color: iconColor, - fontWeight: FontWeight.w600, ), overflow: TextOverflow.ellipsis, ), @@ -293,11 +252,7 @@ class _SummaryChip extends StatelessWidget { const SizedBox(height: 8), Text( value, - style: const TextStyle( - fontSize: 26, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), + style: UiTypography.display1b, ), ], ), @@ -305,24 +260,28 @@ class _SummaryChip extends StatelessWidget { } } -// ” Worker card with risk badge + latest incident ”””””””””””””” +/// Worker card with risk badge and latest incident date. class _WorkerCard extends StatelessWidget { - const _WorkerCard({required this.worker}); + + /// The worker item data. final NoShowWorkerItem worker; + /// Returns the localized risk label. String _riskLabel(BuildContext context, String riskStatus) { if (riskStatus == 'HIGH') return context.t.client_reports.no_show_report.risks.high; if (riskStatus == 'MEDIUM') return context.t.client_reports.no_show_report.risks.medium; return context.t.client_reports.no_show_report.risks.low; } + /// Returns the color for the given risk status. Color _riskColor(String riskStatus) { if (riskStatus == 'HIGH') return UiColors.error; if (riskStatus == 'MEDIUM') return UiColors.textWarning; return UiColors.success; } + /// Returns the background color for the given risk status. Color _riskBg(String riskStatus) { if (riskStatus == 'HIGH') return UiColors.tagError; if (riskStatus == 'MEDIUM') return UiColors.tagPending; @@ -374,16 +333,11 @@ class _WorkerCard extends StatelessWidget { children: [ Text( worker.staffName, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - color: UiColors.textPrimary, - ), + style: UiTypography.body2b, ), Text( context.t.client_reports.no_show_report.no_show_count(count: worker.incidentCount.toString()), - style: const TextStyle( - fontSize: 12, + style: UiTypography.body3r.copyWith( color: UiColors.textSecondary, ), ), @@ -403,9 +357,7 @@ class _WorkerCard extends StatelessWidget { ), child: Text( riskLabel, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, + style: UiTypography.titleUppercase4b.copyWith( color: riskColor, ), ), @@ -420,8 +372,7 @@ class _WorkerCard extends StatelessWidget { children: [ Text( context.t.client_reports.no_show_report.latest_incident, - style: const TextStyle( - fontSize: 11, + style: UiTypography.titleUppercase4m.copyWith( color: UiColors.textSecondary, ), ), @@ -430,10 +381,8 @@ class _WorkerCard extends StatelessWidget { ? DateFormat('MMM dd, yyyy') .format(worker.incidents.first.date) : '-', - style: const TextStyle( - fontSize: 11, + style: UiTypography.titleUppercase4m.copyWith( color: UiColors.textSecondary, - fontWeight: FontWeight.w500, ), ), ], @@ -443,6 +392,3 @@ class _WorkerCard extends StatelessWidget { ); } } - -// Insight line - diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart index 56d601f5..1b27bc4c 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart @@ -1,6 +1,7 @@ -import 'package:client_reports/src/presentation/blocs/performance/performance_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/performance/performance_bloc.dart'; import 'package:client_reports/src/presentation/blocs/performance/performance_event.dart'; import 'package:client_reports/src/presentation/blocs/performance/performance_state.dart'; +import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; @@ -9,9 +10,9 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart'; - +/// Page displaying the performance report with overall score and KPI breakdown. class PerformanceReportPage extends StatefulWidget { + /// Creates a [PerformanceReportPage]. const PerformanceReportPage({super.key}); @override @@ -102,18 +103,18 @@ class _PerformanceReportPageState extends State { ), _KpiData( icon: UiIcons.clock, - iconColor: const Color(0xFF9B59B6), + iconColor: UiColors.primary, label: context.t.client_reports.performance_report.kpis.on_time_rate, target: context.t.client_reports.performance_report.kpis.target_percent(percent: '97'), value: onTimeRate, displayValue: '${onTimeRate.toStringAsFixed(0)}%', - barColor: const Color(0xFF9B59B6), + barColor: UiColors.primary, met: onTimeRate >= 97, close: onTimeRate >= 92, ), _KpiData( icon: UiIcons.trendingUp, - iconColor: const Color(0xFFF39C12), + iconColor: UiColors.textWarning, label: context.t.client_reports.performance_report.kpis.avg_fill_time, target: context.t.client_reports.performance_report.kpis.target_hours(hours: '3'), value: avgFillTimeHours == 0 @@ -121,7 +122,7 @@ class _PerformanceReportPageState extends State { : (3 / avgFillTimeHours * 100).clamp(0, 100), displayValue: '${avgFillTimeHours.toStringAsFixed(1)} hrs', - barColor: const Color(0xFFF39C12), + barColor: UiColors.textWarning, met: avgFillTimeHours <= 3, close: avgFillTimeHours <= 4, ), @@ -173,17 +174,14 @@ class _PerformanceReportPageState extends State { Text( context.t.client_reports.performance_report .title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, + style: UiTypography.title1b.copyWith( color: UiColors.white, ), ), Text( context.t.client_reports.performance_report .subtitle, - style: TextStyle( - fontSize: 12, + style: UiTypography.body3r.copyWith( color: UiColors.white.withOpacity(0.7), ), ), @@ -191,49 +189,11 @@ class _PerformanceReportPageState extends State { ), ], ), - // Export -/* - GestureDetector( - onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Export coming soon'), - duration: Duration(seconds: 2), - ), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(8), - ), - child: const Row( - children: [ - Icon(UiIcons.download, - size: 14, color: UiColors.primary), - SizedBox(width: 6), - Text( - 'Export', - style: TextStyle( - color: UiColors.primary, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), -*/ ], ), ), - // ” Content ””””””””””””””””””””” + // Content Transform.translate( offset: const Offset(0, -16), child: Padding( @@ -248,7 +208,7 @@ class _PerformanceReportPageState extends State { horizontal: 20, ), decoration: BoxDecoration( - color: const Color(0xFFF0F4FF), + color: UiColors.tagInProgress, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( @@ -268,17 +228,14 @@ class _PerformanceReportPageState extends State { const SizedBox(height: 12), Text( context.t.client_reports.performance_report.overall_score.title, - style: const TextStyle( - fontSize: 13, + style: UiTypography.body3m.copyWith( color: UiColors.textSecondary, ), ), const SizedBox(height: 8), Text( '${overallScore.toStringAsFixed(0)}/100', - style: const TextStyle( - fontSize: 48, - fontWeight: FontWeight.bold, + style: UiTypography.secondaryDisplay2b.copyWith( color: UiColors.primary, ), ), @@ -294,9 +251,7 @@ class _PerformanceReportPageState extends State { ), child: Text( scoreLabel, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, + style: UiTypography.body3b.copyWith( color: scoreLabelColor, ), ), @@ -325,9 +280,7 @@ class _PerformanceReportPageState extends State { children: [ Text( context.t.client_reports.performance_report.kpis_title, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, + style: UiTypography.titleUppercase4b.copyWith( color: UiColors.textSecondary, letterSpacing: 1.2, ), @@ -357,9 +310,8 @@ class _PerformanceReportPageState extends State { } } -// ” KPI data model ”””””””””””””””””””””””””””””” +/// Data model for a single KPI row. class _KpiData { - const _KpiData({ required this.icon, required this.iconColor, @@ -371,21 +323,40 @@ class _KpiData { required this.met, required this.close, }); + + /// The KPI icon. final IconData icon; + + /// The icon tint color. final Color iconColor; + + /// The KPI label text. final String label; + + /// The target description text. final String target; - final double value; // 0-100 for bar + + /// The KPI value (0-100) for the progress bar. + final double value; + + /// The formatted display value string. final String displayValue; + + /// The progress bar color. final Color barColor; + + /// Whether the KPI target has been met. final bool met; + + /// Whether the KPI is close to the target. final bool close; } -// ” KPI row widget ”””””””””””””””””””””””””””””” +/// Widget rendering a single KPI row with label, progress bar, and badge. class _KpiRow extends StatelessWidget { - const _KpiRow({required this.kpi}); + + /// The KPI data to render. final _KpiData kpi; @override @@ -428,33 +399,24 @@ class _KpiRow extends StatelessWidget { children: [ Text( kpi.label, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: UiColors.textPrimary, - ), + style: UiTypography.body3m, ), Text( kpi.target, - style: const TextStyle( - fontSize: 11, + style: UiTypography.titleUppercase4m.copyWith( color: UiColors.textSecondary, ), ), ], ), ), - // Value + badge inline (matches prototype) + // Value + badge inline Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( kpi.displayValue, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), + style: UiTypography.body1b, ), const SizedBox(width: 6), Container( @@ -468,9 +430,7 @@ class _KpiRow extends StatelessWidget { ), child: Text( badgeText, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, + style: UiTypography.footnote2b.copyWith( color: badgeColor, ), ), @@ -494,4 +454,3 @@ class _KpiRow extends StatelessWidget { ); } } - diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart index fbcd3c38..de02465f 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart @@ -97,16 +97,13 @@ class _SpendReportPageState extends State { children: [ Text( context.t.client_reports.spend_report.title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, + style: UiTypography.title1b.copyWith( color: UiColors.white, ), ), Text( context.t.client_reports.spend_report.subtitle, - style: TextStyle( - fontSize: 12, + style: UiTypography.body3r.copyWith( color: UiColors.white.withOpacity(0.7), ), ), @@ -179,11 +176,7 @@ class _SpendReportPageState extends State { Text( context.t.client_reports.spend_report .chart_title, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), + style: UiTypography.body2b, ), const SizedBox(height: 32), Expanded( @@ -222,6 +215,7 @@ class _SpendReportPageState extends State { class _SpendBarChart extends StatelessWidget { const _SpendBarChart({required this.chartData}); + /// The chart data points to render. final List chartData; @override @@ -245,9 +239,8 @@ class _SpendBarChart extends StatelessWidget { BarChartRodData rod, int rodIndex) { return BarTooltipItem( '\$${rod.toY.round()}', - const TextStyle( + UiTypography.body2b.copyWith( color: UiColors.white, - fontWeight: FontWeight.bold, ), ); }, @@ -269,9 +262,8 @@ class _SpendBarChart extends StatelessWidget { space: 8, child: Text( DateFormat('E').format(date), - style: const TextStyle( + style: UiTypography.titleUppercase4m.copyWith( color: UiColors.textSecondary, - fontSize: 11, ), ), ); @@ -288,9 +280,8 @@ class _SpendBarChart extends StatelessWidget { axisSide: meta.axisSide, child: Text( '\$${(value / 1000).toStringAsFixed(0)}k', - style: const TextStyle( + style: UiTypography.footnote2r.copyWith( color: UiColors.textSecondary, - fontSize: 10, ), ), ); @@ -333,6 +324,7 @@ class _SpendBarChart extends StatelessWidget { } } +/// Stat card showing a spend metric with icon, value, and pill badge. class _SpendStatCard extends StatelessWidget { const _SpendStatCard({ required this.label, @@ -342,10 +334,19 @@ class _SpendStatCard extends StatelessWidget { required this.icon, }); + /// The metric label text. final String label; + + /// The metric value text. final String value; + + /// The pill badge text. final String pillText; + + /// The theme color for the icon and pill. final Color themeColor; + + /// The icon to display. final IconData icon; @override @@ -373,10 +374,8 @@ class _SpendStatCard extends StatelessWidget { Expanded( child: Text( label, - style: const TextStyle( - fontSize: 12, + style: UiTypography.body3m.copyWith( color: UiColors.textSecondary, - fontWeight: FontWeight.w500, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -387,11 +386,7 @@ class _SpendStatCard extends StatelessWidget { const SizedBox(height: 12), Text( value, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), + style: UiTypography.headline1b, ), const SizedBox(height: 12), Container( @@ -402,9 +397,7 @@ class _SpendStatCard extends StatelessWidget { ), child: Text( pillText, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, + style: UiTypography.footnote2b.copyWith( color: themeColor, ), ), @@ -419,6 +412,7 @@ class _SpendStatCard extends StatelessWidget { class _SpendByCategoryCard extends StatelessWidget { const _SpendByCategoryCard({required this.categories}); + /// The category breakdown items. final List categories; @override @@ -441,11 +435,7 @@ class _SpendByCategoryCard extends StatelessWidget { children: [ Text( context.t.client_reports.spend_report.spend_by_industry, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), + style: UiTypography.body2b, ), const SizedBox(height: 24), if (categories.isEmpty) @@ -454,7 +444,9 @@ class _SpendByCategoryCard extends StatelessWidget { padding: const EdgeInsets.all(16.0), child: Text( context.t.client_reports.spend_report.no_industry_data, - style: const TextStyle(color: UiColors.textSecondary), + style: UiTypography.body2r.copyWith( + color: UiColors.textSecondary, + ), ), ), ) @@ -469,8 +461,7 @@ class _SpendByCategoryCard extends StatelessWidget { children: [ Text( item.category, - style: const TextStyle( - fontSize: 13, + style: UiTypography.body3m.copyWith( color: UiColors.textSecondary, ), ), @@ -478,11 +469,7 @@ class _SpendByCategoryCard extends StatelessWidget { NumberFormat.currency( symbol: r'$', decimalDigits: 0) .format(item.amountCents / 100), - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), + style: UiTypography.body3b, ), ], ), @@ -500,8 +487,7 @@ class _SpendByCategoryCard extends StatelessWidget { Text( context.t.client_reports.spend_report.percent_total( percent: item.percentage.toStringAsFixed(1)), - style: const TextStyle( - fontSize: 10, + style: UiTypography.footnote2r.copyWith( color: UiColors.textDescription, ), ), diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metric_card.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metric_card.dart index 3040f6ed..52fc9135 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metric_card.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metric_card.dart @@ -75,11 +75,7 @@ class MetricCard extends StatelessWidget { children: [ Text( value, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), + style: UiTypography.headline1b, ), const SizedBox(height: 4), Container( diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid.dart index 4436b5c6..dcd2ece3 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid.dart @@ -49,8 +49,9 @@ class MetricsGrid extends StatelessWidget { Expanded( child: Text( state.message, - style: - const TextStyle(color: UiColors.error, fontSize: 12), + style: UiTypography.body3r.copyWith( + color: UiColors.error, + ), ), ), ], diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/quick_reports_section.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/quick_reports_section.dart index 5ca80eb6..200fb4f2 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/quick_reports_section.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/quick_reports_section.dart @@ -1,10 +1,8 @@ -// 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:client_reports/src/presentation/widgets/reports_page/report_card.dart'; import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'report_card.dart'; - /// A section displaying quick access report cards. /// /// Shows 4 quick report cards for: diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/report_card.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/report_card.dart index f5c73970..9c2b1cd5 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/report_card.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/report_card.dart @@ -69,11 +69,7 @@ class ReportCard extends StatelessWidget { children: [ Text( name, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: UiColors.textPrimary, - ), + style: UiTypography.body2m, maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -88,8 +84,7 @@ class ReportCard extends StatelessWidget { const SizedBox(width: 4), Text( context.t.client_reports.quick_reports.two_click_export, - style: const TextStyle( - fontSize: 12, + style: UiTypography.body3r.copyWith( color: UiColors.textSecondary, ), ), diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/reports_header.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/reports_header.dart index 124a2c35..67c26db2 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/reports_header.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/reports_header.dart @@ -64,9 +64,7 @@ class ReportsHeader extends StatelessWidget { const SizedBox(width: 12), Text( context.t.client_reports.title, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, + style: UiTypography.headline3b.copyWith( color: UiColors.white, ), ), @@ -98,12 +96,9 @@ class ReportsHeader extends StatelessWidget { ), labelColor: UiColors.primary, unselectedLabelColor: UiColors.white, - labelStyle: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - ), + labelStyle: UiTypography.body2m, indicatorSize: TabBarIndicatorSize.tab, - dividerColor: Colors.transparent, + dividerColor: UiColors.transparent, tabs: [ Tab(text: context.t.client_reports.tabs.today), Tab(text: context.t.client_reports.tabs.week), diff --git a/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart b/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart index 9bdc8fb6..3902f714 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart @@ -1,5 +1,12 @@ import 'package:client_reports/src/data/repositories_impl/reports_repository_impl.dart'; import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/domain/usecases/get_coverage_report_usecase.dart'; +import 'package:client_reports/src/domain/usecases/get_daily_ops_report_usecase.dart'; +import 'package:client_reports/src/domain/usecases/get_forecast_report_usecase.dart'; +import 'package:client_reports/src/domain/usecases/get_no_show_report_usecase.dart'; +import 'package:client_reports/src/domain/usecases/get_performance_report_usecase.dart'; +import 'package:client_reports/src/domain/usecases/get_reports_summary_usecase.dart'; +import 'package:client_reports/src/domain/usecases/get_spend_report_usecase.dart'; import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart'; import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_bloc.dart'; import 'package:client_reports/src/presentation/blocs/forecast/forecast_bloc.dart'; @@ -25,16 +32,84 @@ class ReportsModule extends Module { @override void binds(Injector i) { - i.addLazySingleton( + // ── Repository ─────────────────────────────────────────────────────────── + i.addLazySingleton( () => ReportsRepositoryImpl(apiService: i.get()), ); - i.add(DailyOpsBloc.new); - i.add(SpendBloc.new); - i.add(CoverageBloc.new); - i.add(ForecastBloc.new); - i.add(PerformanceBloc.new); - i.add(NoShowBloc.new); - i.add(ReportsSummaryBloc.new); + + // ── Use Cases ──────────────────────────────────────────────────────────── + i.add( + () => GetDailyOpsReportUseCase( + i.get(), + ), + ); + i.add( + () => GetSpendReportUseCase( + i.get(), + ), + ); + i.add( + () => GetCoverageReportUseCase( + i.get(), + ), + ); + i.add( + () => GetForecastReportUseCase( + i.get(), + ), + ); + i.add( + () => GetPerformanceReportUseCase( + i.get(), + ), + ); + i.add( + () => GetNoShowReportUseCase( + i.get(), + ), + ); + i.add( + () => GetReportsSummaryUseCase( + i.get(), + ), + ); + + // ── BLoCs ──────────────────────────────────────────────────────────────── + i.add( + () => DailyOpsBloc( + getDailyOpsReportUseCase: i.get(), + ), + ); + i.add( + () => SpendBloc( + getSpendReportUseCase: i.get(), + ), + ); + i.add( + () => CoverageBloc( + getCoverageReportUseCase: i.get(), + ), + ); + i.add( + () => ForecastBloc( + getForecastReportUseCase: i.get(), + ), + ); + i.add( + () => PerformanceBloc( + getPerformanceReportUseCase: i.get(), + ), + ); + i.add( + () => NoShowBloc( + getNoShowReportUseCase: i.get(), + ), + ); + i.add( + () => ReportsSummaryBloc( + getReportsSummaryUseCase: i.get(), + ), + ); } @override diff --git a/apps/mobile/packages/features/client/settings/lib/client_settings.dart b/apps/mobile/packages/features/client/settings/lib/client_settings.dart index 770d4216..37a41377 100644 --- a/apps/mobile/packages/features/client/settings/lib/client_settings.dart +++ b/apps/mobile/packages/features/client/settings/lib/client_settings.dart @@ -21,7 +21,10 @@ class ClientSettingsModule extends Module { void binds(Injector i) { // Repositories i.addLazySingleton( - () => SettingsRepositoryImpl(apiService: i.get()), + () => SettingsRepositoryImpl( + apiService: i.get(), + firebaseAuthService: i.get(), + ), ); // UseCases diff --git a/apps/mobile/packages/features/client/settings/lib/src/data/repositories_impl/settings_repository_impl.dart b/apps/mobile/packages/features/client/settings/lib/src/data/repositories_impl/settings_repository_impl.dart index 15ff9337..b2fd9182 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/data/repositories_impl/settings_repository_impl.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/data/repositories_impl/settings_repository_impl.dart @@ -1,6 +1,5 @@ import 'dart:developer' as developer; -import 'package:firebase_auth/firebase_auth.dart' as firebase; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -8,16 +7,22 @@ import 'package:client_settings/src/domain/repositories/settings_repository_inte /// Implementation of [SettingsRepositoryInterface]. /// -/// Uses V2 API for server-side token revocation and Firebase Auth for local -/// sign-out. Clears the [ClientSessionStore] on sign-out. +/// Uses V2 API for server-side token revocation and [FirebaseAuthService] +/// from core for local sign-out. Clears the [ClientSessionStore] on sign-out. class SettingsRepositoryImpl implements SettingsRepositoryInterface { - /// Creates a [SettingsRepositoryImpl] with the required [BaseApiService]. - const SettingsRepositoryImpl({required BaseApiService apiService}) - : _apiService = apiService; + /// Creates a [SettingsRepositoryImpl] with the required dependencies. + const SettingsRepositoryImpl({ + required BaseApiService apiService, + required FirebaseAuthService firebaseAuthService, + }) : _apiService = apiService, + _firebaseAuthService = firebaseAuthService; /// The V2 API service for backend calls. final BaseApiService _apiService; + /// Core Firebase Auth service for local sign-out. + final FirebaseAuthService _firebaseAuthService; + @override Future signOut() async { try { @@ -31,8 +36,8 @@ class SettingsRepositoryImpl implements SettingsRepositoryInterface { // Continue with local sign-out even if server-side fails. } - // Step 2: Sign out from local Firebase Auth. - await firebase.FirebaseAuth.instance.signOut(); + // Step 2: Sign out from local Firebase Auth via core service. + await _firebaseAuthService.signOut(); // Step 3: Clear the client session store. ClientSessionStore.instance.clear(); diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart index eef1331e..f27cb2db 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart @@ -67,7 +67,7 @@ class SettingsActions extends StatelessWidget { style: UiTypography.headline3m.textPrimary, ), content: Text( - 'Are you sure you want to log out?', + t.client_settings.profile.log_out_confirmation, style: UiTypography.body2r.textSecondary, ), actions: [ @@ -77,7 +77,7 @@ class SettingsActions extends StatelessWidget { ), UiButton.secondary( text: t.common.cancel, - onPressed: () => Modular.to.pop(), + onPressed: () => Modular.to.popSafe(), ), ], ), diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart index 648a1acc..2363fdc9 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart @@ -3,6 +3,7 @@ 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:krow_core/core.dart'; import '../../blocs/client_settings_bloc.dart'; @@ -78,7 +79,7 @@ class SettingsLogout extends StatelessWidget { // Cancel button UiButton.secondary( text: t.common.cancel, - onPressed: () => Modular.to.pop(), + onPressed: () => Modular.to.popSafe(), ), ], ), diff --git a/apps/mobile/packages/features/client/settings/pubspec.yaml b/apps/mobile/packages/features/client/settings/pubspec.yaml index c052e2ee..522463f6 100644 --- a/apps/mobile/packages/features/client/settings/pubspec.yaml +++ b/apps/mobile/packages/features/client/settings/pubspec.yaml @@ -14,8 +14,7 @@ dependencies: flutter_bloc: ^8.1.0 flutter_modular: ^6.3.0 equatable: ^2.0.5 - firebase_auth: ^6.1.2 - + # Architecture Packages design_system: path: ../../../design_system diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index b3c92f14..77737c2a 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:firebase_auth/firebase_auth.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart' as domain; import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart'; @@ -9,55 +8,42 @@ import 'package:staff_authentication/src/utils/test_phone_numbers.dart'; /// V2 API implementation of [AuthRepositoryInterface]. /// -/// Uses the Firebase Auth SDK for client-side phone verification, +/// Uses [FirebaseAuthService] from core for client-side phone verification, /// then calls the V2 unified API to hydrate the session context. -/// All Data Connect dependencies have been removed. +/// All direct `firebase_auth` imports have been removed in favour of the +/// core abstraction. class AuthRepositoryImpl implements AuthRepositoryInterface { /// Creates an [AuthRepositoryImpl]. /// - /// Requires a [domain.BaseApiService] for V2 API calls. - AuthRepositoryImpl({required domain.BaseApiService apiService}) - : _apiService = apiService; + /// Requires a [domain.BaseApiService] for V2 API calls and a + /// [FirebaseAuthService] for client-side Firebase Auth operations. + AuthRepositoryImpl({ + required domain.BaseApiService apiService, + required FirebaseAuthService firebaseAuthService, + }) : _apiService = apiService, + _firebaseAuthService = firebaseAuthService; /// The V2 API service for backend calls. final domain.BaseApiService _apiService; - /// Firebase Auth instance for client-side phone verification. - final FirebaseAuth _auth = FirebaseAuth.instance; - - /// Completer for the pending phone verification request. - Completer? _pendingVerification; + /// Core Firebase Auth service abstraction. + final FirebaseAuthService _firebaseAuthService; @override - Stream get currentUser => - _auth.authStateChanges().map((User? firebaseUser) { - if (firebaseUser == null) { - return null; - } - - return domain.User( - id: firebaseUser.uid, - email: firebaseUser.email, - displayName: firebaseUser.displayName, - phone: firebaseUser.phoneNumber, - status: domain.UserStatus.active, - ); - }); + Stream get currentUser => _firebaseAuthService.authStateChanges; /// Initiates phone verification via the V2 API. /// /// Calls `POST /auth/staff/phone/start` first. The server decides the /// verification mode: - /// - `CLIENT_FIREBASE_SDK` — mobile must do Firebase phone auth client-side - /// - `IDENTITY_TOOLKIT_SMS` — server sent the SMS, returns `sessionInfo` + /// - `CLIENT_FIREBASE_SDK` -- mobile must do Firebase phone auth client-side + /// - `IDENTITY_TOOLKIT_SMS` -- server sent the SMS, returns `sessionInfo` /// /// For mobile without recaptcha tokens, the server returns /// `CLIENT_FIREBASE_SDK` and we fall back to the Firebase Auth SDK. @override Future signInWithPhone({required String phoneNumber}) async { // Step 1: Try V2 to let the server decide the auth mode. - // Falls back to CLIENT_FIREBASE_SDK if the API call fails (e.g. server - // down, 500, or non-JSON response). String mode = 'CLIENT_FIREBASE_SDK'; String? sessionInfo; @@ -74,7 +60,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { mode = startData['mode'] as String? ?? 'CLIENT_FIREBASE_SDK'; sessionInfo = startData['sessionInfo'] as String?; } catch (_) { - // V2 start call failed — fall back to client-side Firebase SDK. + // V2 start call failed -- fall back to client-side Firebase SDK. } // Step 2: If server sent the SMS, return the sessionInfo for verify step. @@ -82,55 +68,16 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { return sessionInfo; } - // Step 3: CLIENT_FIREBASE_SDK mode — do Firebase phone auth client-side. - final Completer completer = Completer(); - _pendingVerification = completer; - - await _auth.verifyPhoneNumber( + // Step 3: CLIENT_FIREBASE_SDK mode -- do Firebase phone auth client-side. + return _firebaseAuthService.verifyPhoneNumber( phoneNumber: phoneNumber, - verificationCompleted: (PhoneAuthCredential credential) { - if (TestPhoneNumbers.isTestNumber(phoneNumber)) return; - }, - verificationFailed: (FirebaseAuthException e) { - if (!completer.isCompleted) { - if (e.code == 'network-request-failed' || - e.message?.contains('Unable to resolve host') == true) { - completer.completeError( - const domain.NetworkException( - technicalMessage: 'Auth network failure', - ), - ); - } else { - completer.completeError( - domain.SignInFailedException( - technicalMessage: 'Firebase ${e.code}: ${e.message}', - ), - ); - } - } - }, - codeSent: (String verificationId, _) { - if (!completer.isCompleted) { - completer.complete(verificationId); - } - }, - codeAutoRetrievalTimeout: (String verificationId) { - if (!completer.isCompleted) { - completer.complete(verificationId); - } - }, + onAutoVerified: TestPhoneNumbers.isTestNumber(phoneNumber) ? null : null, ); - - return completer.future; } @override void cancelPendingPhoneVerification() { - final Completer? completer = _pendingVerification; - if (completer != null && !completer.isCompleted) { - completer.completeError(Exception('Phone verification cancelled.')); - } - _pendingVerification = null; + _firebaseAuthService.cancelPendingPhoneVerification(); } /// Verifies the OTP and completes authentication via the V2 API. @@ -145,53 +92,26 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { required String smsCode, required AuthMode mode, }) async { - // Step 1: Sign in with Firebase credential (client-side). - final PhoneAuthCredential credential = PhoneAuthProvider.credential( + // Step 1: Sign in with Firebase credential via core service. + final PhoneSignInResult signInResult = + await _firebaseAuthService.signInWithPhoneCredential( verificationId: verificationId, smsCode: smsCode, ); - final UserCredential userCredential; - try { - userCredential = await _auth.signInWithCredential(credential); - } on FirebaseAuthException catch (e) { - if (e.code == 'invalid-verification-code') { - throw const domain.InvalidCredentialsException( - technicalMessage: 'Invalid OTP code entered.', - ); - } - rethrow; - } - - final User? firebaseUser = userCredential.user; - if (firebaseUser == null) { - throw const domain.SignInFailedException( - technicalMessage: - 'Phone verification failed, no Firebase user received.', - ); - } - - // Step 2: Get the Firebase ID token. - final String? idToken = await firebaseUser.getIdToken(); - if (idToken == null) { - throw const domain.SignInFailedException( - technicalMessage: 'Failed to obtain Firebase ID token.', - ); - } - - // Step 3: Call V2 verify endpoint with the Firebase ID token. + // Step 2: Call V2 verify endpoint with the Firebase ID token. final String v2Mode = mode == AuthMode.signup ? 'sign-up' : 'sign-in'; final domain.ApiResponse response = await _apiService.post( AuthEndpoints.staffPhoneVerify, data: { - 'idToken': idToken, + 'idToken': signInResult.idToken, 'mode': v2Mode, }, ); final Map data = response.data as Map; - // Step 4: Check for business logic errors from the V2 API. + // Step 3: Check for business logic errors from the V2 API. final Map? staffData = data['staff'] as Map?; final Map? userData = @@ -202,7 +122,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // - Sign-in: staff must exist if (mode == AuthMode.login) { if (staffData == null) { - await _auth.signOut(); + await _firebaseAuthService.signOut(); throw const domain.UserNotFoundException( technicalMessage: 'Your account is not registered yet. Please register first.', @@ -210,7 +130,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { } } - // Step 5: Populate StaffSessionStore from the V2 auth envelope. + // Step 4: Populate StaffSessionStore from the V2 auth envelope. if (staffData != null) { final domain.StaffSession staffSession = domain.StaffSession.fromJson(data); @@ -219,10 +139,10 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // Build the domain user from the V2 response. final domain.User domainUser = domain.User( - id: userData?['id'] as String? ?? firebaseUser.uid, + id: userData?['id'] as String? ?? signInResult.uid, email: userData?['email'] as String?, displayName: userData?['displayName'] as String?, - phone: userData?['phone'] as String? ?? firebaseUser.phoneNumber, + phone: userData?['phone'] as String? ?? signInResult.phoneNumber, status: domain.UserStatus.active, ); @@ -238,7 +158,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // Sign-out should not fail even if the API call fails. // The local sign-out below will clear the session regardless. } - await _auth.signOut(); + await _firebaseAuthService.signOut(); StaffSessionStore.instance.clear(); } } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart index 64a45151..5ad7ac36 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/profile_setup_repository_impl.dart @@ -1,4 +1,3 @@ -import 'package:firebase_auth/firebase_auth.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -11,13 +10,20 @@ import 'package:staff_authentication/src/domain/repositories/profile_setup_repos class ProfileSetupRepositoryImpl implements ProfileSetupRepository { /// Creates a [ProfileSetupRepositoryImpl]. /// - /// Requires a [BaseApiService] for V2 API calls. - ProfileSetupRepositoryImpl({required BaseApiService apiService}) - : _apiService = apiService; + /// Requires a [BaseApiService] for V2 API calls and a + /// [FirebaseAuthService] to resolve the current user's phone number. + ProfileSetupRepositoryImpl({ + required BaseApiService apiService, + required FirebaseAuthService firebaseAuthService, + }) : _apiService = apiService, + _firebaseAuthService = firebaseAuthService; /// The V2 API service for backend calls. final BaseApiService _apiService; + /// Core Firebase Auth service for querying current user info. + final FirebaseAuthService _firebaseAuthService; + @override Future submitProfile({ required String fullName, @@ -38,7 +44,7 @@ class ProfileSetupRepositoryImpl implements ProfileSetupRepository { // to the Firebase Auth current user's phone if the caller passed empty. final String resolvedPhone = phoneNumber.isNotEmpty ? phoneNumber - : (FirebaseAuth.instance.currentUser?.phoneNumber ?? ''); + : (_firebaseAuthService.currentUserPhoneNumber ?? ''); final ApiResponse response = await _apiService.post( StaffEndpoints.profileSetup, diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input.dart index 7eb7b850..28414a34 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input.dart @@ -36,7 +36,7 @@ class _PhoneInputState extends State { if (!mounted) return; _currentPhone = value; - final AuthBloc bloc = context.read(); + final AuthBloc bloc = ReadContext(context).read(); if (!bloc.isClosed) { bloc.add(AuthPhoneUpdated(value)); } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_location.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_location.dart index c6f4c5e2..f4f989a7 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_location.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/profile_setup_page/profile_setup_location.dart @@ -48,7 +48,7 @@ class _ProfileSetupLocationState extends State { void _onSearchChanged(String query) { if (_debounce?.isActive ?? false) _debounce!.cancel(); _debounce = Timer(const Duration(milliseconds: 300), () { - context.read().add( + ReadContext(context).read().add( ProfileSetupLocationQueryChanged(query), ); }); @@ -62,7 +62,7 @@ class _ProfileSetupLocationState extends State { )..add(location); widget.onLocationsChanged(updatedList); _locationController.clear(); - context.read().add( + ReadContext(context).read().add( const ProfileSetupClearLocationSuggestions(), ); } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart b/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart index cfaf7c81..fe602ef6 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart @@ -32,10 +32,16 @@ class StaffAuthenticationModule extends Module { void binds(Injector i) { // Repositories i.addLazySingleton( - () => AuthRepositoryImpl(apiService: i.get()), + () => AuthRepositoryImpl( + apiService: i.get(), + firebaseAuthService: i.get(), + ), ); i.addLazySingleton( - () => ProfileSetupRepositoryImpl(apiService: i.get()), + () => ProfileSetupRepositoryImpl( + apiService: i.get(), + firebaseAuthService: i.get(), + ), ); i.addLazySingleton(PlaceRepositoryImpl.new); diff --git a/apps/mobile/packages/features/staff/authentication/pubspec.yaml b/apps/mobile/packages/features/staff/authentication/pubspec.yaml index d3117db5..69cadc87 100644 --- a/apps/mobile/packages/features/staff/authentication/pubspec.yaml +++ b/apps/mobile/packages/features/staff/authentication/pubspec.yaml @@ -14,7 +14,6 @@ dependencies: flutter_bloc: ^8.1.0 flutter_modular: ^6.3.0 equatable: ^2.0.5 - firebase_auth: ^6.1.2 http: ^1.2.0 # Architecture Packages diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart index cd82a9cf..d219f36c 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart @@ -201,7 +201,7 @@ class _AvailabilityPageState extends State { height: 32, child: OutlinedButton( onPressed: () => - context.read().add(PerformQuickSet(type)), + ReadContext(context).read().add(PerformQuickSet(type)), style: OutlinedButton.styleFrom( padding: EdgeInsets.zero, side: BorderSide( @@ -252,14 +252,14 @@ class _AvailabilityPageState extends State { children: [ _buildNavButton( UiIcons.chevronLeft, - () => context.read().add( + () => ReadContext(context).read().add( const NavigateWeek(-1), ), ), Text(monthYear, style: UiTypography.title2b), _buildNavButton( UiIcons.chevronRight, - () => context.read().add( + () => ReadContext(context).read().add( const NavigateWeek(1), ), ), @@ -307,7 +307,7 @@ class _AvailabilityPageState extends State { return Expanded( child: GestureDetector( onTap: () => - context.read().add(SelectDate(dayDate)), + ReadContext(context).read().add(SelectDate(dayDate)), child: Container( margin: const EdgeInsets.symmetric(horizontal: 2), padding: const EdgeInsets.symmetric(vertical: UiConstants.space3), diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart index 7f11195d..f5b1e46a 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart @@ -2,8 +2,8 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:staff_home/src/domain/repositories/home_repository.dart'; import 'package:staff_home/src/domain/usecases/get_benefits_history_usecase.dart'; +import 'package:staff_home/src/domain/usecases/get_home_shifts.dart'; part 'benefits_overview_state.dart'; @@ -14,14 +14,14 @@ class BenefitsOverviewCubit extends Cubit with BlocErrorHandler { /// Creates a [BenefitsOverviewCubit]. BenefitsOverviewCubit({ - required HomeRepository repository, + required GetDashboardUseCase getDashboard, required GetBenefitsHistoryUseCase getBenefitsHistory, - }) : _repository = repository, + }) : _getDashboard = getDashboard, _getBenefitsHistory = getBenefitsHistory, super(const BenefitsOverviewState.initial()); - /// The repository used for dashboard data access. - final HomeRepository _repository; + /// Use case for fetching dashboard data. + final GetDashboardUseCase _getDashboard; /// Use case for fetching benefit history. final GetBenefitsHistoryUseCase _getBenefitsHistory; @@ -33,7 +33,7 @@ class BenefitsOverviewCubit extends Cubit await handleError( emit: emit, action: () async { - final StaffDashboard dashboard = await _repository.getDashboard(); + final StaffDashboard dashboard = await _getDashboard(); if (isClosed) return; emit( state.copyWith( diff --git a/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart b/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart index 57410288..7f468310 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart @@ -50,7 +50,7 @@ class StaffHomeModule extends Module { // Cubit for benefits overview page (includes history support) i.addLazySingleton( () => BenefitsOverviewCubit( - repository: i.get(), + getDashboard: i.get(), getBenefitsHistory: i.get(), ), ); diff --git a/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart b/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart index 606d08f0..f8d3020d 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart @@ -1,17 +1,19 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart'; + /// Repository implementation for the main profile page. /// /// Uses the V2 API to fetch staff profile, section statuses, and completion. -class ProfileRepositoryImpl { +class ProfileRepositoryImpl implements ProfileRepositoryInterface { /// Creates a [ProfileRepositoryImpl]. ProfileRepositoryImpl({required BaseApiService apiService}) : _api = apiService; final BaseApiService _api; - /// Fetches the staff profile from the V2 session endpoint. + @override Future getStaffProfile() async { final ApiResponse response = await _api.get(StaffEndpoints.session); @@ -20,7 +22,7 @@ class ProfileRepositoryImpl { return Staff.fromJson(json); } - /// Fetches the profile section completion statuses. + @override Future getProfileSections() async { final ApiResponse response = await _api.get(StaffEndpoints.profileSections); @@ -29,7 +31,7 @@ class ProfileRepositoryImpl { return ProfileSectionStatus.fromJson(json); } - /// Signs out the current user. + @override Future signOut() async { await _api.post(AuthEndpoints.signOut); } diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository_interface.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository_interface.dart new file mode 100644 index 00000000..b28b963d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository_interface.dart @@ -0,0 +1,16 @@ +import 'package:krow_domain/krow_domain.dart'; + +/// Abstract interface for the staff profile repository. +/// +/// Defines the contract for fetching staff profile data, +/// section completion statuses, and signing out. +abstract interface class ProfileRepositoryInterface { + /// Fetches the staff profile from the backend. + Future getStaffProfile(); + + /// Fetches the profile section completion statuses. + Future getProfileSections(); + + /// Signs out the current user. + Future signOut(); +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_profile_sections_usecase.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_profile_sections_usecase.dart new file mode 100644 index 00000000..21bbaee3 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_profile_sections_usecase.dart @@ -0,0 +1,17 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart'; + +/// Use case for retrieving profile section completion statuses. +class GetProfileSectionsUseCase implements NoInputUseCase { + /// Creates a [GetProfileSectionsUseCase] with the required [repository]. + GetProfileSectionsUseCase(this._repository); + + final ProfileRepositoryInterface _repository; + + @override + Future call() { + return _repository.getProfileSections(); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_staff_profile_usecase.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_staff_profile_usecase.dart new file mode 100644 index 00000000..de53df83 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_staff_profile_usecase.dart @@ -0,0 +1,17 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart'; + +/// Use case for retrieving the staff member's profile. +class GetStaffProfileUseCase implements NoInputUseCase { + /// Creates a [GetStaffProfileUseCase] with the required [repository]. + GetStaffProfileUseCase(this._repository); + + final ProfileRepositoryInterface _repository; + + @override + Future call() { + return _repository.getStaffProfile(); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/sign_out_usecase.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/sign_out_usecase.dart new file mode 100644 index 00000000..301b6b90 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/sign_out_usecase.dart @@ -0,0 +1,16 @@ +import 'package:krow_core/core.dart'; + +import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart'; + +/// Use case for signing out the current user. +class SignOutUseCase implements NoInputUseCase { + /// Creates a [SignOutUseCase] with the required [repository]. + SignOutUseCase(this._repository); + + final ProfileRepositoryInterface _repository; + + @override + Future call() { + return _repository.signOut(); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart index ec70c614..4b68e2ee 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart @@ -2,19 +2,30 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import 'package:staff_profile/src/data/repositories/profile_repository_impl.dart'; +import 'package:staff_profile/src/domain/usecases/get_profile_sections_usecase.dart'; +import 'package:staff_profile/src/domain/usecases/get_staff_profile_usecase.dart'; +import 'package:staff_profile/src/domain/usecases/sign_out_usecase.dart'; import 'package:staff_profile/src/presentation/blocs/profile_state.dart'; /// Cubit for managing the Profile feature state. /// -/// Uses the V2 API via [ProfileRepositoryImpl] for all data fetching. +/// Delegates all data fetching to use cases, following Clean Architecture. /// Loads the staff profile and section completion statuses in a single flow. class ProfileCubit extends Cubit with BlocErrorHandler { - /// Creates a [ProfileCubit] with the required repository. - ProfileCubit(this._repository) : super(const ProfileState()); + /// Creates a [ProfileCubit] with the required use cases. + ProfileCubit({ + required GetStaffProfileUseCase getStaffProfileUseCase, + required GetProfileSectionsUseCase getProfileSectionsUseCase, + required SignOutUseCase signOutUseCase, + }) : _getStaffProfileUseCase = getStaffProfileUseCase, + _getProfileSectionsUseCase = getProfileSectionsUseCase, + _signOutUseCase = signOutUseCase, + super(const ProfileState()); - final ProfileRepositoryImpl _repository; + final GetStaffProfileUseCase _getStaffProfileUseCase; + final GetProfileSectionsUseCase _getProfileSectionsUseCase; + final SignOutUseCase _signOutUseCase; /// Loads the staff member's profile. Future loadProfile() async { @@ -23,7 +34,7 @@ class ProfileCubit extends Cubit await handleError( emit: emit, action: () async { - final Staff profile = await _repository.getStaffProfile(); + final Staff profile = await _getStaffProfileUseCase(); emit(state.copyWith(status: ProfileStatus.loaded, profile: profile)); }, onError: (String errorKey) => @@ -37,7 +48,7 @@ class ProfileCubit extends Cubit emit: emit, action: () async { final ProfileSectionStatus sections = - await _repository.getProfileSections(); + await _getProfileSectionsUseCase(); emit(state.copyWith( personalInfoComplete: sections.personalInfoCompleted, emergencyContactsComplete: sections.emergencyContactCompleted, @@ -62,7 +73,7 @@ class ProfileCubit extends Cubit await handleError( emit: emit, action: () async { - await _repository.signOut(); + await _signOutUseCase(); emit(state.copyWith(status: ProfileStatus.signedOut)); }, onError: (String _) => diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/logout_button.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/logout_button.dart index d74e9655..11ffa4aa 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/logout_button.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/logout_button.dart @@ -19,7 +19,7 @@ class LogoutButton extends StatelessWidget { /// sign-out process via the ProfileCubit. void _handleSignOut(BuildContext context, ProfileState state) { if (state.status != ProfileStatus.loading) { - context.read().signOut(); + ReadContext(context).read().signOut(); } } @@ -47,7 +47,7 @@ class LogoutButton extends StatelessWidget { onTap: () { _handleSignOut( context, - context.read().state, + ReadContext(context).read().state, ); }, borderRadius: UiConstants.radiusLg, diff --git a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart index 2b6f8f60..e9854ab8 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart @@ -4,13 +4,17 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_profile/src/data/repositories/profile_repository_impl.dart'; +import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart'; +import 'package:staff_profile/src/domain/usecases/get_profile_sections_usecase.dart'; +import 'package:staff_profile/src/domain/usecases/get_staff_profile_usecase.dart'; +import 'package:staff_profile/src/domain/usecases/sign_out_usecase.dart'; import 'package:staff_profile/src/presentation/blocs/profile_cubit.dart'; import 'package:staff_profile/src/presentation/pages/staff_profile_page.dart'; /// The entry module for the Staff Profile feature. /// /// Uses the V2 REST API via [BaseApiService] for all backend access. -/// Section completion statuses are fetched in a single API call. +/// Registers repository interface, use cases, and cubit for DI. class StaffProfileModule extends Module { @override List get imports => [CoreModule()]; @@ -18,15 +22,36 @@ class StaffProfileModule extends Module { @override void binds(Injector i) { // Repository - i.addLazySingleton( + i.addLazySingleton( () => ProfileRepositoryImpl( apiService: i.get(), ), ); + // Use Cases + i.addLazySingleton( + () => GetStaffProfileUseCase( + i.get(), + ), + ); + i.addLazySingleton( + () => GetProfileSectionsUseCase( + i.get(), + ), + ); + i.addLazySingleton( + () => SignOutUseCase( + i.get(), + ), + ); + // Cubit i.addLazySingleton( - () => ProfileCubit(i.get()), + () => ProfileCubit( + getStaffProfileUseCase: i.get(), + getProfileSectionsUseCase: i.get(), + signOutUseCase: i.get(), + ), ); } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_i9_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_i9_page.dart index cc031b38..31fcdb30 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_i9_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_i9_page.dart @@ -121,14 +121,14 @@ class _FormI9PageState extends State { void _handleNext(BuildContext context, int currentStep) { if (currentStep < _steps.length - 1) { - context.read().nextStep(_steps.length); + ReadContext(context).read().nextStep(_steps.length); } else { - context.read().submit(); + ReadContext(context).read().submit(); } } void _handleBack(BuildContext context) { - context.read().previousStep(); + ReadContext(context).read().previousStep(); } @override @@ -459,7 +459,7 @@ class _FormI9PageState extends State { i18n.fields.first_name, value: state.firstName, onChanged: (String val) => - context.read().firstNameChanged(val), + ReadContext(context).read().firstNameChanged(val), placeholder: i18n.fields.hints.first_name, ), ), @@ -469,7 +469,7 @@ class _FormI9PageState extends State { i18n.fields.last_name, value: state.lastName, onChanged: (String val) => - context.read().lastNameChanged(val), + ReadContext(context).read().lastNameChanged(val), placeholder: i18n.fields.hints.last_name, ), ), @@ -483,7 +483,7 @@ class _FormI9PageState extends State { i18n.fields.middle_initial, value: state.middleInitial, onChanged: (String val) => - context.read().middleInitialChanged(val), + ReadContext(context).read().middleInitialChanged(val), placeholder: i18n.fields.hints.middle_initial, ), ), @@ -494,7 +494,7 @@ class _FormI9PageState extends State { i18n.fields.other_last_names, value: state.otherLastNames, onChanged: (String val) => - context.read().otherLastNamesChanged(val), + ReadContext(context).read().otherLastNamesChanged(val), placeholder: i18n.fields.maiden_name, ), ), @@ -505,7 +505,7 @@ class _FormI9PageState extends State { i18n.fields.dob, value: state.dob, onChanged: (String val) => - context.read().dobChanged(val), + ReadContext(context).read().dobChanged(val), placeholder: i18n.fields.hints.dob, keyboardType: TextInputType.datetime, ), @@ -518,7 +518,7 @@ class _FormI9PageState extends State { onChanged: (String val) { String text = val.replaceAll(RegExp(r'\D'), ''); if (text.length > 9) text = text.substring(0, 9); - context.read().ssnChanged(text); + ReadContext(context).read().ssnChanged(text); }, ), const SizedBox(height: UiConstants.space4), @@ -526,7 +526,7 @@ class _FormI9PageState extends State { i18n.fields.email, value: state.email, onChanged: (String val) => - context.read().emailChanged(val), + ReadContext(context).read().emailChanged(val), keyboardType: TextInputType.emailAddress, placeholder: i18n.fields.hints.email, ), @@ -535,7 +535,7 @@ class _FormI9PageState extends State { i18n.fields.phone, value: state.phone, onChanged: (String val) => - context.read().phoneChanged(val), + ReadContext(context).read().phoneChanged(val), keyboardType: TextInputType.phone, placeholder: i18n.fields.hints.phone, ), @@ -554,7 +554,7 @@ class _FormI9PageState extends State { i18n.fields.address_long, value: state.address, onChanged: (String val) => - context.read().addressChanged(val), + ReadContext(context).read().addressChanged(val), placeholder: i18n.fields.hints.address, ), const SizedBox(height: UiConstants.space4), @@ -562,7 +562,7 @@ class _FormI9PageState extends State { i18n.fields.apt, value: state.aptNumber, onChanged: (String val) => - context.read().aptNumberChanged(val), + ReadContext(context).read().aptNumberChanged(val), placeholder: i18n.fields.hints.apt, ), const SizedBox(height: UiConstants.space4), @@ -574,7 +574,7 @@ class _FormI9PageState extends State { i18n.fields.city, value: state.city, onChanged: (String val) => - context.read().cityChanged(val), + ReadContext(context).read().cityChanged(val), placeholder: i18n.fields.hints.city, ), ), @@ -593,7 +593,7 @@ class _FormI9PageState extends State { DropdownButtonFormField( initialValue: state.state.isEmpty ? null : state.state, onChanged: (String? val) => - context.read().stateChanged(val ?? ''), + ReadContext(context).read().stateChanged(val ?? ''), items: _usStates.map((String stateAbbr) { return DropdownMenuItem( value: stateAbbr, @@ -626,7 +626,7 @@ class _FormI9PageState extends State { i18n.fields.zip, value: state.zipCode, onChanged: (String val) => - context.read().zipCodeChanged(val), + ReadContext(context).read().zipCodeChanged(val), placeholder: i18n.fields.hints.zip, keyboardType: TextInputType.number, ), @@ -660,7 +660,7 @@ class _FormI9PageState extends State { i18n.fields.uscis_number_label, value: state.uscisNumber, onChanged: (String val) => - context.read().uscisNumberChanged(val), + ReadContext(context).read().uscisNumberChanged(val), placeholder: i18n.fields.hints.uscis, ), ) @@ -718,7 +718,7 @@ class _FormI9PageState extends State { }) { final bool isSelected = state.citizenshipStatus == value; return GestureDetector( - onTap: () => context.read().citizenshipStatusChanged(value), + onTap: () => ReadContext(context).read().citizenshipStatusChanged(value), child: Container( padding: const EdgeInsets.all(UiConstants.space4), decoration: BoxDecoration( @@ -803,7 +803,7 @@ class _FormI9PageState extends State { CheckboxListTile( value: state.preparerUsed, onChanged: (bool? val) { - context.read().preparerUsedChanged(val ?? false); + ReadContext(context).read().preparerUsedChanged(val ?? false); }, contentPadding: EdgeInsets.zero, title: Text( @@ -837,7 +837,7 @@ class _FormI9PageState extends State { TextPosition(offset: state.signature.length), ), onChanged: (String val) => - context.read().signatureChanged(val), + ReadContext(context).read().signatureChanged(val), decoration: InputDecoration( hintText: i18n.fields.signature_hint, filled: true, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_w4_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_w4_page.dart index 3db67c1a..e1a17d49 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_w4_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/form_w4_page.dart @@ -111,14 +111,14 @@ class _FormW4PageState extends State { void _handleNext(BuildContext context, int currentStep) { if (currentStep < _steps.length - 1) { - context.read().nextStep(_steps.length); + ReadContext(context).read().nextStep(_steps.length); } else { - context.read().submit(); + ReadContext(context).read().submit(); } } void _handleBack(BuildContext context) { - context.read().previousStep(); + ReadContext(context).read().previousStep(); } int _totalCredits(FormW4State state) { @@ -458,7 +458,7 @@ class _FormW4PageState extends State { i18n.fields.first_name, value: state.firstName, onChanged: (String val) => - context.read().firstNameChanged(val), + ReadContext(context).read().firstNameChanged(val), placeholder: i18n.fields.placeholder_john, ), ), @@ -468,7 +468,7 @@ class _FormW4PageState extends State { i18n.fields.last_name, value: state.lastName, onChanged: (String val) => - context.read().lastNameChanged(val), + ReadContext(context).read().lastNameChanged(val), placeholder: i18n.fields.placeholder_smith, ), ), @@ -483,7 +483,7 @@ class _FormW4PageState extends State { onChanged: (String val) { String text = val.replaceAll(RegExp(r'\D'), ''); if (text.length > 9) text = text.substring(0, 9); - context.read().ssnChanged(text); + ReadContext(context).read().ssnChanged(text); }, ), const SizedBox(height: UiConstants.space4), @@ -491,7 +491,7 @@ class _FormW4PageState extends State { i18n.fields.address, value: state.address, onChanged: (String val) => - context.read().addressChanged(val), + ReadContext(context).read().addressChanged(val), placeholder: i18n.fields.placeholder_address, ), const SizedBox(height: UiConstants.space4), @@ -499,7 +499,7 @@ class _FormW4PageState extends State { i18n.fields.city_state_zip, value: state.cityStateZip, onChanged: (String val) => - context.read().cityStateZipChanged(val), + ReadContext(context).read().cityStateZipChanged(val), placeholder: i18n.fields.placeholder_csz, ), ], @@ -557,7 +557,7 @@ class _FormW4PageState extends State { ) { final bool isSelected = state.filingStatus == value; return GestureDetector( - onTap: () => context.read().filingStatusChanged(value), + onTap: () => ReadContext(context).read().filingStatusChanged(value), child: Container( padding: const EdgeInsets.all(UiConstants.space4), decoration: BoxDecoration( @@ -641,7 +641,7 @@ class _FormW4PageState extends State { ), const SizedBox(height: UiConstants.space6), GestureDetector( - onTap: () => context.read().multipleJobsChanged( + onTap: () => ReadContext(context).read().multipleJobsChanged( !state.multipleJobs, ), child: Container( @@ -752,7 +752,7 @@ class _FormW4PageState extends State { i18n.fields.children_each, (FormW4State s) => s.qualifyingChildren, (int val) => - context.read().qualifyingChildrenChanged(val), + ReadContext(context).read().qualifyingChildrenChanged(val), ), const Padding( padding: EdgeInsets.symmetric(vertical: 16), @@ -765,7 +765,7 @@ class _FormW4PageState extends State { i18n.fields.other_each, (FormW4State s) => s.otherDependents, (int val) => - context.read().otherDependentsChanged(val), + ReadContext(context).read().otherDependentsChanged(val), ), ], ), @@ -881,7 +881,7 @@ class _FormW4PageState extends State { i18n.fields.other_income, value: state.otherIncome, onChanged: (String val) => - context.read().otherIncomeChanged(val), + ReadContext(context).read().otherIncomeChanged(val), placeholder: i18n.fields.hints.zero, keyboardType: TextInputType.number, ), @@ -897,7 +897,7 @@ class _FormW4PageState extends State { i18n.fields.deductions, value: state.deductions, onChanged: (String val) => - context.read().deductionsChanged(val), + ReadContext(context).read().deductionsChanged(val), placeholder: i18n.fields.hints.zero, keyboardType: TextInputType.number, ), @@ -913,7 +913,7 @@ class _FormW4PageState extends State { i18n.fields.extra_withholding, value: state.extraWithholding, onChanged: (String val) => - context.read().extraWithholdingChanged(val), + ReadContext(context).read().extraWithholdingChanged(val), placeholder: i18n.fields.hints.zero, keyboardType: TextInputType.number, ), @@ -996,7 +996,7 @@ class _FormW4PageState extends State { TextPosition(offset: state.signature.length), ), onChanged: (String val) => - context.read().signatureChanged(val), + ReadContext(context).read().signatureChanged(val), decoration: InputDecoration( hintText: i18n.fields.signature_hint, filled: true, diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_add_button.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_add_button.dart index 769c709b..20c92a8d 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_add_button.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_add_button.dart @@ -11,7 +11,7 @@ class EmergencyContactAddButton extends StatelessWidget { return Center( child: TextButton.icon( onPressed: () => - context.read().add(EmergencyContactAdded()), + ReadContext(context).read().add(EmergencyContactAdded()), icon: const Icon(UiIcons.add, size: 20.0), label: Text( 'Add Another Contact', diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_form_item.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_form_item.dart index 9a326905..2f64b415 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_form_item.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_form_item.dart @@ -44,7 +44,7 @@ class EmergencyContactFormItem extends StatelessWidget { initialValue: contact.fullName, hint: 'Contact name', icon: UiIcons.user, - onChanged: (val) => context.read().add( + onChanged: (val) => ReadContext(context).read().add( EmergencyContactUpdated(index, contact.copyWith(fullName: val)), ), ), @@ -54,7 +54,7 @@ class EmergencyContactFormItem extends StatelessWidget { initialValue: contact.phone, hint: '+1 (555) 000-0000', icon: UiIcons.phone, - onChanged: (val) => context.read().add( + onChanged: (val) => ReadContext(context).read().add( EmergencyContactUpdated(index, contact.copyWith(phone: val)), ), ), @@ -66,7 +66,7 @@ class EmergencyContactFormItem extends StatelessWidget { items: _kRelationshipTypes, onChanged: (val) { if (val != null) { - context.read().add( + ReadContext(context).read().add( EmergencyContactUpdated( index, contact.copyWith(relationshipType: val), @@ -144,7 +144,7 @@ class EmergencyContactFormItem extends StatelessWidget { color: UiColors.textError, size: 20.0, ), - onPressed: () => context.read().add( + onPressed: () => ReadContext(context).read().add( EmergencyContactRemoved(index), ), ), diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart index 66fa95ab..264e56ac 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart @@ -37,9 +37,9 @@ class _FaqsWidgetState extends State { void _onSearchChanged(String value) { if (value.isEmpty) { - context.read().add(const FetchFaqsEvent()); + ReadContext(context).read().add(const FetchFaqsEvent()); } else { - context.read().add(SearchFaqsEvent(query: value)); + ReadContext(context).read().add(SearchFaqsEvent(query: value)); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/privacy/privacy_section_widget.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/privacy/privacy_section_widget.dart index 215a103c..53afbbe8 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/privacy/privacy_section_widget.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/privacy/privacy_section_widget.dart @@ -23,7 +23,7 @@ class PrivacySectionWidget extends StatelessWidget { type: UiSnackbarType.success, ); // Clear the flag after showing the snackbar - context.read().add( + ReadContext(context).read().add( const ClearProfileVisibilityUpdatedEvent(), ); } diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart index 2e6d5d0f..b76d7f2b 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; @@ -9,7 +8,9 @@ import 'package:staff_main/src/presentation/blocs/staff_main_state.dart'; /// /// Tracks the active bottom-bar tab index, profile completion status, and /// bottom bar visibility based on the current route. -class StaffMainCubit extends Cubit implements Disposable { +class StaffMainCubit extends Cubit + with BlocErrorHandler + implements Disposable { /// Creates a [StaffMainCubit]. StaffMainCubit({ required GetProfileCompletionUseCase getProfileCompletionUsecase, @@ -67,20 +68,21 @@ class StaffMainCubit extends Cubit implements Disposable { if (_isLoadingCompletion || isClosed) return; _isLoadingCompletion = true; - try { - final bool isComplete = await _getProfileCompletionUsecase(); - if (!isClosed) { - emit(state.copyWith(isProfileComplete: isComplete)); - } - } catch (e) { - // If there's an error, allow access to all features - debugPrint('Error loading profile completion: $e'); - if (!isClosed) { - emit(state.copyWith(isProfileComplete: true)); - } - } finally { - _isLoadingCompletion = false; - } + await handleError( + emit: emit, + action: () async { + final bool isComplete = await _getProfileCompletionUsecase(); + if (!isClosed) { + emit(state.copyWith(isProfileComplete: isComplete)); + } + }, + onError: (String errorKey) { + // If there's an error, allow access to all features + _isLoadingCompletion = false; + return state.copyWith(isProfileComplete: true); + }, + ); + _isLoadingCompletion = false; } /// Navigates to the tab at [index].