feat: Refactor context reading in emergency contact and FAQs widgets

- Updated the context reading method in `EmergencyContactAddButton` and `EmergencyContactFormItem` to use `ReadContext`.
- Modified the `FaqsWidget` to utilize `ReadContext` for fetching FAQs.
- Adjusted the `PrivacySectionWidget` to read from `PrivacySecurityBloc` using `ReadContext`.

feat: Implement Firebase Auth isolation pattern

- Introduced `FirebaseAuthService` and `FirebaseAuthServiceImpl` to abstract Firebase Auth operations.
- Ensured features do not directly import `firebase_auth`, adhering to architecture rules.

feat: Create repository interfaces for billing and coverage

- Added `BillingRepositoryInterface` for billing-related operations.
- Created `CoverageRepositoryInterface` for coverage data access.

feat: Add use cases for order management

- Implemented use cases for fetching hubs, managers, and roles related to orders.
- Created `GetHubsUseCase`, `GetManagersByHubUseCase`, and `GetRolesByVendorUseCase`.

feat: Develop report use cases for client reports

- Added use cases for fetching various reports including coverage, daily operations, forecast, no-show, performance, and spend reports.
- Implemented `GetCoverageReportUseCase`, `GetDailyOpsReportUseCase`, `GetForecastReportUseCase`, `GetNoShowReportUseCase`, `GetPerformanceReportUseCase`, and `GetSpendReportUseCase`.

feat: Establish profile repository and use cases

- Created `ProfileRepositoryInterface` for staff profile data access.
- Implemented use cases for retrieving staff profile and section statuses: `GetStaffProfileUseCase` and `GetProfileSectionsUseCase`.
- Added `SignOutUseCase` for signing out the current user.
This commit is contained in:
Achintha Isuru
2026-03-19 01:10:27 -04:00
parent a45a3f6af1
commit 843eec5692
123 changed files with 2102 additions and 1087 deletions

View File

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

View File

@@ -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<FirebaseAuthService>(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`.

View File

@@ -44,6 +44,7 @@ export 'src/services/session/v2_session_service.dart';
// Auth // Auth
export 'src/services/auth/auth_token_provider.dart'; export 'src/services/auth/auth_token_provider.dart';
export 'src/services/auth/firebase_auth_service.dart';
// Device Services // Device Services
export 'src/services/device/camera/camera_service.dart'; export 'src/services/device/camera/camera_service.dart';

View File

@@ -4,6 +4,7 @@ import 'package:image_picker/image_picker.dart';
import 'package:krow_domain/krow_domain.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/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 'package:krow_core/src/services/auth/firebase_auth_token_provider.dart';
import '../core.dart'; import '../core.dart';
@@ -63,7 +64,10 @@ class CoreModule extends Module {
// 6. Auth Token Provider // 6. Auth Token Provider
i.addLazySingleton<AuthTokenProvider>(FirebaseAuthTokenProvider.new); i.addLazySingleton<AuthTokenProvider>(FirebaseAuthTokenProvider.new);
// 7. Register Geofence Device Services // 7. Firebase Auth Service (so features never import firebase_auth)
i.addLazySingleton<FirebaseAuthService>(FirebaseAuthServiceImpl.new);
// 8. Register Geofence Device Services
i.addLazySingleton<LocationService>(() => const LocationService()); i.addLazySingleton<LocationService>(() => const LocationService());
i.addLazySingleton<NotificationService>(() => NotificationService()); i.addLazySingleton<NotificationService>(() => NotificationService());
i.addLazySingleton<StorageService>(() => StorageService()); i.addLazySingleton<StorageService>(() => StorageService());

View File

@@ -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<User?> 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<String?> 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<PhoneSignInResult> 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<String> signInWithEmailAndPassword({
required String email,
required String password,
});
/// Signs out the current user from Firebase Auth locally.
Future<void> signOut();
/// Returns the current user's Firebase ID token.
///
/// Returns `null` if no user is signed in.
Future<String?> 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<String?>? _pendingVerification;
@override
Stream<User?> 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<String?> verifyPhoneNumber({
required String phoneNumber,
void Function()? onAutoVerified,
}) async {
final Completer<String?> completer = Completer<String?>();
_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<String?>? completer = _pendingVerification;
if (completer != null && !completer.isCompleted) {
completer.completeError(Exception('Phone verification cancelled.'));
}
_pendingVerification = null;
}
@override
Future<PhoneSignInResult> 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<String> 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<void> signOut() async {
await _auth.signOut();
}
@override
Future<String?> getIdToken() async {
final firebase.User? user = _auth.currentUser;
return user?.getIdToken();
}
}

View File

@@ -1,5 +1,45 @@
import 'package:intl/intl.dart'; 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 /// Formats a time string (ISO 8601 or HH:mm) into 12-hour format
/// (e.g. "9:00 AM"). /// (e.g. "9:00 AM").
/// ///

View File

@@ -33,7 +33,10 @@ class ClientAuthenticationModule extends Module {
void binds(Injector i) { void binds(Injector i) {
// Repositories // Repositories
i.addLazySingleton<AuthRepositoryInterface>( i.addLazySingleton<AuthRepositoryInterface>(
() => AuthRepositoryImpl(apiService: i.get<BaseApiService>()), () => AuthRepositoryImpl(
apiService: i.get<BaseApiService>(),
firebaseAuthService: i.get<FirebaseAuthService>(),
),
); );
// UseCases // UseCases

View File

@@ -1,7 +1,6 @@
import 'dart:developer' as developer; import 'dart:developer' as developer;
import 'package:client_authentication/src/domain/repositories/auth_repository_interface.dart'; 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_core/core.dart';
import 'package:krow_domain/krow_domain.dart' import 'package:krow_domain/krow_domain.dart'
show show
@@ -20,20 +19,23 @@ import 'package:krow_domain/krow_domain.dart'
/// Production implementation of the [AuthRepositoryInterface] for the client app. /// Production implementation of the [AuthRepositoryInterface] for the client app.
/// ///
/// Uses Firebase Auth client-side for sign-in (to maintain local auth state for /// Uses [FirebaseAuthService] from core for local Firebase sign-in (to maintain
/// the [AuthInterceptor]), then calls V2 `GET /auth/session` to retrieve /// local auth state for the [AuthInterceptor]), then calls V2 `GET /auth/session`
/// business context. Sign-up provisioning (tenant, business, memberships) is /// to retrieve business context. Sign-up provisioning (tenant, business,
/// handled entirely server-side by the V2 API. /// memberships) is handled entirely server-side by the V2 API.
class AuthRepositoryImpl implements AuthRepositoryInterface { class AuthRepositoryImpl implements AuthRepositoryInterface {
/// Creates an [AuthRepositoryImpl] with the given [BaseApiService]. /// Creates an [AuthRepositoryImpl] with the given dependencies.
AuthRepositoryImpl({required BaseApiService apiService}) AuthRepositoryImpl({
: _apiService = apiService; required BaseApiService apiService,
required FirebaseAuthService firebaseAuthService,
}) : _apiService = apiService,
_firebaseAuthService = firebaseAuthService;
/// The V2 API service for backend calls. /// The V2 API service for backend calls.
final BaseApiService _apiService; final BaseApiService _apiService;
/// Firebase Auth instance for client-side sign-in/sign-up. /// Core Firebase Auth service abstraction.
firebase.FirebaseAuth get _auth => firebase.FirebaseAuth.instance; final FirebaseAuthService _firebaseAuthService;
@override @override
Future<User> signInWithEmail({ Future<User> signInWithEmail({
@@ -41,7 +43,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
required String password, required String password,
}) async { }) async {
try { 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. // via Identity Toolkit and returns a full auth envelope.
final ApiResponse response = await _apiService.post( final ApiResponse response = await _apiService.post(
AuthEndpoints.clientSignIn, AuthEndpoints.clientSignIn,
@@ -53,19 +55,14 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
// Step 2: Sign in locally so AuthInterceptor can attach Bearer tokens // Step 2: Sign in locally so AuthInterceptor can attach Bearer tokens
// to subsequent requests. The V2 API already validated credentials, so // to subsequent requests. The V2 API already validated credentials, so
// email/password sign-in establishes the local Firebase Auth state. // email/password sign-in establishes the local Firebase Auth state.
final firebase.UserCredential credential = await _auth await _firebaseAuthService.signInWithEmailAndPassword(
.signInWithEmailAndPassword(email: email, password: password); 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',
);
}
// Step 3: Populate session store from the V2 auth envelope directly // Step 3: Populate session store from the V2 auth envelope directly
// (no need for a separate GET /auth/session call). // (no need for a separate GET /auth/session call).
return _populateStoreFromAuthEnvelope(body, firebaseUser, email); return _populateStoreFromAuthEnvelope(body, email);
} on AppException { } on AppException {
rethrow; rethrow;
} catch (e) { } catch (e) {
@@ -98,35 +95,34 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
// Step 2: Sign in locally to Firebase Auth so AuthInterceptor works // Step 2: Sign in locally to Firebase Auth so AuthInterceptor works
// for subsequent requests. The V2 API already created the Firebase // for subsequent requests. The V2 API already created the Firebase
// account, so this should succeed. // account, so this should succeed.
final firebase.UserCredential credential = await _auth try {
.signInWithEmailAndPassword(email: email, password: password); await _firebaseAuthService.signInWithEmailAndPassword(
email: email,
final firebase.User? firebaseUser = credential.user; password: password,
if (firebaseUser == null) { );
} on SignInFailedException {
throw const SignUpFailedException( throw const SignUpFailedException(
technicalMessage: 'Local Firebase sign-in failed after V2 sign-up', technicalMessage: 'Local Firebase sign-in failed after V2 sign-up',
); );
} }
// Step 3: Populate store from the sign-up response envelope. // Step 3: Populate store from the sign-up response envelope.
return _populateStoreFromAuthEnvelope(body, firebaseUser, email); return _populateStoreFromAuthEnvelope(body, 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}',
);
}
} on AppException { } on AppException {
rethrow; rethrow;
} catch (e) { } 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'); throw SignUpFailedException(technicalMessage: 'Unexpected error: $e');
} }
} }
@@ -149,8 +145,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
} }
try { try {
// Step 2: Sign out from local Firebase Auth. // Step 2: Sign out from local Firebase Auth via core service.
await _auth.signOut(); await _firebaseAuthService.signOut();
} catch (e) { } catch (e) {
throw Exception('Error signing out locally: $e'); throw Exception('Error signing out locally: $e');
} }
@@ -167,7 +163,6 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
/// returns a domain [User]. /// returns a domain [User].
User _populateStoreFromAuthEnvelope( User _populateStoreFromAuthEnvelope(
Map<String, dynamic> envelope, Map<String, dynamic> envelope,
firebase.User firebaseUser,
String fallbackEmail, String fallbackEmail,
) { ) {
final Map<String, dynamic>? userJson = final Map<String, dynamic>? userJson =
@@ -194,7 +189,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
ClientSessionStore.instance.setSession(clientSession); 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; final String email = userJson?['email'] as String? ?? fallbackEmail;
return User( return User(

View File

@@ -14,7 +14,6 @@ dependencies:
flutter_bloc: ^8.1.0 flutter_bloc: ^8.1.0
flutter_modular: ^6.3.0 flutter_modular: ^6.3.0
equatable: ^2.0.5 equatable: ^2.0.5
firebase_auth: ^6.1.2
# Architecture Packages # Architecture Packages
design_system: design_system:

View File

@@ -3,7 +3,7 @@ import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'package:billing/src/data/repositories_impl/billing_repository_impl.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/approve_invoice.dart';
import 'package:billing/src/domain/usecases/dispute_invoice.dart'; import 'package:billing/src/domain/usecases/dispute_invoice.dart';
import 'package:billing/src/domain/usecases/get_bank_accounts.dart'; import 'package:billing/src/domain/usecases/get_bank_accounts.dart';
@@ -29,8 +29,8 @@ class BillingModule extends Module {
@override @override
void binds(Injector i) { void binds(Injector i) {
// Repositories // Repositories
i.addLazySingleton<BillingRepository>( i.addLazySingleton<BillingRepositoryInterface>(
() => BillingRepositoryImpl(apiService: i.get<BaseApiService>()), () => BillingRepositoryInterfaceImpl(apiService: i.get<BaseApiService>()),
); );
// Use Cases // Use Cases

View File

@@ -1,14 +1,14 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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]. /// All backend calls go through [BaseApiService] with [ClientEndpoints].
class BillingRepositoryImpl implements BillingRepository { class BillingRepositoryInterfaceImpl implements BillingRepositoryInterface {
/// Creates a [BillingRepositoryImpl]. /// Creates a [BillingRepositoryInterfaceImpl].
BillingRepositoryImpl({required BaseApiService apiService}) BillingRepositoryInterfaceImpl({required BaseApiService apiService})
: _apiService = apiService; : _apiService = apiService;
/// The API service used for all HTTP requests. /// The API service used for all HTTP requests.

View File

@@ -5,7 +5,7 @@ import 'package:krow_domain/krow_domain.dart';
/// This interface defines the contract for accessing billing-related data, /// This interface defines the contract for accessing billing-related data,
/// acting as a boundary between the Domain and Data layers. /// acting as a boundary between the Domain and Data layers.
/// It allows the Domain layer to remain independent of specific data sources. /// 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. /// Fetches bank accounts associated with the business.
Future<List<BillingAccount>> getBankAccounts(); Future<List<BillingAccount>> getBankAccounts();

View File

@@ -1,6 +1,6 @@
import 'package:krow_core/core.dart'; 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. /// Use case for approving an invoice.
class ApproveInvoiceUseCase extends UseCase<String, void> { class ApproveInvoiceUseCase extends UseCase<String, void> {
@@ -8,7 +8,7 @@ class ApproveInvoiceUseCase extends UseCase<String, void> {
ApproveInvoiceUseCase(this._repository); ApproveInvoiceUseCase(this._repository);
/// The billing repository. /// The billing repository.
final BillingRepository _repository; final BillingRepositoryInterface _repository;
@override @override
Future<void> call(String input) => _repository.approveInvoice(input); Future<void> call(String input) => _repository.approveInvoice(input);

View File

@@ -1,6 +1,6 @@
import 'package:krow_core/core.dart'; 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]. /// Params for [DisputeInvoiceUseCase].
class DisputeInvoiceParams { class DisputeInvoiceParams {
@@ -20,7 +20,7 @@ class DisputeInvoiceUseCase extends UseCase<DisputeInvoiceParams, void> {
DisputeInvoiceUseCase(this._repository); DisputeInvoiceUseCase(this._repository);
/// The billing repository. /// The billing repository.
final BillingRepository _repository; final BillingRepositoryInterface _repository;
@override @override
Future<void> call(DisputeInvoiceParams input) => Future<void> call(DisputeInvoiceParams input) =>

View File

@@ -1,7 +1,7 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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. /// Use case for fetching the bank accounts associated with the business.
class GetBankAccountsUseCase extends NoInputUseCase<List<BillingAccount>> { class GetBankAccountsUseCase extends NoInputUseCase<List<BillingAccount>> {
@@ -9,7 +9,7 @@ class GetBankAccountsUseCase extends NoInputUseCase<List<BillingAccount>> {
GetBankAccountsUseCase(this._repository); GetBankAccountsUseCase(this._repository);
/// The billing repository. /// The billing repository.
final BillingRepository _repository; final BillingRepositoryInterface _repository;
@override @override
Future<List<BillingAccount>> call() => _repository.getBankAccounts(); Future<List<BillingAccount>> call() => _repository.getBankAccounts();

View File

@@ -1,16 +1,16 @@
import 'package:krow_core/core.dart'; 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. /// 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<int> { class GetCurrentBillAmountUseCase extends NoInputUseCase<int> {
/// Creates a [GetCurrentBillAmountUseCase]. /// Creates a [GetCurrentBillAmountUseCase].
GetCurrentBillAmountUseCase(this._repository); GetCurrentBillAmountUseCase(this._repository);
/// The billing repository. /// The billing repository.
final BillingRepository _repository; final BillingRepositoryInterface _repository;
@override @override
Future<int> call() => _repository.getCurrentBillCents(); Future<int> call() => _repository.getCurrentBillCents();

View File

@@ -1,7 +1,7 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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. /// Use case for fetching the invoice history.
/// ///
@@ -11,7 +11,7 @@ class GetInvoiceHistoryUseCase extends NoInputUseCase<List<Invoice>> {
GetInvoiceHistoryUseCase(this._repository); GetInvoiceHistoryUseCase(this._repository);
/// The billing repository. /// The billing repository.
final BillingRepository _repository; final BillingRepositoryInterface _repository;
@override @override
Future<List<Invoice>> call() => _repository.getInvoiceHistory(); Future<List<Invoice>> call() => _repository.getInvoiceHistory();

View File

@@ -1,7 +1,7 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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. /// Use case for fetching the pending invoices.
/// ///
@@ -11,7 +11,7 @@ class GetPendingInvoicesUseCase extends NoInputUseCase<List<Invoice>> {
GetPendingInvoicesUseCase(this._repository); GetPendingInvoicesUseCase(this._repository);
/// The billing repository. /// The billing repository.
final BillingRepository _repository; final BillingRepositoryInterface _repository;
@override @override
Future<List<Invoice>> call() => _repository.getPendingInvoices(); Future<List<Invoice>> call() => _repository.getPendingInvoices();

View File

@@ -1,16 +1,16 @@
import 'package:krow_core/core.dart'; 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. /// 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<int> { class GetSavingsAmountUseCase extends NoInputUseCase<int> {
/// Creates a [GetSavingsAmountUseCase]. /// Creates a [GetSavingsAmountUseCase].
GetSavingsAmountUseCase(this._repository); GetSavingsAmountUseCase(this._repository);
/// The billing repository. /// The billing repository.
final BillingRepository _repository; final BillingRepositoryInterface _repository;
@override @override
Future<int> call() => _repository.getSavingsCents(); Future<int> call() => _repository.getSavingsCents();

View File

@@ -1,7 +1,7 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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]. /// Parameters for [GetSpendBreakdownUseCase].
class SpendBreakdownParams { class SpendBreakdownParams {
@@ -20,14 +20,14 @@ class SpendBreakdownParams {
/// Use case for fetching the spending breakdown by category. /// Use case for fetching the spending breakdown by category.
/// ///
/// Delegates data retrieval to the [BillingRepository]. /// Delegates data retrieval to the [BillingRepositoryInterface].
class GetSpendBreakdownUseCase class GetSpendBreakdownUseCase
extends UseCase<SpendBreakdownParams, List<SpendItem>> { extends UseCase<SpendBreakdownParams, List<SpendItem>> {
/// Creates a [GetSpendBreakdownUseCase]. /// Creates a [GetSpendBreakdownUseCase].
GetSpendBreakdownUseCase(this._repository); GetSpendBreakdownUseCase(this._repository);
/// The billing repository. /// The billing repository.
final BillingRepository _repository; final BillingRepositoryInterface _repository;
@override @override
Future<List<SpendItem>> call(SpendBreakdownParams input) => Future<List<SpendItem>> call(SpendBreakdownParams input) =>

View File

@@ -1,5 +1,3 @@
import 'dart:developer' as developer;
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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'; import 'package:billing/src/presentation/blocs/billing_state.dart';
/// BLoC for managing billing state and data loading. /// 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<BillingEvent, BillingState> class BillingBloc extends Bloc<BillingEvent, BillingState>
with BlocErrorHandler<BillingState> { with BlocErrorHandler<BillingState> {
/// Creates a [BillingBloc] with the given use cases. /// Creates a [BillingBloc] with the given use cases.
@@ -35,64 +36,97 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
on<BillingPeriodChanged>(_onPeriodChanged); on<BillingPeriodChanged>(_onPeriodChanged);
} }
/// Use case for fetching bank accounts.
final GetBankAccountsUseCase _getBankAccounts; final GetBankAccountsUseCase _getBankAccounts;
/// Use case for fetching the current bill amount.
final GetCurrentBillAmountUseCase _getCurrentBillAmount; final GetCurrentBillAmountUseCase _getCurrentBillAmount;
/// Use case for fetching the savings amount.
final GetSavingsAmountUseCase _getSavingsAmount; final GetSavingsAmountUseCase _getSavingsAmount;
/// Use case for fetching pending invoices.
final GetPendingInvoicesUseCase _getPendingInvoices; final GetPendingInvoicesUseCase _getPendingInvoices;
/// Use case for fetching invoice history.
final GetInvoiceHistoryUseCase _getInvoiceHistory; final GetInvoiceHistoryUseCase _getInvoiceHistory;
/// Use case for fetching spending breakdown.
final GetSpendBreakdownUseCase _getSpendBreakdown; final GetSpendBreakdownUseCase _getSpendBreakdown;
/// Executes [loader] and returns null on failure, logging the error. /// Loads all billing data concurrently.
Future<T?> _loadSafe<T>(Future<T> Function() loader) async { ///
try { /// Uses [handleError] to surface errors to the UI via state
return await loader(); /// instead of silently swallowing them. Individual data fetches
} catch (e, stackTrace) { /// use [handleErrorWithResult] so partial failures populate
developer.log( /// with defaults rather than failing the entire load.
'Partial billing load failed: $e',
name: 'BillingBloc',
error: e,
stackTrace: stackTrace,
);
return null;
}
}
Future<void> _onLoadStarted( Future<void> _onLoadStarted(
BillingLoadStarted event, BillingLoadStarted event,
Emitter<BillingState> emit, Emitter<BillingState> emit,
) async { ) 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<Object?> results = await Future.wait<Object?>( final List<Object?> results = await Future.wait<Object?>(
<Future<Object?>>[ <Future<Object?>>[
_loadSafe<int>(() => _getCurrentBillAmount.call()), handleErrorWithResult<int>(
_loadSafe<int>(() => _getSavingsAmount.call()), action: () => _getCurrentBillAmount.call(),
_loadSafe<List<Invoice>>(() => _getPendingInvoices.call()), onError: (_) {},
_loadSafe<List<Invoice>>(() => _getInvoiceHistory.call()), ),
_loadSafe<List<SpendItem>>(() => _getSpendBreakdown.call(spendParams)), handleErrorWithResult<int>(
_loadSafe<List<BillingAccount>>(() => _getBankAccounts.call()), action: () => _getSavingsAmount.call(),
], onError: (_) {},
); ),
handleErrorWithResult<List<Invoice>>(
action: () => _getPendingInvoices.call(),
onError: (_) {},
),
handleErrorWithResult<List<Invoice>>(
action: () => _getInvoiceHistory.call(),
onError: (_) {},
),
handleErrorWithResult<List<SpendItem>>(
action: () => _getSpendBreakdown.call(spendParams),
onError: (_) {},
),
handleErrorWithResult<List<BillingAccount>>(
action: () => _getBankAccounts.call(),
onError: (_) {},
),
],
);
final int? currentBillCents = results[0] as int?; final int? currentBillCents = results[0] as int?;
final int? savingsCents = results[1] as int?; final int? savingsCents = results[1] as int?;
final List<Invoice>? pendingInvoices = results[2] as List<Invoice>?; final List<Invoice>? pendingInvoices =
final List<Invoice>? invoiceHistory = results[3] as List<Invoice>?; results[2] as List<Invoice>?;
final List<SpendItem>? spendBreakdown = results[4] as List<SpendItem>?; final List<Invoice>? invoiceHistory =
final List<BillingAccount>? bankAccounts = results[3] as List<Invoice>?;
results[5] as List<BillingAccount>?; final List<SpendItem>? spendBreakdown =
results[4] as List<SpendItem>?;
final List<BillingAccount>? bankAccounts =
results[5] as List<BillingAccount>?;
emit( emit(
state.copyWith( state.copyWith(
status: BillingStatus.success, status: BillingStatus.success,
currentBillCents: currentBillCents ?? state.currentBillCents, currentBillCents: currentBillCents ?? state.currentBillCents,
savingsCents: savingsCents ?? state.savingsCents, savingsCents: savingsCents ?? state.savingsCents,
pendingInvoices: pendingInvoices ?? state.pendingInvoices, pendingInvoices: pendingInvoices ?? state.pendingInvoices,
invoiceHistory: invoiceHistory ?? state.invoiceHistory, invoiceHistory: invoiceHistory ?? state.invoiceHistory,
spendBreakdown: spendBreakdown ?? state.spendBreakdown, spendBreakdown: spendBreakdown ?? state.spendBreakdown,
bankAccounts: bankAccounts ?? state.bankAccounts, bankAccounts: bankAccounts ?? state.bankAccounts,
),
);
},
onError: (String errorKey) => state.copyWith(
status: BillingStatus.failure,
errorMessage: errorKey,
), ),
); );
} }

View File

@@ -56,7 +56,7 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
final DateFormat formatter = DateFormat('EEEE, MMMM d'); final DateFormat formatter = DateFormat('EEEE, MMMM d');
final String dateLabel = resolvedInvoice.dueDate != null final String dateLabel = resolvedInvoice.dueDate != null
? formatter.format(resolvedInvoice.dueDate!) ? formatter.format(resolvedInvoice.dueDate!)
: 'N/A'; : 'N/A'; // TODO: localize
return Scaffold( return Scaffold(
appBar: UiAppBar( appBar: UiAppBar(
@@ -85,7 +85,7 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
bottomNavigationBar: Container( bottomNavigationBar: Container(
padding: const EdgeInsets.all(UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: UiColors.primaryForeground,
border: Border( border: Border(
top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)), top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
), ),

View File

@@ -95,8 +95,8 @@ class CompletionReviewActions extends StatelessWidget {
context: context, context: context,
builder: (BuildContext dialogContext) => AlertDialog( builder: (BuildContext dialogContext) => AlertDialog(
title: Text(t.client_billing.flag_dialog.title), title: Text(t.client_billing.flag_dialog.title),
surfaceTintColor: Colors.white, surfaceTintColor: UiColors.primaryForeground,
backgroundColor: Colors.white, backgroundColor: UiColors.primaryForeground,
content: TextField( content: TextField(
controller: controller, controller: controller,
decoration: InputDecoration( decoration: InputDecoration(

View File

@@ -23,7 +23,7 @@ class CompletionReviewSearchAndTabs extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4), padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF1F5F9), color: UiColors.muted,
borderRadius: UiConstants.radiusMd, borderRadius: UiConstants.radiusMd,
), ),
child: TextField( child: TextField(
@@ -69,17 +69,17 @@ class CompletionReviewSearchAndTabs extends StatelessWidget {
child: Container( child: Container(
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? const Color(0xFF2563EB) : Colors.white, color: isSelected ? UiColors.primary : UiColors.white,
borderRadius: UiConstants.radiusMd, borderRadius: UiConstants.radiusMd,
border: Border.all( border: Border.all(
color: isSelected ? const Color(0xFF2563EB) : UiColors.border, color: isSelected ? UiColors.primary : UiColors.border,
), ),
), ),
child: Center( child: Center(
child: Text( child: Text(
text, text,
style: UiTypography.body2b.copyWith( style: UiTypography.body2b.copyWith(
color: isSelected ? Colors.white : UiColors.textSecondary, color: isSelected ? UiColors.primaryForeground : UiColors.textSecondary,
), ),
), ),
), ),

View File

@@ -33,7 +33,7 @@ class PendingInvoicesSection extends StatelessWidget {
width: 8, width: 8,
height: 8, height: 8,
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: Colors.orange, color: UiColors.textWarning,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
), ),
@@ -101,7 +101,7 @@ class PendingInvoiceCard extends StatelessWidget {
final DateFormat formatter = DateFormat('EEEE, MMMM d'); final DateFormat formatter = DateFormat('EEEE, MMMM d');
final String dateLabel = invoice.dueDate != null final String dateLabel = invoice.dueDate != null
? formatter.format(invoice.dueDate!) ? formatter.format(invoice.dueDate!)
: 'N/A'; : 'N/A'; // TODO: localize
final double amountDollars = invoice.amountCents / 100.0; final double amountDollars = invoice.amountCents / 100.0;
return Container( return Container(

View File

@@ -3,7 +3,7 @@ import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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/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/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_coverage_stats_usecase.dart';
import 'package:client_coverage/src/domain/usecases/get_shifts_for_date_usecase.dart'; import 'package:client_coverage/src/domain/usecases/get_shifts_for_date_usecase.dart';
@@ -21,8 +21,8 @@ class CoverageModule extends Module {
@override @override
void binds(Injector i) { void binds(Injector i) {
// Repositories // Repositories
i.addLazySingleton<CoverageRepository>( i.addLazySingleton<CoverageRepositoryInterface>(
() => CoverageRepositoryImpl(apiService: i.get<BaseApiService>()), () => CoverageRepositoryInterfaceImpl(apiService: i.get<BaseApiService>()),
); );
// Use Cases // Use Cases

View File

@@ -1,14 +1,14 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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. /// Uses [BaseApiService] with [ClientEndpoints] for all backend access.
class CoverageRepositoryImpl implements CoverageRepository { class CoverageRepositoryInterfaceImpl implements CoverageRepositoryInterface {
/// Creates a [CoverageRepositoryImpl]. /// Creates a [CoverageRepositoryInterfaceImpl].
CoverageRepositoryImpl({required BaseApiService apiService}) CoverageRepositoryInterfaceImpl({required BaseApiService apiService})
: _apiService = apiService; : _apiService = apiService;
final BaseApiService _apiService; final BaseApiService _apiService;

View File

@@ -4,7 +4,7 @@ import 'package:krow_domain/krow_domain.dart';
/// ///
/// Defines the contract for accessing coverage data via the V2 REST API, /// Defines the contract for accessing coverage data via the V2 REST API,
/// acting as a boundary between the Domain and Data layers. /// 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]. /// Fetches shifts with assigned workers for a specific [date].
Future<List<ShiftWithWorkers>> getShiftsForDate({required DateTime date}); Future<List<ShiftWithWorkers>> getShiftsForDate({required DateTime date});

View File

@@ -1,17 +1,17 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:client_coverage/src/domain/arguments/cancel_late_worker_arguments.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. /// 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 class CancelLateWorkerUseCase
implements UseCase<CancelLateWorkerArguments, void> { implements UseCase<CancelLateWorkerArguments, void> {
/// Creates a [CancelLateWorkerUseCase]. /// Creates a [CancelLateWorkerUseCase].
CancelLateWorkerUseCase(this._repository); CancelLateWorkerUseCase(this._repository);
final CoverageRepository _repository; final CoverageRepositoryInterface _repository;
@override @override
Future<void> call(CancelLateWorkerArguments arguments) { Future<void> call(CancelLateWorkerArguments arguments) {

View File

@@ -2,17 +2,17 @@ import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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/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. /// 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 class GetCoverageStatsUseCase
implements UseCase<GetCoverageStatsArguments, CoverageStats> { implements UseCase<GetCoverageStatsArguments, CoverageStats> {
/// Creates a [GetCoverageStatsUseCase]. /// Creates a [GetCoverageStatsUseCase].
GetCoverageStatsUseCase(this._repository); GetCoverageStatsUseCase(this._repository);
final CoverageRepository _repository; final CoverageRepositoryInterface _repository;
@override @override
Future<CoverageStats> call(GetCoverageStatsArguments arguments) { Future<CoverageStats> call(GetCoverageStatsArguments arguments) {

View File

@@ -2,17 +2,17 @@ import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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/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. /// 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 class GetShiftsForDateUseCase
implements UseCase<GetShiftsForDateArguments, List<ShiftWithWorkers>> { implements UseCase<GetShiftsForDateArguments, List<ShiftWithWorkers>> {
/// Creates a [GetShiftsForDateUseCase]. /// Creates a [GetShiftsForDateUseCase].
GetShiftsForDateUseCase(this._repository); GetShiftsForDateUseCase(this._repository);
final CoverageRepository _repository; final CoverageRepositoryInterface _repository;
@override @override
Future<List<ShiftWithWorkers>> call(GetShiftsForDateArguments arguments) { Future<List<ShiftWithWorkers>> call(GetShiftsForDateArguments arguments) {

View File

@@ -1,17 +1,17 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:client_coverage/src/domain/arguments/submit_worker_review_arguments.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. /// 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 class SubmitWorkerReviewUseCase
implements UseCase<SubmitWorkerReviewArguments, void> { implements UseCase<SubmitWorkerReviewArguments, void> {
/// Creates a [SubmitWorkerReviewUseCase]. /// Creates a [SubmitWorkerReviewUseCase].
SubmitWorkerReviewUseCase(this._repository); SubmitWorkerReviewUseCase(this._repository);
final CoverageRepository _repository; final CoverageRepositoryInterface _repository;
@override @override
Future<void> call(SubmitWorkerReviewArguments arguments) async { Future<void> call(SubmitWorkerReviewArguments arguments) async {

View File

@@ -128,6 +128,9 @@ class _CoveragePageState extends State<CoveragePage> {
], ],
flexibleSpace: Container( flexibleSpace: Container(
decoration: const BoxDecoration( 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( gradient: LinearGradient(
colors: <Color>[ colors: <Color>[
UiColors.primary, UiColors.primary,

View File

@@ -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_event.dart';
import 'package:client_coverage/src/presentation/blocs/coverage_state.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 { class WorkerReviewSheet extends StatefulWidget {
const WorkerReviewSheet({required this.worker, super.key}); const WorkerReviewSheet({required this.worker, super.key});
@@ -201,7 +206,7 @@ class _WorkerReviewSheetState extends State<WorkerReviewSheet> {
icon: Icons.favorite, icon: Icons.favorite,
label: l10n.favorite_label, label: l10n.favorite_label,
isActive: _isFavorite, isActive: _isFavorite,
activeColor: const Color(0xFFE91E63), activeColor: _kFavoriteColor,
onTap: () => setState(() => _isFavorite = !_isFavorite), onTap: () => setState(() => _isFavorite = !_isFavorite),
), ),
), ),

View File

@@ -1,20 +1,34 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.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<ClientMainState> 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<ClientMainState>
with BlocErrorHandler<ClientMainState>
implements Disposable {
/// Creates a [ClientMainCubit] and starts listening for route changes.
ClientMainCubit() : super(const ClientMainState()) { ClientMainCubit() : super(const ClientMainState()) {
Modular.to.addListener(_onRouteChanged); Modular.to.addListener(_onRouteChanged);
_onRouteChanged(); _onRouteChanged();
} }
/// Routes that should hide the bottom navigation bar.
static const List<String> _hideBottomBarPaths = <String>[ static const List<String> _hideBottomBarPaths = <String>[
ClientPaths.completionReview, ClientPaths.completionReview,
ClientPaths.awaitingApproval, 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() { void _onRouteChanged() {
if (isClosed) return;
final String path = Modular.to.path; final String path = Modular.to.path;
int newIndex = state.currentIndex; int newIndex = state.currentIndex;
@@ -41,6 +55,9 @@ class ClientMainCubit extends Cubit<ClientMainState> implements Disposable {
} }
} }
/// Navigates to the tab at [index] via Modular safe navigation.
///
/// State update happens automatically via [_onRouteChanged].
void navigateToTab(int index) { void navigateToTab(int index) {
if (index == state.currentIndex) return; if (index == state.currentIndex) return;
@@ -61,7 +78,6 @@ class ClientMainCubit extends Cubit<ClientMainState> implements Disposable {
Modular.to.toClientReports(); Modular.to.toClientReports();
break; break;
} }
// State update will happen via _onRouteChanged
} }
@override @override

View File

@@ -1,14 +1,20 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
/// State for [ClientMainCubit] representing bottom navigation status.
class ClientMainState extends Equatable { class ClientMainState extends Equatable {
/// Creates a [ClientMainState] with the given tab index and bar visibility.
const ClientMainState({ const ClientMainState({
this.currentIndex = 2, // Default to Home this.currentIndex = 2, // Default to Home
this.showBottomBar = true, this.showBottomBar = true,
}); });
/// Index of the currently active bottom navigation tab.
final int currentIndex; final int currentIndex;
/// Whether the bottom navigation bar should be visible.
final bool showBottomBar; final bool showBottomBar;
/// Creates a copy of this state with updated fields.
ClientMainState copyWith({int? currentIndex, bool? showBottomBar}) { ClientMainState copyWith({int? currentIndex, bool? showBottomBar}) {
return ClientMainState( return ClientMainState(
currentIndex: currentIndex ?? this.currentIndex, currentIndex: currentIndex ?? this.currentIndex,

View File

@@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'package:client_hubs/src/presentation/blocs/edit_hub/edit_hub_bloc.dart'; import 'package:client_hubs/src/presentation/blocs/edit_hub/edit_hub_bloc.dart';
@@ -38,7 +39,7 @@ class EditHubPage extends StatelessWidget {
message: message, message: message,
type: UiSnackbarType.success, type: UiSnackbarType.success,
); );
Modular.to.pop(true); Modular.to.popSafe(true);
} }
if (state.status == EditHubStatus.failure && if (state.status == EditHubStatus.failure &&
state.errorMessage != null) { state.errorMessage != null) {
@@ -65,7 +66,7 @@ class EditHubPage extends StatelessWidget {
child: HubForm( child: HubForm(
hub: hub, hub: hub,
costCenters: state.costCenters, costCenters: state.costCenters,
onCancel: () => Modular.to.pop(), onCancel: () => Modular.to.popSafe(),
onSave: ({ onSave: ({
required String name, required String name,
required String fullAddress, required String fullAddress,

View File

@@ -38,7 +38,7 @@ class HubDetailsPage extends StatelessWidget {
message: message, message: message,
type: UiSnackbarType.success, 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 && if (state.status == HubDetailsStatus.failure &&
state.errorMessage != null) { state.errorMessage != null) {
@@ -117,7 +117,7 @@ class HubDetailsPage extends StatelessWidget {
Future<void> _navigateToEditPage(BuildContext context) async { Future<void> _navigateToEditPage(BuildContext context) async {
final bool? saved = await Modular.to.toEditHub(hub: hub); final bool? saved = await Modular.to.toEditHub(hub: hub);
if (saved == true && context.mounted) { if (saved == true && context.mounted) {
Modular.to.pop(true); // Return true to indicate change Modular.to.popSafe(true); // Return true to indicate change
} }
} }

View File

@@ -112,7 +112,7 @@ class _HubFormState extends State<HubForm> {
vertical: 16, vertical: 16,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF8FAFD), color: UiColors.muted,
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
UiConstants.radiusBase * 1.5, UiConstants.radiusBase * 1.5,
), ),
@@ -225,7 +225,7 @@ class _HubFormState extends State<HubForm> {
color: UiColors.textSecondary.withValues(alpha: 0.5), color: UiColors.textSecondary.withValues(alpha: 0.5),
), ),
filled: true, filled: true,
fillColor: const Color(0xFFF8FAFD), fillColor: UiColors.muted,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4, horizontal: UiConstants.space4,
vertical: 16, vertical: 16,

View File

@@ -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_permanent_order_usecase.dart';
import 'domain/usecases/create_rapid_order_usecase.dart'; import 'domain/usecases/create_rapid_order_usecase.dart';
import 'domain/usecases/create_recurring_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_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/parse_rapid_order_usecase.dart';
import 'domain/usecases/transcribe_rapid_order_usecase.dart'; import 'domain/usecases/transcribe_rapid_order_usecase.dart';
import 'presentation/blocs/index.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(CreateOneTimeOrderUseCase.new);
i.addLazySingleton(CreatePermanentOrderUseCase.new); i.addLazySingleton(CreatePermanentOrderUseCase.new);
i.addLazySingleton(CreateRecurringOrderUseCase.new); i.addLazySingleton(CreateRecurringOrderUseCase.new);
@@ -55,6 +59,12 @@ class ClientCreateOrderModule extends Module {
i.addLazySingleton(ParseRapidOrderTextToOrderUseCase.new); i.addLazySingleton(ParseRapidOrderTextToOrderUseCase.new);
i.addLazySingleton(GetOrderDetailsForReorderUseCase.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 // BLoCs
i.add<RapidOrderBloc>( i.add<RapidOrderBloc>(
() => RapidOrderBloc( () => RapidOrderBloc(
@@ -63,15 +73,36 @@ class ClientCreateOrderModule extends Module {
i.get<AudioRecorderService>(), i.get<AudioRecorderService>(),
), ),
); );
i.add<OneTimeOrderBloc>(OneTimeOrderBloc.new); i.add<OneTimeOrderBloc>(
() => OneTimeOrderBloc(
i.get<CreateOneTimeOrderUseCase>(),
i.get<GetOrderDetailsForReorderUseCase>(),
i.get<GetVendorsUseCase>(),
i.get<GetRolesByVendorUseCase>(),
i.get<GetHubsUseCase>(),
i.get<GetManagersByHubUseCase>(),
),
);
i.add<PermanentOrderBloc>( i.add<PermanentOrderBloc>(
() => PermanentOrderBloc( () => PermanentOrderBloc(
i.get<CreatePermanentOrderUseCase>(), i.get<CreatePermanentOrderUseCase>(),
i.get<GetOrderDetailsForReorderUseCase>(), i.get<GetOrderDetailsForReorderUseCase>(),
i.get<ClientOrderQueryRepositoryInterface>(), i.get<GetVendorsUseCase>(),
i.get<GetRolesByVendorUseCase>(),
i.get<GetHubsUseCase>(),
i.get<GetManagersByHubUseCase>(),
),
);
i.add<RecurringOrderBloc>(
() => RecurringOrderBloc(
i.get<CreateRecurringOrderUseCase>(),
i.get<GetOrderDetailsForReorderUseCase>(),
i.get<GetVendorsUseCase>(),
i.get<GetRolesByVendorUseCase>(),
i.get<GetHubsUseCase>(),
i.get<GetManagersByHubUseCase>(),
), ),
); );
i.add<RecurringOrderBloc>(RecurringOrderBloc.new);
} }
@override @override

View File

@@ -1,15 +1,69 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
/// Arguments for the [CreateOneTimeOrderUseCase]. /// A single position entry for a one-time order submission.
/// class OneTimeOrderPositionArgument extends UseCaseArgument {
/// Wraps the V2 API payload map for a one-time order. /// Creates a [OneTimeOrderPositionArgument].
class OneTimeOrderArguments extends UseCaseArgument { const OneTimeOrderPositionArgument({
/// Creates a [OneTimeOrderArguments] with the given [payload]. required this.roleId,
const OneTimeOrderArguments({required this.payload}); required this.workerCount,
required this.startTime,
required this.endTime,
this.roleName,
this.lunchBreak,
});
/// The V2 API payload map. /// The role ID for this position.
final Map<String, dynamic> payload; 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 @override
List<Object?> get props => <Object?>[payload]; List<Object?> get props =>
<Object?>[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<OneTimeOrderPositionArgument> positions;
/// The selected vendor ID, if applicable.
final String? vendorId;
@override
List<Object?> get props =>
<Object?>[hubId, eventName, orderDate, positions, vendorId];
} }

View File

@@ -1,10 +1,75 @@
/// Arguments for the [CreatePermanentOrderUseCase]. import 'package:krow_core/core.dart';
///
/// Wraps the V2 API payload map for a permanent order.
class PermanentOrderArguments {
/// Creates a [PermanentOrderArguments] with the given [payload].
const PermanentOrderArguments({required this.payload});
/// The V2 API payload map. /// A single position entry for a permanent order submission.
final Map<String, dynamic> payload; 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<Object?> get props =>
<Object?>[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<String> daysOfWeek;
/// The list of position entries.
final List<PermanentOrderPositionArgument> positions;
/// The selected vendor ID, if applicable.
final String? vendorId;
@override
List<Object?> get props => <Object?>[
hubId,
eventName,
startDate,
daysOfWeek,
positions,
vendorId,
];
} }

View File

@@ -1,10 +1,80 @@
/// Arguments for the [CreateRecurringOrderUseCase]. import 'package:krow_core/core.dart';
///
/// Wraps the V2 API payload map for a recurring order.
class RecurringOrderArguments {
/// Creates a [RecurringOrderArguments] with the given [payload].
const RecurringOrderArguments({required this.payload});
/// The V2 API payload map. /// A single position entry for a recurring order submission.
final Map<String, dynamic> payload; 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<Object?> get props =>
<Object?>[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<String> recurringDays;
/// The list of position entries.
final List<RecurringOrderPositionArgument> positions;
/// The selected vendor ID, if applicable.
final String? vendorId;
@override
List<Object?> get props => <Object?>[
hubId,
eventName,
startDate,
endDate,
recurringDays,
positions,
vendorId,
];
} }

View File

@@ -5,16 +5,45 @@ import '../repositories/client_create_order_repository_interface.dart';
/// Use case for creating a one-time staffing order. /// 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 class CreateOneTimeOrderUseCase
implements UseCase<OneTimeOrderArguments, void> { implements UseCase<OneTimeOrderArguments, void> {
/// Creates a [CreateOneTimeOrderUseCase]. /// Creates a [CreateOneTimeOrderUseCase].
const CreateOneTimeOrderUseCase(this._repository); const CreateOneTimeOrderUseCase(this._repository);
/// The create-order repository.
final ClientCreateOrderRepositoryInterface _repository; final ClientCreateOrderRepositoryInterface _repository;
@override @override
Future<void> call(OneTimeOrderArguments input) { Future<void> call(OneTimeOrderArguments input) {
return _repository.createOneTimeOrder(input.payload); final String orderDate = formatDateToIso(input.orderDate);
final List<Map<String, dynamic>> positions =
input.positions.map((OneTimeOrderPositionArgument p) {
return <String, dynamic>{
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<String, dynamic> payload = <String, dynamic>{
'hubId': input.hubId,
'eventName': input.eventName,
'orderDate': orderDate,
'positions': positions,
if (input.vendorId != null) 'vendorId': input.vendorId,
};
return _repository.createOneTimeOrder(payload);
} }
} }

View File

@@ -1,17 +1,61 @@
import 'package:krow_core/core.dart';
import '../arguments/permanent_order_arguments.dart'; import '../arguments/permanent_order_arguments.dart';
import '../repositories/client_create_order_repository_interface.dart'; import '../repositories/client_create_order_repository_interface.dart';
/// Day-of-week labels in Sunday-first order, matching the V2 API convention.
const List<String> _dayLabels = <String>[
'SUN',
'MON',
'TUE',
'WED',
'THU',
'FRI',
'SAT',
];
/// Use case for creating a permanent staffing order. /// Use case for creating a permanent staffing order.
/// ///
/// Delegates the V2 API payload to the repository. /// Builds the V2 API payload from typed [PermanentOrderArguments] and
class CreatePermanentOrderUseCase { /// 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<PermanentOrderArguments, void> {
/// Creates a [CreatePermanentOrderUseCase]. /// Creates a [CreatePermanentOrderUseCase].
const CreatePermanentOrderUseCase(this._repository); const CreatePermanentOrderUseCase(this._repository);
/// The create-order repository.
final ClientCreateOrderRepositoryInterface _repository; final ClientCreateOrderRepositoryInterface _repository;
/// Executes the use case with the given [args]. @override
Future<void> call(PermanentOrderArguments args) { Future<void> call(PermanentOrderArguments input) {
return _repository.createPermanentOrder(args.payload); final String startDate = formatDateToIso(input.startDate);
final List<int> daysOfWeek = input.daysOfWeek
.map((String day) => _dayLabels.indexOf(day) % 7)
.toList();
final List<Map<String, dynamic>> positions =
input.positions.map((PermanentOrderPositionArgument p) {
return <String, dynamic>{
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<String, dynamic> payload = <String, dynamic>{
'hubId': input.hubId,
'eventName': input.eventName,
'startDate': startDate,
'daysOfWeek': daysOfWeek,
'positions': positions,
if (input.vendorId != null) 'vendorId': input.vendorId,
};
return _repository.createPermanentOrder(payload);
} }
} }

View File

@@ -1,17 +1,63 @@
import 'package:krow_core/core.dart';
import '../arguments/recurring_order_arguments.dart'; import '../arguments/recurring_order_arguments.dart';
import '../repositories/client_create_order_repository_interface.dart'; import '../repositories/client_create_order_repository_interface.dart';
/// Day-of-week labels in Sunday-first order, matching the V2 API convention.
const List<String> _dayLabels = <String>[
'SUN',
'MON',
'TUE',
'WED',
'THU',
'FRI',
'SAT',
];
/// Use case for creating a recurring staffing order. /// Use case for creating a recurring staffing order.
/// ///
/// Delegates the V2 API payload to the repository. /// Builds the V2 API payload from typed [RecurringOrderArguments] and
class CreateRecurringOrderUseCase { /// 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<RecurringOrderArguments, void> {
/// Creates a [CreateRecurringOrderUseCase]. /// Creates a [CreateRecurringOrderUseCase].
const CreateRecurringOrderUseCase(this._repository); const CreateRecurringOrderUseCase(this._repository);
/// The create-order repository.
final ClientCreateOrderRepositoryInterface _repository; final ClientCreateOrderRepositoryInterface _repository;
/// Executes the use case with the given [args]. @override
Future<void> call(RecurringOrderArguments args) { Future<void> call(RecurringOrderArguments input) {
return _repository.createRecurringOrder(args.payload); final String startDate = formatDateToIso(input.startDate);
final String endDate = formatDateToIso(input.endDate);
final List<int> recurrenceDays = input.recurringDays
.map((String day) => _dayLabels.indexOf(day) % 7)
.toList();
final List<Map<String, dynamic>> positions =
input.positions.map((RecurringOrderPositionArgument p) {
return <String, dynamic>{
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<String, dynamic> payload = <String, dynamic>{
'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);
} }
} }

View File

@@ -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<List<OrderHub>> {
/// Creates a [GetHubsUseCase].
const GetHubsUseCase(this._repository);
/// The query repository for order reference data.
final ClientOrderQueryRepositoryInterface _repository;
@override
Future<List<OrderHub>> call() {
return _repository.getHubs();
}
}

View File

@@ -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<String, List<OrderManager>> {
/// Creates a [GetManagersByHubUseCase].
const GetManagersByHubUseCase(this._repository);
/// The query repository for order reference data.
final ClientOrderQueryRepositoryInterface _repository;
@override
Future<List<OrderManager>> call(String hubId) {
return _repository.getManagersByHub(hubId);
}
}

View File

@@ -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<String, List<OrderRole>> {
/// Creates a [GetRolesByVendorUseCase].
const GetRolesByVendorUseCase(this._repository);
/// The query repository for order reference data.
final ClientOrderQueryRepositoryInterface _repository;
@override
Future<List<OrderRole>> call(String vendorId) {
return _repository.getRolesByVendor(vendorId);
}
}

View File

@@ -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<List<Vendor>> {
/// Creates a [GetVendorsUseCase].
const GetVendorsUseCase(this._repository);
/// The query repository for order reference data.
final ClientOrderQueryRepositoryInterface _repository;
@override
Future<List<Vendor>> call() {
return _repository.getVendors();
}
}

View File

@@ -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_hub.dart';
import 'package:client_create_order/src/domain/models/order_manager.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/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/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_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:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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. /// 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<OneTimeOrderEvent, OneTimeOrderState> class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
with with
BlocErrorHandler<OneTimeOrderState>, BlocErrorHandler<OneTimeOrderState>,
SafeBloc<OneTimeOrderEvent, OneTimeOrderState> { SafeBloc<OneTimeOrderEvent, OneTimeOrderState> {
/// Creates the BLoC with required dependencies. /// Creates the BLoC with required use case dependencies.
OneTimeOrderBloc( OneTimeOrderBloc(
this._createOneTimeOrderUseCase, this._createOneTimeOrderUseCase,
this._getOrderDetailsForReorderUseCase, this._getOrderDetailsForReorderUseCase,
this._queryRepository, this._getVendorsUseCase,
this._getRolesByVendorUseCase,
this._getHubsUseCase,
this._getManagersByHubUseCase,
) : super(OneTimeOrderState.initial()) { ) : super(OneTimeOrderState.initial()) {
on<OneTimeOrderVendorsLoaded>(_onVendorsLoaded); on<OneTimeOrderVendorsLoaded>(_onVendorsLoaded);
on<OneTimeOrderVendorChanged>(_onVendorChanged); on<OneTimeOrderVendorChanged>(_onVendorChanged);
@@ -45,16 +52,21 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
final CreateOneTimeOrderUseCase _createOneTimeOrderUseCase; final CreateOneTimeOrderUseCase _createOneTimeOrderUseCase;
final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase; 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<void> _loadVendors() async { Future<void> _loadVendors() async {
final List<Vendor>? vendors = await handleErrorWithResult( final List<Vendor>? vendors = await handleErrorWithResult(
action: () => _queryRepository.getVendors(), action: () => _getVendorsUseCase(),
onError: (_) => add(const OneTimeOrderVendorsLoaded(<Vendor>[])), onError: (_) => add(const OneTimeOrderVendorsLoaded(<Vendor>[])),
); );
if (vendors != null) add(OneTimeOrderVendorsLoaded(vendors)); if (vendors != null) add(OneTimeOrderVendorsLoaded(vendors));
} }
/// Loads roles for [vendorId] and maps them to presentation option models.
Future<void> _loadRolesForVendor( Future<void> _loadRolesForVendor(
String vendorId, String vendorId,
Emitter<OneTimeOrderState> emit, Emitter<OneTimeOrderState> emit,
@@ -62,7 +74,7 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
final List<OneTimeOrderRoleOption>? roles = await handleErrorWithResult( final List<OneTimeOrderRoleOption>? roles = await handleErrorWithResult(
action: () async { action: () async {
final List<OrderRole> result = final List<OrderRole> result =
await _queryRepository.getRolesByVendor(vendorId); await _getRolesByVendorUseCase(vendorId);
return result return result
.map((OrderRole r) => OneTimeOrderRoleOption( .map((OrderRole r) => OneTimeOrderRoleOption(
id: r.id, name: r.name, costPerHour: r.costPerHour)) id: r.id, name: r.name, costPerHour: r.costPerHour))
@@ -74,10 +86,11 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
if (roles != null) emit(state.copyWith(roles: roles)); if (roles != null) emit(state.copyWith(roles: roles));
} }
/// Loads hubs via the use case and maps to presentation option models.
Future<void> _loadHubs() async { Future<void> _loadHubs() async {
final List<OneTimeOrderHubOption>? hubs = await handleErrorWithResult( final List<OneTimeOrderHubOption>? hubs = await handleErrorWithResult(
action: () async { action: () async {
final List<OrderHub> result = await _queryRepository.getHubs(); final List<OrderHub> result = await _getHubsUseCase();
return result return result
.map((OrderHub h) => OneTimeOrderHubOption( .map((OrderHub h) => OneTimeOrderHubOption(
id: h.id, id: h.id,
@@ -100,12 +113,13 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
if (hubs != null) add(OneTimeOrderHubsLoaded(hubs)); if (hubs != null) add(OneTimeOrderHubsLoaded(hubs));
} }
/// Loads managers for [hubId] via the use case.
Future<void> _loadManagersForHub(String hubId) async { Future<void> _loadManagersForHub(String hubId) async {
final List<OneTimeOrderManagerOption>? managers = final List<OneTimeOrderManagerOption>? managers =
await handleErrorWithResult( await handleErrorWithResult(
action: () async { action: () async {
final List<OrderManager> result = final List<OrderManager> result =
await _queryRepository.getManagersByHub(hubId); await _getManagersByHubUseCase(hubId);
return result return result
.map((OrderManager m) => .map((OrderManager m) =>
OneTimeOrderManagerOption(id: m.id, name: m.name)) OneTimeOrderManagerOption(id: m.id, name: m.name))
@@ -224,7 +238,7 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
emit(state.copyWith(positions: newPositions)); 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<void> _onSubmitted( Future<void> _onSubmitted(
OneTimeOrderSubmitted event, OneTimeOrderSubmitted event,
Emitter<OneTimeOrderState> emit, Emitter<OneTimeOrderState> emit,
@@ -236,12 +250,7 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
final OneTimeOrderHubOption? selectedHub = state.selectedHub; final OneTimeOrderHubOption? selectedHub = state.selectedHub;
if (selectedHub == null) throw const OrderMissingHubException(); if (selectedHub == null) throw const OrderMissingHubException();
final String orderDate = final List<OneTimeOrderPositionArgument> positionArgs =
'${state.date.year.toString().padLeft(4, '0')}-'
'${state.date.month.toString().padLeft(2, '0')}-'
'${state.date.day.toString().padLeft(2, '0')}';
final List<Map<String, dynamic>> positions =
state.positions.map((OneTimeOrderPosition p) { state.positions.map((OneTimeOrderPosition p) {
final OneTimeOrderRoleOption? role = state.roles final OneTimeOrderRoleOption? role = state.roles
.cast<OneTimeOrderRoleOption?>() .cast<OneTimeOrderRoleOption?>()
@@ -249,28 +258,24 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
(OneTimeOrderRoleOption? r) => r != null && r.id == p.role, (OneTimeOrderRoleOption? r) => r != null && r.id == p.role,
orElse: () => null, orElse: () => null,
); );
return <String, dynamic>{ return OneTimeOrderPositionArgument(
if (role != null) 'roleName': role.name, roleId: p.role,
if (p.role.isNotEmpty) 'roleId': p.role, roleName: role?.name,
'workerCount': p.count, workerCount: p.count,
'startTime': p.startTime, startTime: p.startTime,
'endTime': p.endTime, endTime: p.endTime,
if (p.lunchBreak != 'NO_BREAK' && p.lunchBreak.isNotEmpty) lunchBreak: p.lunchBreak,
'lunchBreakMinutes': _breakMinutes(p.lunchBreak), );
};
}).toList(); }).toList();
final Map<String, dynamic> payload = <String, dynamic>{
'hubId': selectedHub.id,
'eventName': state.eventName,
'orderDate': orderDate,
'positions': positions,
if (state.selectedVendor != null)
'vendorId': state.selectedVendor!.id,
};
await _createOneTimeOrderUseCase( 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)); emit(state.copyWith(status: OneTimeOrderStatus.success));
}, },
@@ -339,8 +344,8 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
positions.add(OneTimeOrderPosition( positions.add(OneTimeOrderPosition(
role: role.roleId, role: role.roleId,
count: role.workersNeeded, count: role.workersNeeded,
startTime: _formatTime(shift.startsAt), startTime: formatTimeHHmm(shift.startsAt),
endTime: _formatTime(shift.endsAt), endTime: formatTimeHHmm(shift.endsAt),
)); ));
} }
} }
@@ -357,29 +362,4 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
), ),
); );
} }
/// 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;
}
}
} }

View File

@@ -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_hub.dart';
import 'package:client_create_order/src/domain/models/order_manager.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/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/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_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:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.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 'package:krow_domain/krow_domain.dart' as domain;
import 'permanent_order_event.dart'; import 'permanent_order_event.dart';
import 'permanent_order_state.dart'; import 'permanent_order_state.dart';
/// BLoC for managing the permanent order creation form. /// 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<PermanentOrderEvent, PermanentOrderState> class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
with with
BlocErrorHandler<PermanentOrderState>, BlocErrorHandler<PermanentOrderState>,
SafeBloc<PermanentOrderEvent, PermanentOrderState> { SafeBloc<PermanentOrderEvent, PermanentOrderState> {
/// Creates a BLoC with required use case dependencies.
PermanentOrderBloc( PermanentOrderBloc(
this._createPermanentOrderUseCase, this._createPermanentOrderUseCase,
this._getOrderDetailsForReorderUseCase, this._getOrderDetailsForReorderUseCase,
this._queryRepository, this._getVendorsUseCase,
this._getRolesByVendorUseCase,
this._getHubsUseCase,
this._getManagersByHubUseCase,
) : super(PermanentOrderState.initial()) { ) : super(PermanentOrderState.initial()) {
on<PermanentOrderVendorsLoaded>(_onVendorsLoaded); on<PermanentOrderVendorsLoaded>(_onVendorsLoaded);
on<PermanentOrderVendorChanged>(_onVendorChanged); on<PermanentOrderVendorChanged>(_onVendorChanged);
@@ -43,7 +53,10 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
final CreatePermanentOrderUseCase _createPermanentOrderUseCase; final CreatePermanentOrderUseCase _createPermanentOrderUseCase;
final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase; final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase;
final ClientOrderQueryRepositoryInterface _queryRepository; final GetVendorsUseCase _getVendorsUseCase;
final GetRolesByVendorUseCase _getRolesByVendorUseCase;
final GetHubsUseCase _getHubsUseCase;
final GetManagersByHubUseCase _getManagersByHubUseCase;
static const List<String> _dayLabels = <String>[ static const List<String> _dayLabels = <String>[
'SUN', 'SUN',
@@ -55,9 +68,10 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
'SAT', 'SAT',
]; ];
/// Loads available vendors via the use case.
Future<void> _loadVendors() async { Future<void> _loadVendors() async {
final List<domain.Vendor>? vendors = await handleErrorWithResult( final List<domain.Vendor>? vendors = await handleErrorWithResult(
action: () => _queryRepository.getVendors(), action: () => _getVendorsUseCase(),
onError: (_) => add(const PermanentOrderVendorsLoaded(<domain.Vendor>[])), onError: (_) => add(const PermanentOrderVendorsLoaded(<domain.Vendor>[])),
); );
@@ -66,6 +80,8 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
} }
} }
/// Loads roles for [vendorId] via the use case and maps them to
/// presentation option models.
Future<void> _loadRolesForVendor( Future<void> _loadRolesForVendor(
String vendorId, String vendorId,
Emitter<PermanentOrderState> emit, Emitter<PermanentOrderState> emit,
@@ -73,7 +89,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
final List<PermanentOrderRoleOption>? roles = await handleErrorWithResult( final List<PermanentOrderRoleOption>? roles = await handleErrorWithResult(
action: () async { action: () async {
final List<OrderRole> orderRoles = final List<OrderRole> orderRoles =
await _queryRepository.getRolesByVendor(vendorId); await _getRolesByVendorUseCase(vendorId);
return orderRoles return orderRoles
.map( .map(
(OrderRole r) => PermanentOrderRoleOption( (OrderRole r) => PermanentOrderRoleOption(
@@ -93,10 +109,11 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
} }
} }
/// Loads hubs via the use case and maps them to presentation option models.
Future<void> _loadHubs() async { Future<void> _loadHubs() async {
final List<PermanentOrderHubOption>? hubs = await handleErrorWithResult( final List<PermanentOrderHubOption>? hubs = await handleErrorWithResult(
action: () async { action: () async {
final List<OrderHub> orderHubs = await _queryRepository.getHubs(); final List<OrderHub> orderHubs = await _getHubsUseCase();
return orderHubs return orderHubs
.map( .map(
(OrderHub hub) => PermanentOrderHubOption( (OrderHub hub) => PermanentOrderHubOption(
@@ -193,6 +210,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
emit(state.copyWith(managers: event.managers)); emit(state.copyWith(managers: event.managers));
} }
/// Loads managers for [hubId] via the use case.
Future<void> _loadManagersForHub( Future<void> _loadManagersForHub(
String hubId, String hubId,
Emitter<PermanentOrderState> emit, Emitter<PermanentOrderState> emit,
@@ -201,7 +219,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
await handleErrorWithResult( await handleErrorWithResult(
action: () async { action: () async {
final List<OrderManager> orderManagers = final List<OrderManager> orderManagers =
await _queryRepository.getManagersByHub(hubId); await _getManagersByHubUseCase(hubId);
return orderManagers return orderManagers
.map( .map(
(OrderManager m) => PermanentOrderManagerOption( (OrderManager m) => PermanentOrderManagerOption(
@@ -221,7 +239,6 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
} }
} }
void _onEventNameChanged( void _onEventNameChanged(
PermanentOrderEventNameChanged event, PermanentOrderEventNameChanged event,
Emitter<PermanentOrderState> emit, Emitter<PermanentOrderState> emit,
@@ -315,6 +332,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
emit(state.copyWith(positions: newPositions)); emit(state.copyWith(positions: newPositions));
} }
/// Builds typed arguments from form state and submits via the use case.
Future<void> _onSubmitted( Future<void> _onSubmitted(
PermanentOrderSubmitted event, PermanentOrderSubmitted event,
Emitter<PermanentOrderState> emit, Emitter<PermanentOrderState> emit,
@@ -328,16 +346,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
throw const domain.OrderMissingHubException(); throw const domain.OrderMissingHubException();
} }
final String startDate = final List<PermanentOrderPositionArgument> positionArgs =
'${state.startDate.year.toString().padLeft(4, '0')}-'
'${state.startDate.month.toString().padLeft(2, '0')}-'
'${state.startDate.day.toString().padLeft(2, '0')}';
final List<int> daysOfWeek = state.permanentDays
.map((String day) => _dayLabels.indexOf(day) % 7)
.toList();
final List<Map<String, dynamic>> positions =
state.positions.map((PermanentOrderPosition p) { state.positions.map((PermanentOrderPosition p) {
final PermanentOrderRoleOption? role = state.roles final PermanentOrderRoleOption? role = state.roles
.cast<PermanentOrderRoleOption?>() .cast<PermanentOrderRoleOption?>()
@@ -345,27 +354,24 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
(PermanentOrderRoleOption? r) => r != null && r.id == p.role, (PermanentOrderRoleOption? r) => r != null && r.id == p.role,
orElse: () => null, orElse: () => null,
); );
return <String, dynamic>{ return PermanentOrderPositionArgument(
if (role != null) 'roleName': role.name, roleId: p.role,
if (p.role.isNotEmpty) 'roleId': p.role, roleName: role?.name,
'workerCount': p.count, workerCount: p.count,
'startTime': p.startTime, startTime: p.startTime,
'endTime': p.endTime, endTime: p.endTime,
}; );
}).toList(); }).toList();
final Map<String, dynamic> payload = <String, dynamic>{
'hubId': selectedHub.id,
'eventName': state.eventName,
'startDate': startDate,
'daysOfWeek': daysOfWeek,
'positions': positions,
if (state.selectedVendor != null)
'vendorId': state.selectedVendor!.id,
};
await _createPermanentOrderUseCase( 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)); emit(state.copyWith(status: PermanentOrderStatus.success));
}, },
@@ -376,6 +382,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
); );
} }
/// Initializes the form from route arguments or reorder preview data.
Future<void> _onInitialized( Future<void> _onInitialized(
PermanentOrderInitialized event, PermanentOrderInitialized event,
Emitter<PermanentOrderState> emit, Emitter<PermanentOrderState> emit,
@@ -406,8 +413,8 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
positions.add(PermanentOrderPosition( positions.add(PermanentOrderPosition(
role: role.roleId, role: role.roleId,
count: role.workersNeeded, count: role.workersNeeded,
startTime: _formatTime(shift.startsAt), startTime: formatTimeHHmm(shift.startsAt),
endTime: _formatTime(shift.endsAt), endTime: formatTimeHHmm(shift.endsAt),
)); ));
} }
} }
@@ -430,13 +437,6 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
); );
} }
/// 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<String> _sortDays(List<String> days) { static List<String> _sortDays(List<String> days) {
days.sort( days.sort(
(String a, String b) => (String a, String b) =>

View File

@@ -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_hub.dart';
import 'package:client_create_order/src/domain/models/order_manager.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/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/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_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:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.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 'package:krow_domain/krow_domain.dart' as domain;
import 'recurring_order_event.dart'; import 'recurring_order_event.dart';
@@ -14,19 +17,20 @@ import 'recurring_order_state.dart';
/// BLoC for managing the recurring order creation form. /// BLoC for managing the recurring order creation form.
/// ///
/// Delegates all backend queries to [ClientOrderQueryRepositoryInterface] /// Delegates all data fetching to query use cases and order submission
/// and order submission to [CreateRecurringOrderUseCase]. /// to [CreateRecurringOrderUseCase]. Builds V2 API payloads from form state.
/// Builds V2 API payloads from form state.
class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState> class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
with with
BlocErrorHandler<RecurringOrderState>, BlocErrorHandler<RecurringOrderState>,
SafeBloc<RecurringOrderEvent, RecurringOrderState> { SafeBloc<RecurringOrderEvent, RecurringOrderState> {
/// Creates a [RecurringOrderBloc] with the required use cases and /// Creates a [RecurringOrderBloc] with the required use case dependencies.
/// query repository.
RecurringOrderBloc( RecurringOrderBloc(
this._createRecurringOrderUseCase, this._createRecurringOrderUseCase,
this._getOrderDetailsForReorderUseCase, this._getOrderDetailsForReorderUseCase,
this._queryRepository, this._getVendorsUseCase,
this._getRolesByVendorUseCase,
this._getHubsUseCase,
this._getManagersByHubUseCase,
) : super(RecurringOrderState.initial()) { ) : super(RecurringOrderState.initial()) {
on<RecurringOrderVendorsLoaded>(_onVendorsLoaded); on<RecurringOrderVendorsLoaded>(_onVendorsLoaded);
on<RecurringOrderVendorChanged>(_onVendorChanged); on<RecurringOrderVendorChanged>(_onVendorChanged);
@@ -50,7 +54,10 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
final CreateRecurringOrderUseCase _createRecurringOrderUseCase; final CreateRecurringOrderUseCase _createRecurringOrderUseCase;
final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase; final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase;
final ClientOrderQueryRepositoryInterface _queryRepository; final GetVendorsUseCase _getVendorsUseCase;
final GetRolesByVendorUseCase _getRolesByVendorUseCase;
final GetHubsUseCase _getHubsUseCase;
final GetManagersByHubUseCase _getManagersByHubUseCase;
static const List<String> _dayLabels = <String>[ static const List<String> _dayLabels = <String>[
'SUN', 'SUN',
@@ -62,12 +69,10 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
'SAT', 'SAT',
]; ];
/// Loads the list of available vendors from the query repository. /// Loads the list of available vendors via the use case.
Future<void> _loadVendors() async { Future<void> _loadVendors() async {
final List<domain.Vendor>? vendors = await handleErrorWithResult( final List<domain.Vendor>? vendors = await handleErrorWithResult(
action: () async { action: () => _getVendorsUseCase(),
return _queryRepository.getVendors();
},
onError: (_) => onError: (_) =>
add(const RecurringOrderVendorsLoaded(<domain.Vendor>[])), add(const RecurringOrderVendorsLoaded(<domain.Vendor>[])),
); );
@@ -77,8 +82,8 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
} }
} }
/// Loads roles for the given [vendorId] and maps them to presentation /// Loads roles for [vendorId] via the use case and maps them to
/// option models. /// presentation option models.
Future<void> _loadRolesForVendor( Future<void> _loadRolesForVendor(
String vendorId, String vendorId,
Emitter<RecurringOrderState> emit, Emitter<RecurringOrderState> emit,
@@ -86,7 +91,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
final List<RecurringOrderRoleOption>? roles = await handleErrorWithResult( final List<RecurringOrderRoleOption>? roles = await handleErrorWithResult(
action: () async { action: () async {
final List<OrderRole> orderRoles = final List<OrderRole> orderRoles =
await _queryRepository.getRolesByVendor(vendorId); await _getRolesByVendorUseCase(vendorId);
return orderRoles return orderRoles
.map( .map(
(OrderRole r) => RecurringOrderRoleOption( (OrderRole r) => RecurringOrderRoleOption(
@@ -106,12 +111,12 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
} }
} }
/// Loads team hubs for the current business owner and maps them to /// Loads team hubs via the use case and maps them to presentation
/// presentation option models. /// option models.
Future<void> _loadHubs() async { Future<void> _loadHubs() async {
final List<RecurringOrderHubOption>? hubs = await handleErrorWithResult( final List<RecurringOrderHubOption>? hubs = await handleErrorWithResult(
action: () async { action: () async {
final List<OrderHub> orderHubs = await _queryRepository.getHubs(); final List<OrderHub> orderHubs = await _getHubsUseCase();
return orderHubs return orderHubs
.map( .map(
(OrderHub hub) => RecurringOrderHubOption( (OrderHub hub) => RecurringOrderHubOption(
@@ -208,8 +213,8 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
emit(state.copyWith(managers: event.managers)); emit(state.copyWith(managers: event.managers));
} }
/// Loads managers for the given [hubId] and maps them to presentation /// Loads managers for [hubId] via the use case and maps them to
/// option models. /// presentation option models.
Future<void> _loadManagersForHub( Future<void> _loadManagersForHub(
String hubId, String hubId,
Emitter<RecurringOrderState> emit, Emitter<RecurringOrderState> emit,
@@ -218,7 +223,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
await handleErrorWithResult( await handleErrorWithResult(
action: () async { action: () async {
final List<OrderManager> orderManagers = final List<OrderManager> orderManagers =
await _queryRepository.getManagersByHub(hubId); await _getManagersByHubUseCase(hubId);
return orderManagers return orderManagers
.map( .map(
(OrderManager m) => RecurringOrderManagerOption( (OrderManager m) => RecurringOrderManagerOption(
@@ -347,6 +352,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
emit(state.copyWith(positions: newPositions)); emit(state.copyWith(positions: newPositions));
} }
/// Builds typed arguments from form state and submits via the use case.
Future<void> _onSubmitted( Future<void> _onSubmitted(
RecurringOrderSubmitted event, RecurringOrderSubmitted event,
Emitter<RecurringOrderState> emit, Emitter<RecurringOrderState> emit,
@@ -360,21 +366,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
throw const domain.OrderMissingHubException(); throw const domain.OrderMissingHubException();
} }
final String startDate = final List<RecurringOrderPositionArgument> positionArgs =
'${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<int> recurrenceDays = state.recurringDays
.map((String day) => _dayLabels.indexOf(day) % 7)
.toList();
final List<Map<String, dynamic>> positions =
state.positions.map((RecurringOrderPosition p) { state.positions.map((RecurringOrderPosition p) {
final RecurringOrderRoleOption? role = state.roles final RecurringOrderRoleOption? role = state.roles
.cast<RecurringOrderRoleOption?>() .cast<RecurringOrderRoleOption?>()
@@ -382,28 +374,25 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
(RecurringOrderRoleOption? r) => r != null && r.id == p.role, (RecurringOrderRoleOption? r) => r != null && r.id == p.role,
orElse: () => null, orElse: () => null,
); );
return <String, dynamic>{ return RecurringOrderPositionArgument(
if (role != null) 'roleName': role.name, roleId: p.role,
if (p.role.isNotEmpty) 'roleId': p.role, roleName: role?.name,
'workerCount': p.count, workerCount: p.count,
'startTime': p.startTime, startTime: p.startTime,
'endTime': p.endTime, endTime: p.endTime,
}; );
}).toList(); }).toList();
final Map<String, dynamic> payload = <String, dynamic>{
'hubId': selectedHub.id,
'eventName': state.eventName,
'startDate': startDate,
'endDate': endDate,
'recurrenceDays': recurrenceDays,
'positions': positions,
if (state.selectedVendor != null)
'vendorId': state.selectedVendor!.id,
};
await _createRecurringOrderUseCase( 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)); emit(state.copyWith(status: RecurringOrderStatus.success));
}, },
@@ -414,6 +403,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
); );
} }
/// Initializes the form from route arguments or reorder preview data.
Future<void> _onInitialized( Future<void> _onInitialized(
RecurringOrderInitialized event, RecurringOrderInitialized event,
Emitter<RecurringOrderState> emit, Emitter<RecurringOrderState> emit,
@@ -445,8 +435,8 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
positions.add(RecurringOrderPosition( positions.add(RecurringOrderPosition(
role: role.roleId, role: role.roleId,
count: role.workersNeeded, count: role.workersNeeded,
startTime: _formatTime(shift.startsAt), startTime: formatTimeHHmm(shift.startsAt),
endTime: _formatTime(shift.endsAt), endTime: formatTimeHHmm(shift.endsAt),
)); ));
} }
} }
@@ -470,13 +460,6 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
); );
} }
/// 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<String> _sortDays(List<String> days) { static List<String> _sortDays(List<String> days) {
days.sort( days.sort(
(String a, String b) => (String a, String b) =>

View File

@@ -1,13 +1,13 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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 /// Replaces the old Data Connect implementation with [BaseApiService] calls
/// to the V2 query and command API endpoints. /// to the V2 query and command API endpoints.
class ViewOrdersRepositoryImpl implements IViewOrdersRepository { class ViewOrdersRepositoryImpl implements ViewOrdersRepositoryInterface {
/// Creates an instance backed by the given [apiService]. /// Creates an instance backed by the given [apiService].
ViewOrdersRepositoryImpl({required BaseApiService apiService}) ViewOrdersRepositoryImpl({required BaseApiService apiService})
: _api = apiService; : _api = apiService;

View File

@@ -4,7 +4,7 @@ import 'package:krow_domain/krow_domain.dart';
/// ///
/// V2 API returns workers inline with order items, so the separate /// V2 API returns workers inline with order items, so the separate
/// accepted-applications method is no longer needed. /// 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. /// Fetches [OrderItem] list for the given date range via the V2 API.
Future<List<OrderItem>> getOrdersForRange({ Future<List<OrderItem>> getOrdersForRange({
required DateTime start, required DateTime start,

View File

@@ -1,18 +1,18 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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'; import '../arguments/orders_range_arguments.dart';
/// Use case for retrieving the list of client orders. /// Use case for retrieving the list of client orders.
/// ///
/// This use case encapsulates the business rule of fetching 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 class GetOrdersUseCase
implements UseCase<OrdersRangeArguments, List<OrderItem>> { implements UseCase<OrdersRangeArguments, List<OrderItem>> {
/// Creates a [GetOrdersUseCase] with the required [IViewOrdersRepository]. /// Creates a [GetOrdersUseCase] with the required [ViewOrdersRepositoryInterface].
GetOrdersUseCase(this._repository); GetOrdersUseCase(this._repository);
final IViewOrdersRepository _repository; final ViewOrdersRepositoryInterface _repository;
@override @override
Future<List<OrderItem>> call(OrdersRangeArguments input) { Future<List<OrderItem>> call(OrdersRangeArguments input) {

View File

@@ -4,11 +4,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.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. /// 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. /// The V2 `clientOrderEdit` endpoint creates an edited copy.
class OrderEditSheet extends StatefulWidget { class OrderEditSheet extends StatefulWidget {
/// Creates an [OrderEditSheet] for the given [order]. /// Creates an [OrderEditSheet] for the given [order].
@@ -39,12 +39,12 @@ class OrderEditSheetState extends State<OrderEditSheet> {
List<Map<String, dynamic>> _hubs = const <Map<String, dynamic>>[]; List<Map<String, dynamic>> _hubs = const <Map<String, dynamic>>[];
Map<String, dynamic>? _selectedHub; Map<String, dynamic>? _selectedHub;
late IViewOrdersRepository _repository; late ViewOrdersRepositoryInterface _repository;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_repository = Modular.get<IViewOrdersRepository>(); _repository = Modular.get<ViewOrdersRepositoryInterface>();
_orderNameController = TextEditingController(text: widget.order.roleName); _orderNameController = TextEditingController(text: widget.order.roleName);
final String startHH = final String startHH =
@@ -441,9 +441,9 @@ class OrderEditSheetState extends State<OrderEditSheet> {
const SizedBox(height: UiConstants.space3), const SizedBox(height: UiConstants.space3),
// Role selector // Role selector
_buildSectionHeader('ROLE'), _buildSectionHeader('ROLE'), // TODO: localize
_buildDropdown( _buildDropdown(
hint: 'Select role', hint: 'Select role', // TODO: localize
value: roleName.isNotEmpty ? roleName : null, value: roleName.isNotEmpty ? roleName : null,
items: _roles items: _roles
.map((Map<String, dynamic> r) => r['roleName'] as String? ?? r['name'] as String? ?? '') .map((Map<String, dynamic> r) => r['roleName'] as String? ?? r['name'] as String? ?? '')
@@ -495,7 +495,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
child: _buildInlineTimeInput( child: _buildInlineTimeInput(
label: 'Start Time', label: 'Start Time', // TODO: localize
value: pos['startTime'] as String? ?? '09:00', value: pos['startTime'] as String? ?? '09:00',
onTap: () async { onTap: () async {
final TimeOfDay? picked = await showTimePicker( final TimeOfDay? picked = await showTimePicker(
@@ -513,7 +513,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
const SizedBox(width: UiConstants.space3), const SizedBox(width: UiConstants.space3),
Expanded( Expanded(
child: _buildInlineTimeInput( child: _buildInlineTimeInput(
label: 'End Time', label: 'End Time', // TODO: localize
value: pos['endTime'] as String? ?? '17:00', value: pos['endTime'] as String? ?? '17:00',
onTap: () async { onTap: () async {
final TimeOfDay? picked = await showTimePicker( final TimeOfDay? picked = await showTimePicker(
@@ -825,6 +825,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
style: UiTypography.body2b.textPrimary, style: UiTypography.body2b.textPrimary,
), ),
Text( Text(
// TODO: localize
'${pos['workerCount']} worker${(pos['workerCount'] as int? ?? 1) > 1 ? 's' : ''}', '${pos['workerCount']} worker${(pos['workerCount'] as int? ?? 1) > 1 ? 's' : ''}',
style: UiTypography.footnote2r.textSecondary, style: UiTypography.footnote2r.textSecondary,
), ),

View File

@@ -4,7 +4,7 @@ import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'data/repositories/view_orders_repository_impl.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 'domain/usecases/get_orders_use_case.dart';
import 'presentation/blocs/view_orders_cubit.dart'; import 'presentation/blocs/view_orders_cubit.dart';
import 'presentation/pages/view_orders_page.dart'; import 'presentation/pages/view_orders_page.dart';
@@ -20,7 +20,7 @@ class ViewOrdersModule extends Module {
@override @override
void binds(Injector i) { void binds(Injector i) {
// Repositories // Repositories
i.add<IViewOrdersRepository>( i.add<ViewOrdersRepositoryInterface>(
() => ViewOrdersRepositoryImpl( () => ViewOrdersRepositoryImpl(
apiService: i.get<BaseApiService>(), apiService: i.get<BaseApiService>(),
), ),

View File

@@ -3,12 +3,12 @@ import 'package:krow_domain/krow_domain.dart';
import 'package:client_reports/src/domain/repositories/reports_repository.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, /// Each method hits its corresponding `ClientEndpoints.reports*` endpoint,
/// passing date-range query parameters, and deserialises the JSON response /// passing date-range query parameters, and deserialises the JSON response
/// into the relevant domain entity. /// into the relevant domain entity.
class ReportsRepositoryImpl implements ReportsRepository { class ReportsRepositoryImpl implements ReportsRepositoryInterface {
/// Creates a [ReportsRepositoryImpl]. /// Creates a [ReportsRepositoryImpl].
ReportsRepositoryImpl({required BaseApiService apiService}) ReportsRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService; : _apiService = apiService;

View File

@@ -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<Object?> get props => <Object?>[date];
}

View File

@@ -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<Object?> get props => <Object?>[startDate, endDate];
}

View File

@@ -1,7 +1,7 @@
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
/// Contract for fetching report data from the V2 API. /// Contract for fetching report data from the V2 API.
abstract class ReportsRepository { abstract class ReportsRepositoryInterface {
/// Fetches the daily operations report for a given [date]. /// Fetches the daily operations report for a given [date].
Future<DailyOpsReport> getDailyOpsReport({ Future<DailyOpsReport> getDailyOpsReport({
required DateTime date, required DateTime date,

View File

@@ -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<DateRangeArguments, CoverageReport> {
/// Creates a [GetCoverageReportUseCase].
GetCoverageReportUseCase(this._repository);
/// The repository providing report data.
final ReportsRepositoryInterface _repository;
@override
Future<CoverageReport> call(DateRangeArguments input) {
return _repository.getCoverageReport(
startDate: input.startDate,
endDate: input.endDate,
);
}
}

View File

@@ -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<DailyOpsArguments, DailyOpsReport> {
/// Creates a [GetDailyOpsReportUseCase].
GetDailyOpsReportUseCase(this._repository);
/// The repository providing report data.
final ReportsRepositoryInterface _repository;
@override
Future<DailyOpsReport> call(DailyOpsArguments input) {
return _repository.getDailyOpsReport(date: input.date);
}
}

View File

@@ -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<DateRangeArguments, ForecastReport> {
/// Creates a [GetForecastReportUseCase].
GetForecastReportUseCase(this._repository);
/// The repository providing report data.
final ReportsRepositoryInterface _repository;
@override
Future<ForecastReport> call(DateRangeArguments input) {
return _repository.getForecastReport(
startDate: input.startDate,
endDate: input.endDate,
);
}
}

View File

@@ -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<DateRangeArguments, NoShowReport> {
/// Creates a [GetNoShowReportUseCase].
GetNoShowReportUseCase(this._repository);
/// The repository providing report data.
final ReportsRepositoryInterface _repository;
@override
Future<NoShowReport> call(DateRangeArguments input) {
return _repository.getNoShowReport(
startDate: input.startDate,
endDate: input.endDate,
);
}
}

View File

@@ -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<DateRangeArguments, PerformanceReport> {
/// Creates a [GetPerformanceReportUseCase].
GetPerformanceReportUseCase(this._repository);
/// The repository providing report data.
final ReportsRepositoryInterface _repository;
@override
Future<PerformanceReport> call(DateRangeArguments input) {
return _repository.getPerformanceReport(
startDate: input.startDate,
endDate: input.endDate,
);
}
}

View File

@@ -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<DateRangeArguments, ReportSummary> {
/// Creates a [GetReportsSummaryUseCase].
GetReportsSummaryUseCase(this._repository);
/// The repository providing report data.
final ReportsRepositoryInterface _repository;
@override
Future<ReportSummary> call(DateRangeArguments input) {
return _repository.getReportsSummary(
startDate: input.startDate,
endDate: input.endDate,
);
}
}

View File

@@ -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<DateRangeArguments, SpendReport> {
/// Creates a [GetSpendReportUseCase].
GetSpendReportUseCase(this._repository);
/// The repository providing report data.
final ReportsRepositoryInterface _repository;
@override
Future<SpendReport> call(DateRangeArguments input) {
return _repository.getSpendReport(
startDate: input.startDate,
endDate: input.endDate,
);
}
}

View File

@@ -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_event.dart';
import 'package:client_reports/src/presentation/blocs/coverage/coverage_state.dart'; import 'package:client_reports/src/presentation/blocs/coverage/coverage_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
/// BLoC that loads the [CoverageReport]. /// BLoC that loads the [CoverageReport] via [GetCoverageReportUseCase].
class CoverageBloc extends Bloc<CoverageEvent, CoverageState> class CoverageBloc extends Bloc<CoverageEvent, CoverageState>
with BlocErrorHandler<CoverageState> { with BlocErrorHandler<CoverageState> {
/// Creates a [CoverageBloc]. /// Creates a [CoverageBloc].
CoverageBloc({required ReportsRepository reportsRepository}) CoverageBloc({required GetCoverageReportUseCase getCoverageReportUseCase})
: _reportsRepository = reportsRepository, : _getCoverageReportUseCase = getCoverageReportUseCase,
super(CoverageInitial()) { super(CoverageInitial()) {
on<LoadCoverageReport>(_onLoadCoverageReport); on<LoadCoverageReport>(_onLoadCoverageReport);
} }
/// The repository used to fetch report data. /// The use case for fetching the coverage report.
final ReportsRepository _reportsRepository; final GetCoverageReportUseCase _getCoverageReportUseCase;
Future<void> _onLoadCoverageReport( Future<void> _onLoadCoverageReport(
LoadCoverageReport event, LoadCoverageReport event,
@@ -26,10 +27,11 @@ class CoverageBloc extends Bloc<CoverageEvent, CoverageState>
emit: emit, emit: emit,
action: () async { action: () async {
emit(CoverageLoading()); emit(CoverageLoading());
final CoverageReport report = final CoverageReport report = await _getCoverageReportUseCase.call(
await _reportsRepository.getCoverageReport( DateRangeArguments(
startDate: event.startDate, startDate: event.startDate,
endDate: event.endDate, endDate: event.endDate,
),
); );
emit(CoverageLoaded(report)); emit(CoverageLoaded(report));
}, },

View File

@@ -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'; import 'package:krow_domain/krow_domain.dart';
/// Base state for the coverage report BLoC.
abstract class CoverageState extends Equatable { abstract class CoverageState extends Equatable {
/// Creates a [CoverageState].
const CoverageState(); const CoverageState();
@override @override
List<Object?> get props => <Object?>[]; List<Object?> get props => <Object?>[];
} }
/// Initial state before any coverage report has been requested.
class CoverageInitial extends CoverageState {} class CoverageInitial extends CoverageState {}
/// State while the coverage report is loading.
class CoverageLoading extends CoverageState {} class CoverageLoading extends CoverageState {}
/// State when the coverage report has loaded successfully.
class CoverageLoaded extends CoverageState { class CoverageLoaded extends CoverageState {
/// Creates a [CoverageLoaded] with the given [report].
const CoverageLoaded(this.report); const CoverageLoaded(this.report);
/// The loaded coverage report data.
final CoverageReport report; final CoverageReport report;
@override @override
List<Object?> get props => <Object?>[report]; List<Object?> get props => <Object?>[report];
} }
/// State when loading the coverage report has failed.
class CoverageError extends CoverageState { class CoverageError extends CoverageState {
/// Creates a [CoverageError] with the given error [message].
const CoverageError(this.message); const CoverageError(this.message);
/// The error message describing the failure.
final String message; final String message;
@override @override

View File

@@ -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_event.dart';
import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_state.dart'; import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
/// BLoC that loads the [DailyOpsReport]. /// BLoC that loads the [DailyOpsReport] via [GetDailyOpsReportUseCase].
class DailyOpsBloc extends Bloc<DailyOpsEvent, DailyOpsState> class DailyOpsBloc extends Bloc<DailyOpsEvent, DailyOpsState>
with BlocErrorHandler<DailyOpsState> { with BlocErrorHandler<DailyOpsState> {
/// Creates a [DailyOpsBloc]. /// Creates a [DailyOpsBloc].
DailyOpsBloc({required ReportsRepository reportsRepository}) DailyOpsBloc({required GetDailyOpsReportUseCase getDailyOpsReportUseCase})
: _reportsRepository = reportsRepository, : _getDailyOpsReportUseCase = getDailyOpsReportUseCase,
super(DailyOpsInitial()) { super(DailyOpsInitial()) {
on<LoadDailyOpsReport>(_onLoadDailyOpsReport); on<LoadDailyOpsReport>(_onLoadDailyOpsReport);
} }
/// The repository used to fetch report data. /// The use case for fetching the daily operations report.
final ReportsRepository _reportsRepository; final GetDailyOpsReportUseCase _getDailyOpsReportUseCase;
Future<void> _onLoadDailyOpsReport( Future<void> _onLoadDailyOpsReport(
LoadDailyOpsReport event, LoadDailyOpsReport event,
@@ -26,9 +27,8 @@ class DailyOpsBloc extends Bloc<DailyOpsEvent, DailyOpsState>
emit: emit, emit: emit,
action: () async { action: () async {
emit(DailyOpsLoading()); emit(DailyOpsLoading());
final DailyOpsReport report = final DailyOpsReport report = await _getDailyOpsReportUseCase.call(
await _reportsRepository.getDailyOpsReport( DailyOpsArguments(date: event.date),
date: event.date,
); );
emit(DailyOpsLoaded(report)); emit(DailyOpsLoaded(report));
}, },

View File

@@ -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'; import 'package:krow_domain/krow_domain.dart';
/// Base state for the daily operations report BLoC.
abstract class DailyOpsState extends Equatable { abstract class DailyOpsState extends Equatable {
/// Creates a [DailyOpsState].
const DailyOpsState(); const DailyOpsState();
@override @override
List<Object?> get props => <Object?>[]; List<Object?> get props => <Object?>[];
} }
/// Initial state before any report has been requested.
class DailyOpsInitial extends DailyOpsState {} class DailyOpsInitial extends DailyOpsState {}
/// State while the daily operations report is loading.
class DailyOpsLoading extends DailyOpsState {} class DailyOpsLoading extends DailyOpsState {}
/// State when the daily operations report has loaded successfully.
class DailyOpsLoaded extends DailyOpsState { class DailyOpsLoaded extends DailyOpsState {
/// Creates a [DailyOpsLoaded] with the given [report].
const DailyOpsLoaded(this.report); const DailyOpsLoaded(this.report);
/// The loaded daily operations report data.
final DailyOpsReport report; final DailyOpsReport report;
@override @override
List<Object?> get props => <Object?>[report]; List<Object?> get props => <Object?>[report];
} }
/// State when loading the daily operations report has failed.
class DailyOpsError extends DailyOpsState { class DailyOpsError extends DailyOpsState {
/// Creates a [DailyOpsError] with the given error [message].
const DailyOpsError(this.message); const DailyOpsError(this.message);
/// The error message describing the failure.
final String message; final String message;
@override @override

View File

@@ -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_event.dart';
import 'package:client_reports/src/presentation/blocs/forecast/forecast_state.dart'; import 'package:client_reports/src/presentation/blocs/forecast/forecast_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
/// BLoC that loads the [ForecastReport]. /// BLoC that loads the [ForecastReport] via [GetForecastReportUseCase].
class ForecastBloc extends Bloc<ForecastEvent, ForecastState> class ForecastBloc extends Bloc<ForecastEvent, ForecastState>
with BlocErrorHandler<ForecastState> { with BlocErrorHandler<ForecastState> {
/// Creates a [ForecastBloc]. /// Creates a [ForecastBloc].
ForecastBloc({required ReportsRepository reportsRepository}) ForecastBloc({required GetForecastReportUseCase getForecastReportUseCase})
: _reportsRepository = reportsRepository, : _getForecastReportUseCase = getForecastReportUseCase,
super(ForecastInitial()) { super(ForecastInitial()) {
on<LoadForecastReport>(_onLoadForecastReport); on<LoadForecastReport>(_onLoadForecastReport);
} }
/// The repository used to fetch report data. /// The use case for fetching the forecast report.
final ReportsRepository _reportsRepository; final GetForecastReportUseCase _getForecastReportUseCase;
Future<void> _onLoadForecastReport( Future<void> _onLoadForecastReport(
LoadForecastReport event, LoadForecastReport event,
@@ -26,10 +27,11 @@ class ForecastBloc extends Bloc<ForecastEvent, ForecastState>
emit: emit, emit: emit,
action: () async { action: () async {
emit(ForecastLoading()); emit(ForecastLoading());
final ForecastReport report = final ForecastReport report = await _getForecastReportUseCase.call(
await _reportsRepository.getForecastReport( DateRangeArguments(
startDate: event.startDate, startDate: event.startDate,
endDate: event.endDate, endDate: event.endDate,
),
); );
emit(ForecastLoaded(report)); emit(ForecastLoaded(report));
}, },

View File

@@ -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'; import 'package:krow_domain/krow_domain.dart';
/// Base state for the forecast report BLoC.
abstract class ForecastState extends Equatable { abstract class ForecastState extends Equatable {
/// Creates a [ForecastState].
const ForecastState(); const ForecastState();
@override @override
List<Object?> get props => <Object?>[]; List<Object?> get props => <Object?>[];
} }
/// Initial state before any forecast has been requested.
class ForecastInitial extends ForecastState {} class ForecastInitial extends ForecastState {}
/// State while the forecast report is loading.
class ForecastLoading extends ForecastState {} class ForecastLoading extends ForecastState {}
/// State when the forecast report has loaded successfully.
class ForecastLoaded extends ForecastState { class ForecastLoaded extends ForecastState {
/// Creates a [ForecastLoaded] with the given [report].
const ForecastLoaded(this.report); const ForecastLoaded(this.report);
/// The loaded forecast report data.
final ForecastReport report; final ForecastReport report;
@override @override
List<Object?> get props => <Object?>[report]; List<Object?> get props => <Object?>[report];
} }
/// State when loading the forecast report has failed.
class ForecastError extends ForecastState { class ForecastError extends ForecastState {
/// Creates a [ForecastError] with the given error [message].
const ForecastError(this.message); const ForecastError(this.message);
/// The error message describing the failure.
final String message; final String message;
@override @override

View File

@@ -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_event.dart';
import 'package:client_reports/src/presentation/blocs/no_show/no_show_state.dart'; import 'package:client_reports/src/presentation/blocs/no_show/no_show_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
/// BLoC that loads the [NoShowReport]. /// BLoC that loads the [NoShowReport] via [GetNoShowReportUseCase].
class NoShowBloc extends Bloc<NoShowEvent, NoShowState> class NoShowBloc extends Bloc<NoShowEvent, NoShowState>
with BlocErrorHandler<NoShowState> { with BlocErrorHandler<NoShowState> {
/// Creates a [NoShowBloc]. /// Creates a [NoShowBloc].
NoShowBloc({required ReportsRepository reportsRepository}) NoShowBloc({required GetNoShowReportUseCase getNoShowReportUseCase})
: _reportsRepository = reportsRepository, : _getNoShowReportUseCase = getNoShowReportUseCase,
super(NoShowInitial()) { super(NoShowInitial()) {
on<LoadNoShowReport>(_onLoadNoShowReport); on<LoadNoShowReport>(_onLoadNoShowReport);
} }
/// The repository used to fetch report data. /// The use case for fetching the no-show report.
final ReportsRepository _reportsRepository; final GetNoShowReportUseCase _getNoShowReportUseCase;
Future<void> _onLoadNoShowReport( Future<void> _onLoadNoShowReport(
LoadNoShowReport event, LoadNoShowReport event,
@@ -26,9 +27,11 @@ class NoShowBloc extends Bloc<NoShowEvent, NoShowState>
emit: emit, emit: emit,
action: () async { action: () async {
emit(NoShowLoading()); emit(NoShowLoading());
final NoShowReport report = await _reportsRepository.getNoShowReport( final NoShowReport report = await _getNoShowReportUseCase.call(
startDate: event.startDate, DateRangeArguments(
endDate: event.endDate, startDate: event.startDate,
endDate: event.endDate,
),
); );
emit(NoShowLoaded(report)); emit(NoShowLoaded(report));
}, },

View File

@@ -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'; import 'package:krow_domain/krow_domain.dart';
/// Base state for the no-show report BLoC.
abstract class NoShowState extends Equatable { abstract class NoShowState extends Equatable {
/// Creates a [NoShowState].
const NoShowState(); const NoShowState();
@override @override
List<Object?> get props => <Object?>[]; List<Object?> get props => <Object?>[];
} }
/// Initial state before any no-show report has been requested.
class NoShowInitial extends NoShowState {} class NoShowInitial extends NoShowState {}
/// State while the no-show report is loading.
class NoShowLoading extends NoShowState {} class NoShowLoading extends NoShowState {}
/// State when the no-show report has loaded successfully.
class NoShowLoaded extends NoShowState { class NoShowLoaded extends NoShowState {
/// Creates a [NoShowLoaded] with the given [report].
const NoShowLoaded(this.report); const NoShowLoaded(this.report);
/// The loaded no-show report data.
final NoShowReport report; final NoShowReport report;
@override @override
List<Object?> get props => <Object?>[report]; List<Object?> get props => <Object?>[report];
} }
/// State when loading the no-show report has failed.
class NoShowError extends NoShowState { class NoShowError extends NoShowState {
/// Creates a [NoShowError] with the given error [message].
const NoShowError(this.message); const NoShowError(this.message);
/// The error message describing the failure.
final String message; final String message;
@override @override

View File

@@ -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_event.dart';
import 'package:client_reports/src/presentation/blocs/performance/performance_state.dart'; import 'package:client_reports/src/presentation/blocs/performance/performance_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
/// BLoC that loads the [PerformanceReport]. /// BLoC that loads the [PerformanceReport] via [GetPerformanceReportUseCase].
class PerformanceBloc extends Bloc<PerformanceEvent, PerformanceState> class PerformanceBloc extends Bloc<PerformanceEvent, PerformanceState>
with BlocErrorHandler<PerformanceState> { with BlocErrorHandler<PerformanceState> {
/// Creates a [PerformanceBloc]. /// Creates a [PerformanceBloc].
PerformanceBloc({required ReportsRepository reportsRepository}) PerformanceBloc({
: _reportsRepository = reportsRepository, required GetPerformanceReportUseCase getPerformanceReportUseCase,
}) : _getPerformanceReportUseCase = getPerformanceReportUseCase,
super(PerformanceInitial()) { super(PerformanceInitial()) {
on<LoadPerformanceReport>(_onLoadPerformanceReport); on<LoadPerformanceReport>(_onLoadPerformanceReport);
} }
/// The repository used to fetch report data. /// The use case for fetching the performance report.
final ReportsRepository _reportsRepository; final GetPerformanceReportUseCase _getPerformanceReportUseCase;
Future<void> _onLoadPerformanceReport( Future<void> _onLoadPerformanceReport(
LoadPerformanceReport event, LoadPerformanceReport event,
@@ -26,10 +28,11 @@ class PerformanceBloc extends Bloc<PerformanceEvent, PerformanceState>
emit: emit, emit: emit,
action: () async { action: () async {
emit(PerformanceLoading()); emit(PerformanceLoading());
final PerformanceReport report = final PerformanceReport report = await _getPerformanceReportUseCase.call(
await _reportsRepository.getPerformanceReport( DateRangeArguments(
startDate: event.startDate, startDate: event.startDate,
endDate: event.endDate, endDate: event.endDate,
),
); );
emit(PerformanceLoaded(report)); emit(PerformanceLoaded(report));
}, },

View File

@@ -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'; import 'package:krow_domain/krow_domain.dart';
/// Base state for the performance report BLoC.
abstract class PerformanceState extends Equatable { abstract class PerformanceState extends Equatable {
/// Creates a [PerformanceState].
const PerformanceState(); const PerformanceState();
@override @override
List<Object?> get props => <Object?>[]; List<Object?> get props => <Object?>[];
} }
/// Initial state before any performance report has been requested.
class PerformanceInitial extends PerformanceState {} class PerformanceInitial extends PerformanceState {}
/// State while the performance report is loading.
class PerformanceLoading extends PerformanceState {} class PerformanceLoading extends PerformanceState {}
/// State when the performance report has loaded successfully.
class PerformanceLoaded extends PerformanceState { class PerformanceLoaded extends PerformanceState {
/// Creates a [PerformanceLoaded] with the given [report].
const PerformanceLoaded(this.report); const PerformanceLoaded(this.report);
/// The loaded performance report data.
final PerformanceReport report; final PerformanceReport report;
@override @override
List<Object?> get props => <Object?>[report]; List<Object?> get props => <Object?>[report];
} }
/// State when loading the performance report has failed.
class PerformanceError extends PerformanceState { class PerformanceError extends PerformanceState {
/// Creates a [PerformanceError] with the given error [message].
const PerformanceError(this.message); const PerformanceError(this.message);
/// The error message describing the failure.
final String message; final String message;
@override @override

View File

@@ -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_event.dart';
import 'package:client_reports/src/presentation/blocs/spend/spend_state.dart'; import 'package:client_reports/src/presentation/blocs/spend/spend_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
/// BLoC that loads the [SpendReport]. /// BLoC that loads the [SpendReport] via [GetSpendReportUseCase].
class SpendBloc extends Bloc<SpendEvent, SpendState> class SpendBloc extends Bloc<SpendEvent, SpendState>
with BlocErrorHandler<SpendState> { with BlocErrorHandler<SpendState> {
/// Creates a [SpendBloc]. /// Creates a [SpendBloc].
SpendBloc({required ReportsRepository reportsRepository}) SpendBloc({required GetSpendReportUseCase getSpendReportUseCase})
: _reportsRepository = reportsRepository, : _getSpendReportUseCase = getSpendReportUseCase,
super(SpendInitial()) { super(SpendInitial()) {
on<LoadSpendReport>(_onLoadSpendReport); on<LoadSpendReport>(_onLoadSpendReport);
} }
/// The repository used to fetch report data. /// The use case for fetching the spend report.
final ReportsRepository _reportsRepository; final GetSpendReportUseCase _getSpendReportUseCase;
Future<void> _onLoadSpendReport( Future<void> _onLoadSpendReport(
LoadSpendReport event, LoadSpendReport event,
@@ -26,9 +27,11 @@ class SpendBloc extends Bloc<SpendEvent, SpendState>
emit: emit, emit: emit,
action: () async { action: () async {
emit(SpendLoading()); emit(SpendLoading());
final SpendReport report = await _reportsRepository.getSpendReport( final SpendReport report = await _getSpendReportUseCase.call(
startDate: event.startDate, DateRangeArguments(
endDate: event.endDate, startDate: event.startDate,
endDate: event.endDate,
),
); );
emit(SpendLoaded(report)); emit(SpendLoaded(report));
}, },

View File

@@ -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'; import 'package:krow_domain/krow_domain.dart';
/// Base state for the spend report BLoC.
abstract class SpendState extends Equatable { abstract class SpendState extends Equatable {
/// Creates a [SpendState].
const SpendState(); const SpendState();
@override @override
List<Object?> get props => <Object?>[]; List<Object?> get props => <Object?>[];
} }
/// Initial state before any spend report has been requested.
class SpendInitial extends SpendState {} class SpendInitial extends SpendState {}
/// State while the spend report is loading.
class SpendLoading extends SpendState {} class SpendLoading extends SpendState {}
/// State when the spend report has loaded successfully.
class SpendLoaded extends SpendState { class SpendLoaded extends SpendState {
/// Creates a [SpendLoaded] with the given [report].
const SpendLoaded(this.report); const SpendLoaded(this.report);
/// The loaded spend report data.
final SpendReport report; final SpendReport report;
@override @override
List<Object?> get props => <Object?>[report]; List<Object?> get props => <Object?>[report];
} }
/// State when loading the spend report has failed.
class SpendError extends SpendState { class SpendError extends SpendState {
/// Creates a [SpendError] with the given error [message].
const SpendError(this.message); const SpendError(this.message);
/// The error message describing the failure.
final String message; final String message;
@override @override

View File

@@ -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_event.dart';
import 'package:client_reports/src/presentation/blocs/summary/reports_summary_state.dart'; import 'package:client_reports/src/presentation/blocs/summary/reports_summary_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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 class ReportsSummaryBloc
extends Bloc<ReportsSummaryEvent, ReportsSummaryState> extends Bloc<ReportsSummaryEvent, ReportsSummaryState>
with BlocErrorHandler<ReportsSummaryState> { with BlocErrorHandler<ReportsSummaryState> {
/// Creates a [ReportsSummaryBloc]. /// Creates a [ReportsSummaryBloc].
ReportsSummaryBloc({required ReportsRepository reportsRepository}) ReportsSummaryBloc({
: _reportsRepository = reportsRepository, required GetReportsSummaryUseCase getReportsSummaryUseCase,
}) : _getReportsSummaryUseCase = getReportsSummaryUseCase,
super(ReportsSummaryInitial()) { super(ReportsSummaryInitial()) {
on<LoadReportsSummary>(_onLoadReportsSummary); on<LoadReportsSummary>(_onLoadReportsSummary);
} }
/// The repository used to fetch summary data. /// The use case for fetching the report summary.
final ReportsRepository _reportsRepository; final GetReportsSummaryUseCase _getReportsSummaryUseCase;
Future<void> _onLoadReportsSummary( Future<void> _onLoadReportsSummary(
LoadReportsSummary event, LoadReportsSummary event,
@@ -27,10 +29,11 @@ class ReportsSummaryBloc
emit: emit, emit: emit,
action: () async { action: () async {
emit(ReportsSummaryLoading()); emit(ReportsSummaryLoading());
final ReportSummary summary = final ReportSummary summary = await _getReportsSummaryUseCase.call(
await _reportsRepository.getReportsSummary( DateRangeArguments(
startDate: event.startDate, startDate: event.startDate,
endDate: event.endDate, endDate: event.endDate,
),
); );
emit(ReportsSummaryLoaded(summary)); emit(ReportsSummaryLoaded(summary));
}, },

View File

@@ -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_event.dart';
import 'package:client_reports/src/presentation/blocs/coverage/coverage_state.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:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -10,9 +11,9 @@ import 'package:intl/intl.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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 { class CoverageReportPage extends StatefulWidget {
/// Creates a [CoverageReportPage].
const CoverageReportPage({super.key}); const CoverageReportPage({super.key});
@override @override
@@ -86,17 +87,14 @@ class _CoverageReportPageState extends State<CoverageReportPage> {
children: <Widget>[ children: <Widget>[
Text( Text(
context.t.client_reports.coverage_report.title, context.t.client_reports.coverage_report.title,
style: const TextStyle( style: UiTypography.title1b.copyWith(
fontSize: 18,
fontWeight: FontWeight.bold,
color: UiColors.white, color: UiColors.white,
), ),
), ),
Text( Text(
context.t.client_reports.coverage_report context.t.client_reports.coverage_report
.subtitle, .subtitle,
style: TextStyle( style: UiTypography.body3r.copyWith(
fontSize: 12,
color: UiColors.white.withOpacity(0.7), color: UiColors.white.withOpacity(0.7),
), ),
), ),
@@ -143,9 +141,7 @@ class _CoverageReportPageState extends State<CoverageReportPage> {
// Daily List // Daily List
Text( Text(
context.t.client_reports.coverage_report.next_7_days, context.t.client_reports.coverage_report.next_7_days,
style: const TextStyle( style: UiTypography.body3b.copyWith(
fontSize: 12,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary, color: UiColors.textSecondary,
letterSpacing: 1.2, letterSpacing: 1.2,
), ),
@@ -177,17 +173,25 @@ class _CoverageReportPageState extends State<CoverageReportPage> {
} }
} }
/// Summary card for coverage metrics with icon and value.
class _CoverageSummaryCard extends StatelessWidget { class _CoverageSummaryCard extends StatelessWidget {
const _CoverageSummaryCard({ const _CoverageSummaryCard({
required this.label, required this.label,
required this.value, required this.value,
required this.icon, required this.icon,
required this.color, required this.color,
}); });
/// The metric label text.
final String label; final String label;
/// The metric value text.
final String value; final String value;
/// The icon to display.
final IconData icon; final IconData icon;
/// The icon and accent color.
final Color color; final Color color;
@override @override
@@ -216,26 +220,42 @@ class _CoverageSummaryCard extends StatelessWidget {
child: Icon(icon, size: 16, color: color), child: Icon(icon, size: 16, color: color),
), ),
const SizedBox(height: 12), 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), 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 { class _CoverageListItem extends StatelessWidget {
const _CoverageListItem({ const _CoverageListItem({
required this.date, required this.date,
required this.needed, required this.needed,
required this.filled, required this.filled,
required this.percentage, required this.percentage,
}); });
/// The formatted date string.
final String date; final String date;
/// The number of workers needed.
final int needed; final int needed;
/// The number of workers filled.
final int filled; final int filled;
/// The coverage percentage.
final double percentage; final double percentage;
@override @override
@@ -262,7 +282,10 @@ class _CoverageListItem extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text(date, style: const TextStyle(fontWeight: FontWeight.bold)), Text(
date,
style: UiTypography.body2b,
),
const SizedBox(height: 4), const SizedBox(height: 4),
// Progress Bar // Progress Bar
ClipRRect( ClipRRect(
@@ -283,13 +306,11 @@ class _CoverageListItem extends StatelessWidget {
children: <Widget>[ children: <Widget>[
Text( Text(
'$filled/$needed', '$filled/$needed',
style: const TextStyle(fontWeight: FontWeight.bold), style: UiTypography.body2b,
), ),
Text( Text(
'${percentage.toStringAsFixed(0)}%', '${percentage.toStringAsFixed(0)}%',
style: TextStyle( style: UiTypography.body3b.copyWith(
fontSize: 12,
fontWeight: FontWeight.bold,
color: statusColor, color: statusColor,
), ),
), ),
@@ -300,4 +321,3 @@ class _CoverageListItem extends StatelessWidget {
); );
} }
} }

View File

@@ -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_event.dart';
import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_state.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:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -10,9 +11,9 @@ import 'package:intl/intl.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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 { class DailyOpsReportPage extends StatefulWidget {
/// Creates a [DailyOpsReportPage].
const DailyOpsReportPage({super.key}); const DailyOpsReportPage({super.key});
@override @override
@@ -117,17 +118,14 @@ class _DailyOpsReportPageState extends State<DailyOpsReportPage> {
Text( Text(
context.t.client_reports.daily_ops_report context.t.client_reports.daily_ops_report
.title, .title,
style: const TextStyle( style: UiTypography.title1b.copyWith(
fontSize: 18,
fontWeight: FontWeight.bold,
color: UiColors.white, color: UiColors.white,
), ),
), ),
Text( Text(
context.t.client_reports.daily_ops_report context.t.client_reports.daily_ops_report
.subtitle, .subtitle,
style: TextStyle( style: UiTypography.body3r.copyWith(
fontSize: 12,
color: UiColors.white.withOpacity(0.7), color: UiColors.white.withOpacity(0.7),
), ),
), ),
@@ -135,52 +133,6 @@ class _DailyOpsReportPageState extends State<DailyOpsReportPage> {
), ),
], ],
), ),
/*
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<DailyOpsReportPage> {
Text( Text(
DateFormat('MMM dd, yyyy') DateFormat('MMM dd, yyyy')
.format(_selectedDate), .format(_selectedDate),
style: const TextStyle( style: UiTypography.body2b,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
), ),
], ],
), ),
@@ -325,10 +274,7 @@ class _DailyOpsReportPageState extends State<DailyOpsReportPage> {
context.t.client_reports.daily_ops_report context.t.client_reports.daily_ops_report
.all_shifts_title .all_shifts_title
.toUpperCase(), .toUpperCase(),
style: const TextStyle( style: UiTypography.body2b.copyWith(
fontSize: 14,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
), ),
@@ -377,8 +323,8 @@ class _DailyOpsReportPageState extends State<DailyOpsReportPage> {
} }
} }
/// Stat card showing a metric with icon, value, and colored badge.
class _OpsStatCard extends StatelessWidget { class _OpsStatCard extends StatelessWidget {
const _OpsStatCard({ const _OpsStatCard({
required this.label, required this.label,
required this.value, required this.value,
@@ -386,10 +332,20 @@ class _OpsStatCard extends StatelessWidget {
required this.color, required this.color,
required this.icon, required this.icon,
}); });
/// The metric label text.
final String label; final String label;
/// The metric value text.
final String value; final String value;
/// The badge sub-value text.
final String subValue; final String subValue;
/// The theme color for icon and badge.
final Color color; final Color color;
/// The icon to display.
final IconData icon; final IconData icon;
@override @override
@@ -412,10 +368,8 @@ class _OpsStatCard extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
label, label,
style: const TextStyle( style: UiTypography.body3m.copyWith(
fontSize: 12,
color: UiColors.textSecondary, color: UiColors.textSecondary,
fontWeight: FontWeight.w600,
), ),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -428,15 +382,8 @@ class _OpsStatCard extends StatelessWidget {
children: <Widget>[ children: <Widget>[
Text( Text(
value, value,
style: const TextStyle( style: UiTypography.display1b,
fontSize: 28,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
), ),
//UiChip(label: subValue),
// Colored pill badge (matches prototype)
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, horizontal: 8,
@@ -448,9 +395,7 @@ class _OpsStatCard extends StatelessWidget {
), ),
child: Text( child: Text(
subValue, subValue,
style: TextStyle( style: UiTypography.body3b.copyWith(
fontSize: 12,
fontWeight: FontWeight.bold,
color: color, color: color,
), ),
), ),
@@ -463,8 +408,8 @@ class _OpsStatCard extends StatelessWidget {
} }
} }
/// A single shift row in the daily operations list.
class _ShiftListItem extends StatelessWidget { class _ShiftListItem extends StatelessWidget {
const _ShiftListItem({ const _ShiftListItem({
required this.title, required this.title,
required this.location, required this.location,
@@ -474,12 +419,26 @@ class _ShiftListItem extends StatelessWidget {
required this.status, required this.status,
required this.statusColor, required this.statusColor,
}); });
/// The shift role name.
final String title; final String title;
/// The shift location or ID.
final String location; final String location;
/// The formatted time range string.
final String time; final String time;
/// The workers ratio string (e.g. "3/5").
final String workers; final String workers;
/// The rate string.
final String rate; final String rate;
/// The status label text.
final String status; final String status;
/// The color for the status badge.
final Color statusColor; final Color statusColor;
@override @override
@@ -508,11 +467,7 @@ class _ShiftListItem extends StatelessWidget {
children: <Widget>[ children: <Widget>[
Text( Text(
title, title,
style: const TextStyle( style: UiTypography.body2b,
fontWeight: FontWeight.bold,
fontSize: 14,
color: UiColors.textPrimary,
),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Row( Row(
@@ -526,8 +481,7 @@ class _ShiftListItem extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
location, location,
style: const TextStyle( style: UiTypography.titleUppercase4m.copyWith(
fontSize: 11,
color: UiColors.textSecondary, color: UiColors.textSecondary,
), ),
maxLines: 1, maxLines: 1,
@@ -548,10 +502,8 @@ class _ShiftListItem extends StatelessWidget {
), ),
child: Text( child: Text(
status.toUpperCase(), status.toUpperCase(),
style: TextStyle( style: UiTypography.footnote2b.copyWith(
color: statusColor, 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( Widget _infoItem(
BuildContext context, IconData icon, String label, String value) { BuildContext context, IconData icon, String label, String value) {
return Row( return Row(
@@ -596,13 +549,13 @@ class _ShiftListItem extends StatelessWidget {
children: <Widget>[ children: <Widget>[
Text( Text(
label, label,
style: const TextStyle(fontSize: 10, color: UiColors.pinInactive), style: UiTypography.footnote2r.copyWith(
color: UiColors.textInactive,
),
), ),
Text( Text(
value, value,
style: const TextStyle( style: UiTypography.titleUppercase4b.copyWith(
fontSize: 11,
fontWeight: FontWeight.bold,
color: UiColors.textDescription, color: UiColors.textDescription,
), ),
), ),
@@ -612,4 +565,3 @@ class _ShiftListItem extends StatelessWidget {
); );
} }
} }

View File

@@ -100,12 +100,13 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
); );
} }
/// Builds the gradient header with back button and title.
Widget _buildHeader(BuildContext context) { Widget _buildHeader(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.only(top: 60, left: 20, right: 20, bottom: 40), padding: const EdgeInsets.only(top: 60, left: 20, right: 20, bottom: 40),
decoration: const BoxDecoration( decoration: const BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
colors: <Color>[UiColors.primary, Color(0xFF0020A0)], colors: <Color>[UiColors.primary, UiColors.buttonPrimaryHover],
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
), ),
@@ -150,6 +151,7 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
); );
} }
/// Builds the 2x2 metrics grid.
Widget _buildMetricsGrid(BuildContext context, ForecastReport report) { Widget _buildMetricsGrid(BuildContext context, ForecastReport report) {
final TranslationsClientReportsForecastReportEn t = final TranslationsClientReportsForecastReportEn t =
context.t.client_reports.forecast_report; context.t.client_reports.forecast_report;
@@ -186,8 +188,8 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
label: t.metrics.total_shifts, label: t.metrics.total_shifts,
value: report.totalShifts.toString(), value: report.totalShifts.toString(),
badgeText: t.badges.scheduled, badgeText: t.badges.scheduled,
iconColor: const Color(0xFF9333EA), iconColor: UiColors.primary,
badgeColor: const Color(0xFFF3E8FF), badgeColor: UiColors.tagInProgress,
), ),
_MetricCard( _MetricCard(
icon: UiIcons.users, icon: UiIcons.users,
@@ -201,6 +203,7 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
); );
} }
/// Builds the chart section with weekly spend trend.
Widget _buildChartSection(BuildContext context, ForecastReport report) { Widget _buildChartSection(BuildContext context, ForecastReport report) {
return Container( return Container(
height: 320, height: 320,
@@ -231,13 +234,14 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[ children: <Widget>[
for (int i = 0; i < report.weeks.length; i++) ...<Widget>[ for (int i = 0; i < report.weeks.length; i++) ...<Widget>[
Text('W${i + 1}', Text(
style: const TextStyle( 'W${i + 1}',
color: UiColors.textSecondary, fontSize: 12)), style: UiTypography.body3r.copyWith(
color: UiColors.textSecondary,
),
),
if (i < report.weeks.length - 1) if (i < report.weeks.length - 1)
const Text('', const SizedBox.shrink(),
style: TextStyle(
color: UiColors.transparent, fontSize: 12)),
], ],
], ],
), ),
@@ -247,6 +251,7 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
} }
} }
/// Metric card widget for the forecast grid.
class _MetricCard extends StatelessWidget { class _MetricCard extends StatelessWidget {
const _MetricCard({ const _MetricCard({
required this.icon, required this.icon,
@@ -257,11 +262,22 @@ class _MetricCard extends StatelessWidget {
required this.badgeColor, required this.badgeColor,
}); });
/// The metric icon.
final IconData icon; final IconData icon;
/// The metric label text.
final String label; final String label;
/// The metric value text.
final String value; final String value;
/// The badge text.
final String badgeText; final String badgeText;
/// The icon tint color.
final Color iconColor; final Color iconColor;
/// The badge background color.
final Color badgeColor; final Color badgeColor;
@override @override
@@ -308,11 +324,7 @@ class _MetricCard extends StatelessWidget {
), ),
child: Text( child: Text(
badgeText, badgeText,
style: UiTypography.footnote1r.copyWith( style: UiTypography.footnote2b,
color: UiColors.textPrimary,
fontSize: 10,
fontWeight: FontWeight.w600,
),
), ),
), ),
], ],
@@ -328,7 +340,10 @@ class _WeeklyBreakdownItem extends StatelessWidget {
required this.weekIndex, required this.weekIndex,
}); });
/// The forecast week data.
final ForecastWeek week; final ForecastWeek week;
/// The 1-based week index.
final int weekIndex; final int weekIndex;
@override @override
@@ -386,6 +401,7 @@ class _WeeklyBreakdownItem extends StatelessWidget {
); );
} }
/// Builds a label/value stat column.
Widget _buildStat(String label, String value) { Widget _buildStat(String label, String value) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -402,6 +418,7 @@ class _WeeklyBreakdownItem extends StatelessWidget {
class _ForecastChart extends StatelessWidget { class _ForecastChart extends StatelessWidget {
const _ForecastChart({required this.weeks}); const _ForecastChart({required this.weeks});
/// The weekly forecast data points.
final List<ForecastWeek> weeks; final List<ForecastWeek> weeks;
@override @override

View File

@@ -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_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_event.dart';
import 'package:client_reports/src/presentation/blocs/no_show/no_show_state.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:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.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 { class NoShowReportPage extends StatefulWidget {
/// Creates a [NoShowReportPage].
const NoShowReportPage({super.key}); const NoShowReportPage({super.key});
@override @override
@@ -26,7 +26,7 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( return BlocProvider<NoShowBloc>(
create: (BuildContext context) => Modular.get<NoShowBloc>() create: (BuildContext context) => Modular.get<NoShowBloc>()
..add(LoadNoShowReport(startDate: _startDate, endDate: _endDate)), ..add(LoadNoShowReport(startDate: _startDate, endDate: _endDate)),
child: Scaffold( child: Scaffold(
@@ -90,16 +90,13 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
children: <Widget>[ children: <Widget>[
Text( Text(
context.t.client_reports.no_show_report.title, context.t.client_reports.no_show_report.title,
style: const TextStyle( style: UiTypography.title1b.copyWith(
fontSize: 18,
fontWeight: FontWeight.bold,
color: UiColors.white, color: UiColors.white,
), ),
), ),
Text( Text(
context.t.client_reports.no_show_report.subtitle, context.t.client_reports.no_show_report.subtitle,
style: TextStyle( style: UiTypography.body3r.copyWith(
fontSize: 12,
color: UiColors.white.withOpacity(0.6), color: UiColors.white.withOpacity(0.6),
), ),
), ),
@@ -107,47 +104,6 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
), ),
], ],
), ),
// 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<NoShowReportPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
// 3-chip summary row (matches prototype) // 3-chip summary row
Row( Row(
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
@@ -198,9 +154,7 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
Text( Text(
context.t.client_reports.no_show_report context.t.client_reports.no_show_report
.workers_list_title, .workers_list_title,
style: const TextStyle( style: UiTypography.body3b.copyWith(
fontSize: 12,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary, color: UiColors.textSecondary,
letterSpacing: 1.2, letterSpacing: 1.2,
), ),
@@ -214,7 +168,7 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
context.t.client_reports.no_show_report.empty_state, context.t.client_reports.no_show_report.empty_state,
style: const TextStyle( style: UiTypography.body2r.copyWith(
color: UiColors.textSecondary, color: UiColors.textSecondary,
), ),
), ),
@@ -241,18 +195,25 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
} }
} }
// Summary chip (top 3 stats) /// Summary chip showing a single metric with icon.
class _SummaryChip extends StatelessWidget { class _SummaryChip extends StatelessWidget {
const _SummaryChip({ const _SummaryChip({
required this.icon, required this.icon,
required this.iconColor, required this.iconColor,
required this.label, required this.label,
required this.value, required this.value,
}); });
/// The icon to display.
final IconData icon; final IconData icon;
/// The icon and label color.
final Color iconColor; final Color iconColor;
/// The metric label text.
final String label; final String label;
/// The metric value text.
final String value; final String value;
@override @override
@@ -280,10 +241,8 @@ class _SummaryChip extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
label, label,
style: TextStyle( style: UiTypography.footnote2b.copyWith(
fontSize: 10,
color: iconColor, color: iconColor,
fontWeight: FontWeight.w600,
), ),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -293,11 +252,7 @@ class _SummaryChip extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
value, value,
style: const TextStyle( style: UiTypography.display1b,
fontSize: 26,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
), ),
], ],
), ),
@@ -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 { class _WorkerCard extends StatelessWidget {
const _WorkerCard({required this.worker}); const _WorkerCard({required this.worker});
/// The worker item data.
final NoShowWorkerItem worker; final NoShowWorkerItem worker;
/// Returns the localized risk label.
String _riskLabel(BuildContext context, String riskStatus) { String _riskLabel(BuildContext context, String riskStatus) {
if (riskStatus == 'HIGH') return context.t.client_reports.no_show_report.risks.high; 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; if (riskStatus == 'MEDIUM') return context.t.client_reports.no_show_report.risks.medium;
return context.t.client_reports.no_show_report.risks.low; return context.t.client_reports.no_show_report.risks.low;
} }
/// Returns the color for the given risk status.
Color _riskColor(String riskStatus) { Color _riskColor(String riskStatus) {
if (riskStatus == 'HIGH') return UiColors.error; if (riskStatus == 'HIGH') return UiColors.error;
if (riskStatus == 'MEDIUM') return UiColors.textWarning; if (riskStatus == 'MEDIUM') return UiColors.textWarning;
return UiColors.success; return UiColors.success;
} }
/// Returns the background color for the given risk status.
Color _riskBg(String riskStatus) { Color _riskBg(String riskStatus) {
if (riskStatus == 'HIGH') return UiColors.tagError; if (riskStatus == 'HIGH') return UiColors.tagError;
if (riskStatus == 'MEDIUM') return UiColors.tagPending; if (riskStatus == 'MEDIUM') return UiColors.tagPending;
@@ -374,16 +333,11 @@ class _WorkerCard extends StatelessWidget {
children: <Widget>[ children: <Widget>[
Text( Text(
worker.staffName, worker.staffName,
style: const TextStyle( style: UiTypography.body2b,
fontWeight: FontWeight.bold,
fontSize: 14,
color: UiColors.textPrimary,
),
), ),
Text( Text(
context.t.client_reports.no_show_report.no_show_count(count: worker.incidentCount.toString()), context.t.client_reports.no_show_report.no_show_count(count: worker.incidentCount.toString()),
style: const TextStyle( style: UiTypography.body3r.copyWith(
fontSize: 12,
color: UiColors.textSecondary, color: UiColors.textSecondary,
), ),
), ),
@@ -403,9 +357,7 @@ class _WorkerCard extends StatelessWidget {
), ),
child: Text( child: Text(
riskLabel, riskLabel,
style: TextStyle( style: UiTypography.titleUppercase4b.copyWith(
fontSize: 11,
fontWeight: FontWeight.bold,
color: riskColor, color: riskColor,
), ),
), ),
@@ -420,8 +372,7 @@ class _WorkerCard extends StatelessWidget {
children: <Widget>[ children: <Widget>[
Text( Text(
context.t.client_reports.no_show_report.latest_incident, context.t.client_reports.no_show_report.latest_incident,
style: const TextStyle( style: UiTypography.titleUppercase4m.copyWith(
fontSize: 11,
color: UiColors.textSecondary, color: UiColors.textSecondary,
), ),
), ),
@@ -430,10 +381,8 @@ class _WorkerCard extends StatelessWidget {
? DateFormat('MMM dd, yyyy') ? DateFormat('MMM dd, yyyy')
.format(worker.incidents.first.date) .format(worker.incidents.first.date)
: '-', : '-',
style: const TextStyle( style: UiTypography.titleUppercase4m.copyWith(
fontSize: 11,
color: UiColors.textSecondary, color: UiColors.textSecondary,
fontWeight: FontWeight.w500,
), ),
), ),
], ],
@@ -443,6 +392,3 @@ class _WorkerCard extends StatelessWidget {
); );
} }
} }
// Insight line

View File

@@ -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_event.dart';
import 'package:client_reports/src/presentation/blocs/performance/performance_state.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:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.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_core/core.dart';
import 'package:krow_domain/krow_domain.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 { class PerformanceReportPage extends StatefulWidget {
/// Creates a [PerformanceReportPage].
const PerformanceReportPage({super.key}); const PerformanceReportPage({super.key});
@override @override
@@ -102,18 +103,18 @@ class _PerformanceReportPageState extends State<PerformanceReportPage> {
), ),
_KpiData( _KpiData(
icon: UiIcons.clock, icon: UiIcons.clock,
iconColor: const Color(0xFF9B59B6), iconColor: UiColors.primary,
label: context.t.client_reports.performance_report.kpis.on_time_rate, label: context.t.client_reports.performance_report.kpis.on_time_rate,
target: context.t.client_reports.performance_report.kpis.target_percent(percent: '97'), target: context.t.client_reports.performance_report.kpis.target_percent(percent: '97'),
value: onTimeRate, value: onTimeRate,
displayValue: '${onTimeRate.toStringAsFixed(0)}%', displayValue: '${onTimeRate.toStringAsFixed(0)}%',
barColor: const Color(0xFF9B59B6), barColor: UiColors.primary,
met: onTimeRate >= 97, met: onTimeRate >= 97,
close: onTimeRate >= 92, close: onTimeRate >= 92,
), ),
_KpiData( _KpiData(
icon: UiIcons.trendingUp, icon: UiIcons.trendingUp,
iconColor: const Color(0xFFF39C12), iconColor: UiColors.textWarning,
label: context.t.client_reports.performance_report.kpis.avg_fill_time, label: context.t.client_reports.performance_report.kpis.avg_fill_time,
target: context.t.client_reports.performance_report.kpis.target_hours(hours: '3'), target: context.t.client_reports.performance_report.kpis.target_hours(hours: '3'),
value: avgFillTimeHours == 0 value: avgFillTimeHours == 0
@@ -121,7 +122,7 @@ class _PerformanceReportPageState extends State<PerformanceReportPage> {
: (3 / avgFillTimeHours * 100).clamp(0, 100), : (3 / avgFillTimeHours * 100).clamp(0, 100),
displayValue: displayValue:
'${avgFillTimeHours.toStringAsFixed(1)} hrs', '${avgFillTimeHours.toStringAsFixed(1)} hrs',
barColor: const Color(0xFFF39C12), barColor: UiColors.textWarning,
met: avgFillTimeHours <= 3, met: avgFillTimeHours <= 3,
close: avgFillTimeHours <= 4, close: avgFillTimeHours <= 4,
), ),
@@ -173,17 +174,14 @@ class _PerformanceReportPageState extends State<PerformanceReportPage> {
Text( Text(
context.t.client_reports.performance_report context.t.client_reports.performance_report
.title, .title,
style: const TextStyle( style: UiTypography.title1b.copyWith(
fontSize: 18,
fontWeight: FontWeight.bold,
color: UiColors.white, color: UiColors.white,
), ),
), ),
Text( Text(
context.t.client_reports.performance_report context.t.client_reports.performance_report
.subtitle, .subtitle,
style: TextStyle( style: UiTypography.body3r.copyWith(
fontSize: 12,
color: UiColors.white.withOpacity(0.7), color: UiColors.white.withOpacity(0.7),
), ),
), ),
@@ -191,49 +189,11 @@ class _PerformanceReportPageState extends State<PerformanceReportPage> {
), ),
], ],
), ),
// 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( Transform.translate(
offset: const Offset(0, -16), offset: const Offset(0, -16),
child: Padding( child: Padding(
@@ -248,7 +208,7 @@ class _PerformanceReportPageState extends State<PerformanceReportPage> {
horizontal: 20, horizontal: 20,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF0F4FF), color: UiColors.tagInProgress,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: <BoxShadow>[ boxShadow: <BoxShadow>[
BoxShadow( BoxShadow(
@@ -268,17 +228,14 @@ class _PerformanceReportPageState extends State<PerformanceReportPage> {
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
context.t.client_reports.performance_report.overall_score.title, context.t.client_reports.performance_report.overall_score.title,
style: const TextStyle( style: UiTypography.body3m.copyWith(
fontSize: 13,
color: UiColors.textSecondary, color: UiColors.textSecondary,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'${overallScore.toStringAsFixed(0)}/100', '${overallScore.toStringAsFixed(0)}/100',
style: const TextStyle( style: UiTypography.secondaryDisplay2b.copyWith(
fontSize: 48,
fontWeight: FontWeight.bold,
color: UiColors.primary, color: UiColors.primary,
), ),
), ),
@@ -294,9 +251,7 @@ class _PerformanceReportPageState extends State<PerformanceReportPage> {
), ),
child: Text( child: Text(
scoreLabel, scoreLabel,
style: TextStyle( style: UiTypography.body3b.copyWith(
fontSize: 13,
fontWeight: FontWeight.bold,
color: scoreLabelColor, color: scoreLabelColor,
), ),
), ),
@@ -325,9 +280,7 @@ class _PerformanceReportPageState extends State<PerformanceReportPage> {
children: <Widget>[ children: <Widget>[
Text( Text(
context.t.client_reports.performance_report.kpis_title, context.t.client_reports.performance_report.kpis_title,
style: const TextStyle( style: UiTypography.titleUppercase4b.copyWith(
fontSize: 11,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary, color: UiColors.textSecondary,
letterSpacing: 1.2, letterSpacing: 1.2,
), ),
@@ -357,9 +310,8 @@ class _PerformanceReportPageState extends State<PerformanceReportPage> {
} }
} }
// ” KPI data model ”””””””””””””””””””””””””””””” /// Data model for a single KPI row.
class _KpiData { class _KpiData {
const _KpiData({ const _KpiData({
required this.icon, required this.icon,
required this.iconColor, required this.iconColor,
@@ -371,21 +323,40 @@ class _KpiData {
required this.met, required this.met,
required this.close, required this.close,
}); });
/// The KPI icon.
final IconData icon; final IconData icon;
/// The icon tint color.
final Color iconColor; final Color iconColor;
/// The KPI label text.
final String label; final String label;
/// The target description text.
final String target; 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; final String displayValue;
/// The progress bar color.
final Color barColor; final Color barColor;
/// Whether the KPI target has been met.
final bool met; final bool met;
/// Whether the KPI is close to the target.
final bool close; final bool close;
} }
// ” KPI row widget ”””””””””””””””””””””””””””””” /// Widget rendering a single KPI row with label, progress bar, and badge.
class _KpiRow extends StatelessWidget { class _KpiRow extends StatelessWidget {
const _KpiRow({required this.kpi}); const _KpiRow({required this.kpi});
/// The KPI data to render.
final _KpiData kpi; final _KpiData kpi;
@override @override
@@ -428,33 +399,24 @@ class _KpiRow extends StatelessWidget {
children: <Widget>[ children: <Widget>[
Text( Text(
kpi.label, kpi.label,
style: const TextStyle( style: UiTypography.body3m,
fontSize: 13,
fontWeight: FontWeight.w600,
color: UiColors.textPrimary,
),
), ),
Text( Text(
kpi.target, kpi.target,
style: const TextStyle( style: UiTypography.titleUppercase4m.copyWith(
fontSize: 11,
color: UiColors.textSecondary, color: UiColors.textSecondary,
), ),
), ),
], ],
), ),
), ),
// Value + badge inline (matches prototype) // Value + badge inline
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[ children: <Widget>[
Text( Text(
kpi.displayValue, kpi.displayValue,
style: const TextStyle( style: UiTypography.body1b,
fontSize: 16,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Container( Container(
@@ -468,9 +430,7 @@ class _KpiRow extends StatelessWidget {
), ),
child: Text( child: Text(
badgeText, badgeText,
style: TextStyle( style: UiTypography.footnote2b.copyWith(
fontSize: 10,
fontWeight: FontWeight.bold,
color: badgeColor, color: badgeColor,
), ),
), ),
@@ -494,4 +454,3 @@ class _KpiRow extends StatelessWidget {
); );
} }
} }

View File

@@ -97,16 +97,13 @@ class _SpendReportPageState extends State<SpendReportPage> {
children: <Widget>[ children: <Widget>[
Text( Text(
context.t.client_reports.spend_report.title, context.t.client_reports.spend_report.title,
style: const TextStyle( style: UiTypography.title1b.copyWith(
fontSize: 18,
fontWeight: FontWeight.bold,
color: UiColors.white, color: UiColors.white,
), ),
), ),
Text( Text(
context.t.client_reports.spend_report.subtitle, context.t.client_reports.spend_report.subtitle,
style: TextStyle( style: UiTypography.body3r.copyWith(
fontSize: 12,
color: UiColors.white.withOpacity(0.7), color: UiColors.white.withOpacity(0.7),
), ),
), ),
@@ -179,11 +176,7 @@ class _SpendReportPageState extends State<SpendReportPage> {
Text( Text(
context.t.client_reports.spend_report context.t.client_reports.spend_report
.chart_title, .chart_title,
style: const TextStyle( style: UiTypography.body2b,
fontSize: 14,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
Expanded( Expanded(
@@ -222,6 +215,7 @@ class _SpendReportPageState extends State<SpendReportPage> {
class _SpendBarChart extends StatelessWidget { class _SpendBarChart extends StatelessWidget {
const _SpendBarChart({required this.chartData}); const _SpendBarChart({required this.chartData});
/// The chart data points to render.
final List<SpendDataPoint> chartData; final List<SpendDataPoint> chartData;
@override @override
@@ -245,9 +239,8 @@ class _SpendBarChart extends StatelessWidget {
BarChartRodData rod, int rodIndex) { BarChartRodData rod, int rodIndex) {
return BarTooltipItem( return BarTooltipItem(
'\$${rod.toY.round()}', '\$${rod.toY.round()}',
const TextStyle( UiTypography.body2b.copyWith(
color: UiColors.white, color: UiColors.white,
fontWeight: FontWeight.bold,
), ),
); );
}, },
@@ -269,9 +262,8 @@ class _SpendBarChart extends StatelessWidget {
space: 8, space: 8,
child: Text( child: Text(
DateFormat('E').format(date), DateFormat('E').format(date),
style: const TextStyle( style: UiTypography.titleUppercase4m.copyWith(
color: UiColors.textSecondary, color: UiColors.textSecondary,
fontSize: 11,
), ),
), ),
); );
@@ -288,9 +280,8 @@ class _SpendBarChart extends StatelessWidget {
axisSide: meta.axisSide, axisSide: meta.axisSide,
child: Text( child: Text(
'\$${(value / 1000).toStringAsFixed(0)}k', '\$${(value / 1000).toStringAsFixed(0)}k',
style: const TextStyle( style: UiTypography.footnote2r.copyWith(
color: UiColors.textSecondary, 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 { class _SpendStatCard extends StatelessWidget {
const _SpendStatCard({ const _SpendStatCard({
required this.label, required this.label,
@@ -342,10 +334,19 @@ class _SpendStatCard extends StatelessWidget {
required this.icon, required this.icon,
}); });
/// The metric label text.
final String label; final String label;
/// The metric value text.
final String value; final String value;
/// The pill badge text.
final String pillText; final String pillText;
/// The theme color for the icon and pill.
final Color themeColor; final Color themeColor;
/// The icon to display.
final IconData icon; final IconData icon;
@override @override
@@ -373,10 +374,8 @@ class _SpendStatCard extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
label, label,
style: const TextStyle( style: UiTypography.body3m.copyWith(
fontSize: 12,
color: UiColors.textSecondary, color: UiColors.textSecondary,
fontWeight: FontWeight.w500,
), ),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -387,11 +386,7 @@ class _SpendStatCard extends StatelessWidget {
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
value, value,
style: const TextStyle( style: UiTypography.headline1b,
fontSize: 24,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Container( Container(
@@ -402,9 +397,7 @@ class _SpendStatCard extends StatelessWidget {
), ),
child: Text( child: Text(
pillText, pillText,
style: TextStyle( style: UiTypography.footnote2b.copyWith(
fontSize: 10,
fontWeight: FontWeight.bold,
color: themeColor, color: themeColor,
), ),
), ),
@@ -419,6 +412,7 @@ class _SpendStatCard extends StatelessWidget {
class _SpendByCategoryCard extends StatelessWidget { class _SpendByCategoryCard extends StatelessWidget {
const _SpendByCategoryCard({required this.categories}); const _SpendByCategoryCard({required this.categories});
/// The category breakdown items.
final List<SpendItem> categories; final List<SpendItem> categories;
@override @override
@@ -441,11 +435,7 @@ class _SpendByCategoryCard extends StatelessWidget {
children: <Widget>[ children: <Widget>[
Text( Text(
context.t.client_reports.spend_report.spend_by_industry, context.t.client_reports.spend_report.spend_by_industry,
style: const TextStyle( style: UiTypography.body2b,
fontSize: 14,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
if (categories.isEmpty) if (categories.isEmpty)
@@ -454,7 +444,9 @@ class _SpendByCategoryCard extends StatelessWidget {
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Text( child: Text(
context.t.client_reports.spend_report.no_industry_data, 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: <Widget>[ children: <Widget>[
Text( Text(
item.category, item.category,
style: const TextStyle( style: UiTypography.body3m.copyWith(
fontSize: 13,
color: UiColors.textSecondary, color: UiColors.textSecondary,
), ),
), ),
@@ -478,11 +469,7 @@ class _SpendByCategoryCard extends StatelessWidget {
NumberFormat.currency( NumberFormat.currency(
symbol: r'$', decimalDigits: 0) symbol: r'$', decimalDigits: 0)
.format(item.amountCents / 100), .format(item.amountCents / 100),
style: const TextStyle( style: UiTypography.body3b,
fontSize: 13,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
), ),
], ],
), ),
@@ -500,8 +487,7 @@ class _SpendByCategoryCard extends StatelessWidget {
Text( Text(
context.t.client_reports.spend_report.percent_total( context.t.client_reports.spend_report.percent_total(
percent: item.percentage.toStringAsFixed(1)), percent: item.percentage.toStringAsFixed(1)),
style: const TextStyle( style: UiTypography.footnote2r.copyWith(
fontSize: 10,
color: UiColors.textDescription, color: UiColors.textDescription,
), ),
), ),

View File

@@ -75,11 +75,7 @@ class MetricCard extends StatelessWidget {
children: <Widget>[ children: <Widget>[
Text( Text(
value, value,
style: const TextStyle( style: UiTypography.headline1b,
fontSize: 24,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Container( Container(

View File

@@ -49,8 +49,9 @@ class MetricsGrid extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
state.message, state.message,
style: style: UiTypography.body3r.copyWith(
const TextStyle(color: UiColors.error, fontSize: 12), color: UiColors.error,
),
), ),
), ),
], ],

View File

@@ -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:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'report_card.dart';
/// A section displaying quick access report cards. /// A section displaying quick access report cards.
/// ///
/// Shows 4 quick report cards for: /// Shows 4 quick report cards for:

View File

@@ -69,11 +69,7 @@ class ReportCard extends StatelessWidget {
children: <Widget>[ children: <Widget>[
Text( Text(
name, name,
style: const TextStyle( style: UiTypography.body2m,
fontSize: 14,
fontWeight: FontWeight.w600,
color: UiColors.textPrimary,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -88,8 +84,7 @@ class ReportCard extends StatelessWidget {
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
context.t.client_reports.quick_reports.two_click_export, context.t.client_reports.quick_reports.two_click_export,
style: const TextStyle( style: UiTypography.body3r.copyWith(
fontSize: 12,
color: UiColors.textSecondary, color: UiColors.textSecondary,
), ),
), ),

View File

@@ -64,9 +64,7 @@ class ReportsHeader extends StatelessWidget {
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(
context.t.client_reports.title, context.t.client_reports.title,
style: const TextStyle( style: UiTypography.headline3b.copyWith(
fontSize: 20,
fontWeight: FontWeight.bold,
color: UiColors.white, color: UiColors.white,
), ),
), ),
@@ -98,12 +96,9 @@ class ReportsHeader extends StatelessWidget {
), ),
labelColor: UiColors.primary, labelColor: UiColors.primary,
unselectedLabelColor: UiColors.white, unselectedLabelColor: UiColors.white,
labelStyle: const TextStyle( labelStyle: UiTypography.body2m,
fontWeight: FontWeight.w600,
fontSize: 14,
),
indicatorSize: TabBarIndicatorSize.tab, indicatorSize: TabBarIndicatorSize.tab,
dividerColor: Colors.transparent, dividerColor: UiColors.transparent,
tabs: <Widget>[ tabs: <Widget>[
Tab(text: context.t.client_reports.tabs.today), Tab(text: context.t.client_reports.tabs.today),
Tab(text: context.t.client_reports.tabs.week), Tab(text: context.t.client_reports.tabs.week),

View File

@@ -1,5 +1,12 @@
import 'package:client_reports/src/data/repositories_impl/reports_repository_impl.dart'; 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/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/coverage/coverage_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_bloc.dart';
import 'package:client_reports/src/presentation/blocs/forecast/forecast_bloc.dart'; import 'package:client_reports/src/presentation/blocs/forecast/forecast_bloc.dart';
@@ -25,16 +32,84 @@ class ReportsModule extends Module {
@override @override
void binds(Injector i) { void binds(Injector i) {
i.addLazySingleton<ReportsRepository>( // ── Repository ───────────────────────────────────────────────────────────
i.addLazySingleton<ReportsRepositoryInterface>(
() => ReportsRepositoryImpl(apiService: i.get<BaseApiService>()), () => ReportsRepositoryImpl(apiService: i.get<BaseApiService>()),
); );
i.add<DailyOpsBloc>(DailyOpsBloc.new);
i.add<SpendBloc>(SpendBloc.new); // ── Use Cases ────────────────────────────────────────────────────────────
i.add<CoverageBloc>(CoverageBloc.new); i.add<GetDailyOpsReportUseCase>(
i.add<ForecastBloc>(ForecastBloc.new); () => GetDailyOpsReportUseCase(
i.add<PerformanceBloc>(PerformanceBloc.new); i.get<ReportsRepositoryInterface>(),
i.add<NoShowBloc>(NoShowBloc.new); ),
i.add<ReportsSummaryBloc>(ReportsSummaryBloc.new); );
i.add<GetSpendReportUseCase>(
() => GetSpendReportUseCase(
i.get<ReportsRepositoryInterface>(),
),
);
i.add<GetCoverageReportUseCase>(
() => GetCoverageReportUseCase(
i.get<ReportsRepositoryInterface>(),
),
);
i.add<GetForecastReportUseCase>(
() => GetForecastReportUseCase(
i.get<ReportsRepositoryInterface>(),
),
);
i.add<GetPerformanceReportUseCase>(
() => GetPerformanceReportUseCase(
i.get<ReportsRepositoryInterface>(),
),
);
i.add<GetNoShowReportUseCase>(
() => GetNoShowReportUseCase(
i.get<ReportsRepositoryInterface>(),
),
);
i.add<GetReportsSummaryUseCase>(
() => GetReportsSummaryUseCase(
i.get<ReportsRepositoryInterface>(),
),
);
// ── BLoCs ────────────────────────────────────────────────────────────────
i.add<DailyOpsBloc>(
() => DailyOpsBloc(
getDailyOpsReportUseCase: i.get<GetDailyOpsReportUseCase>(),
),
);
i.add<SpendBloc>(
() => SpendBloc(
getSpendReportUseCase: i.get<GetSpendReportUseCase>(),
),
);
i.add<CoverageBloc>(
() => CoverageBloc(
getCoverageReportUseCase: i.get<GetCoverageReportUseCase>(),
),
);
i.add<ForecastBloc>(
() => ForecastBloc(
getForecastReportUseCase: i.get<GetForecastReportUseCase>(),
),
);
i.add<PerformanceBloc>(
() => PerformanceBloc(
getPerformanceReportUseCase: i.get<GetPerformanceReportUseCase>(),
),
);
i.add<NoShowBloc>(
() => NoShowBloc(
getNoShowReportUseCase: i.get<GetNoShowReportUseCase>(),
),
);
i.add<ReportsSummaryBloc>(
() => ReportsSummaryBloc(
getReportsSummaryUseCase: i.get<GetReportsSummaryUseCase>(),
),
);
} }
@override @override

View File

@@ -21,7 +21,10 @@ class ClientSettingsModule extends Module {
void binds(Injector i) { void binds(Injector i) {
// Repositories // Repositories
i.addLazySingleton<SettingsRepositoryInterface>( i.addLazySingleton<SettingsRepositoryInterface>(
() => SettingsRepositoryImpl(apiService: i.get<BaseApiService>()), () => SettingsRepositoryImpl(
apiService: i.get<BaseApiService>(),
firebaseAuthService: i.get<FirebaseAuthService>(),
),
); );
// UseCases // UseCases

View File

@@ -1,6 +1,5 @@
import 'dart:developer' as developer; import 'dart:developer' as developer;
import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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]. /// Implementation of [SettingsRepositoryInterface].
/// ///
/// Uses V2 API for server-side token revocation and Firebase Auth for local /// Uses V2 API for server-side token revocation and [FirebaseAuthService]
/// sign-out. Clears the [ClientSessionStore] on sign-out. /// from core for local sign-out. Clears the [ClientSessionStore] on sign-out.
class SettingsRepositoryImpl implements SettingsRepositoryInterface { class SettingsRepositoryImpl implements SettingsRepositoryInterface {
/// Creates a [SettingsRepositoryImpl] with the required [BaseApiService]. /// Creates a [SettingsRepositoryImpl] with the required dependencies.
const SettingsRepositoryImpl({required BaseApiService apiService}) const SettingsRepositoryImpl({
: _apiService = apiService; required BaseApiService apiService,
required FirebaseAuthService firebaseAuthService,
}) : _apiService = apiService,
_firebaseAuthService = firebaseAuthService;
/// The V2 API service for backend calls. /// The V2 API service for backend calls.
final BaseApiService _apiService; final BaseApiService _apiService;
/// Core Firebase Auth service for local sign-out.
final FirebaseAuthService _firebaseAuthService;
@override @override
Future<void> signOut() async { Future<void> signOut() async {
try { try {
@@ -31,8 +36,8 @@ class SettingsRepositoryImpl implements SettingsRepositoryInterface {
// Continue with local sign-out even if server-side fails. // Continue with local sign-out even if server-side fails.
} }
// Step 2: Sign out from local Firebase Auth. // Step 2: Sign out from local Firebase Auth via core service.
await firebase.FirebaseAuth.instance.signOut(); await _firebaseAuthService.signOut();
// Step 3: Clear the client session store. // Step 3: Clear the client session store.
ClientSessionStore.instance.clear(); ClientSessionStore.instance.clear();

View File

@@ -67,7 +67,7 @@ class SettingsActions extends StatelessWidget {
style: UiTypography.headline3m.textPrimary, style: UiTypography.headline3m.textPrimary,
), ),
content: Text( content: Text(
'Are you sure you want to log out?', t.client_settings.profile.log_out_confirmation,
style: UiTypography.body2r.textSecondary, style: UiTypography.body2r.textSecondary,
), ),
actions: <Widget>[ actions: <Widget>[
@@ -77,7 +77,7 @@ class SettingsActions extends StatelessWidget {
), ),
UiButton.secondary( UiButton.secondary(
text: t.common.cancel, text: t.common.cancel,
onPressed: () => Modular.to.pop(), onPressed: () => Modular.to.popSafe(),
), ),
], ],
), ),

View File

@@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../../blocs/client_settings_bloc.dart'; import '../../blocs/client_settings_bloc.dart';
@@ -78,7 +79,7 @@ class SettingsLogout extends StatelessWidget {
// Cancel button // Cancel button
UiButton.secondary( UiButton.secondary(
text: t.common.cancel, text: t.common.cancel,
onPressed: () => Modular.to.pop(), onPressed: () => Modular.to.popSafe(),
), ),
], ],
), ),

View File

@@ -14,7 +14,6 @@ dependencies:
flutter_bloc: ^8.1.0 flutter_bloc: ^8.1.0
flutter_modular: ^6.3.0 flutter_modular: ^6.3.0
equatable: ^2.0.5 equatable: ^2.0.5
firebase_auth: ^6.1.2
# Architecture Packages # Architecture Packages
design_system: design_system:

View File

@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart' as domain; import 'package:krow_domain/krow_domain.dart' as domain;
import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart'; 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]. /// 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. /// 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 { class AuthRepositoryImpl implements AuthRepositoryInterface {
/// Creates an [AuthRepositoryImpl]. /// Creates an [AuthRepositoryImpl].
/// ///
/// Requires a [domain.BaseApiService] for V2 API calls. /// Requires a [domain.BaseApiService] for V2 API calls and a
AuthRepositoryImpl({required domain.BaseApiService apiService}) /// [FirebaseAuthService] for client-side Firebase Auth operations.
: _apiService = apiService; AuthRepositoryImpl({
required domain.BaseApiService apiService,
required FirebaseAuthService firebaseAuthService,
}) : _apiService = apiService,
_firebaseAuthService = firebaseAuthService;
/// The V2 API service for backend calls. /// The V2 API service for backend calls.
final domain.BaseApiService _apiService; final domain.BaseApiService _apiService;
/// Firebase Auth instance for client-side phone verification. /// Core Firebase Auth service abstraction.
final FirebaseAuth _auth = FirebaseAuth.instance; final FirebaseAuthService _firebaseAuthService;
/// Completer for the pending phone verification request.
Completer<String?>? _pendingVerification;
@override @override
Stream<domain.User?> get currentUser => Stream<domain.User?> get currentUser => _firebaseAuthService.authStateChanges;
_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,
);
});
/// Initiates phone verification via the V2 API. /// Initiates phone verification via the V2 API.
/// ///
/// Calls `POST /auth/staff/phone/start` first. The server decides the /// Calls `POST /auth/staff/phone/start` first. The server decides the
/// verification mode: /// verification mode:
/// - `CLIENT_FIREBASE_SDK` mobile must do Firebase phone auth client-side /// - `CLIENT_FIREBASE_SDK` -- mobile must do Firebase phone auth client-side
/// - `IDENTITY_TOOLKIT_SMS` server sent the SMS, returns `sessionInfo` /// - `IDENTITY_TOOLKIT_SMS` -- server sent the SMS, returns `sessionInfo`
/// ///
/// For mobile without recaptcha tokens, the server returns /// For mobile without recaptcha tokens, the server returns
/// `CLIENT_FIREBASE_SDK` and we fall back to the Firebase Auth SDK. /// `CLIENT_FIREBASE_SDK` and we fall back to the Firebase Auth SDK.
@override @override
Future<String?> signInWithPhone({required String phoneNumber}) async { Future<String?> signInWithPhone({required String phoneNumber}) async {
// Step 1: Try V2 to let the server decide the auth mode. // 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 mode = 'CLIENT_FIREBASE_SDK';
String? sessionInfo; String? sessionInfo;
@@ -74,7 +60,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
mode = startData['mode'] as String? ?? 'CLIENT_FIREBASE_SDK'; mode = startData['mode'] as String? ?? 'CLIENT_FIREBASE_SDK';
sessionInfo = startData['sessionInfo'] as String?; sessionInfo = startData['sessionInfo'] as String?;
} catch (_) { } 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. // Step 2: If server sent the SMS, return the sessionInfo for verify step.
@@ -82,55 +68,16 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
return sessionInfo; return sessionInfo;
} }
// Step 3: CLIENT_FIREBASE_SDK mode do Firebase phone auth client-side. // Step 3: CLIENT_FIREBASE_SDK mode -- do Firebase phone auth client-side.
final Completer<String?> completer = Completer<String?>(); return _firebaseAuthService.verifyPhoneNumber(
_pendingVerification = completer;
await _auth.verifyPhoneNumber(
phoneNumber: phoneNumber, phoneNumber: phoneNumber,
verificationCompleted: (PhoneAuthCredential credential) { onAutoVerified: TestPhoneNumbers.isTestNumber(phoneNumber) ? null : null,
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);
}
},
); );
return completer.future;
} }
@override @override
void cancelPendingPhoneVerification() { void cancelPendingPhoneVerification() {
final Completer<String?>? completer = _pendingVerification; _firebaseAuthService.cancelPendingPhoneVerification();
if (completer != null && !completer.isCompleted) {
completer.completeError(Exception('Phone verification cancelled.'));
}
_pendingVerification = null;
} }
/// Verifies the OTP and completes authentication via the V2 API. /// Verifies the OTP and completes authentication via the V2 API.
@@ -145,53 +92,26 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
required String smsCode, required String smsCode,
required AuthMode mode, required AuthMode mode,
}) async { }) async {
// Step 1: Sign in with Firebase credential (client-side). // Step 1: Sign in with Firebase credential via core service.
final PhoneAuthCredential credential = PhoneAuthProvider.credential( final PhoneSignInResult signInResult =
await _firebaseAuthService.signInWithPhoneCredential(
verificationId: verificationId, verificationId: verificationId,
smsCode: smsCode, smsCode: smsCode,
); );
final UserCredential userCredential; // Step 2: Call V2 verify endpoint with the Firebase ID token.
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.
final String v2Mode = mode == AuthMode.signup ? 'sign-up' : 'sign-in'; final String v2Mode = mode == AuthMode.signup ? 'sign-up' : 'sign-in';
final domain.ApiResponse response = await _apiService.post( final domain.ApiResponse response = await _apiService.post(
AuthEndpoints.staffPhoneVerify, AuthEndpoints.staffPhoneVerify,
data: <String, dynamic>{ data: <String, dynamic>{
'idToken': idToken, 'idToken': signInResult.idToken,
'mode': v2Mode, 'mode': v2Mode,
}, },
); );
final Map<String, dynamic> data = response.data as Map<String, dynamic>; final Map<String, dynamic> data = response.data as Map<String, dynamic>;
// Step 4: Check for business logic errors from the V2 API. // Step 3: Check for business logic errors from the V2 API.
final Map<String, dynamic>? staffData = final Map<String, dynamic>? staffData =
data['staff'] as Map<String, dynamic>?; data['staff'] as Map<String, dynamic>?;
final Map<String, dynamic>? userData = final Map<String, dynamic>? userData =
@@ -202,7 +122,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
// - Sign-in: staff must exist // - Sign-in: staff must exist
if (mode == AuthMode.login) { if (mode == AuthMode.login) {
if (staffData == null) { if (staffData == null) {
await _auth.signOut(); await _firebaseAuthService.signOut();
throw const domain.UserNotFoundException( throw const domain.UserNotFoundException(
technicalMessage: technicalMessage:
'Your account is not registered yet. Please register first.', '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) { if (staffData != null) {
final domain.StaffSession staffSession = final domain.StaffSession staffSession =
domain.StaffSession.fromJson(data); domain.StaffSession.fromJson(data);
@@ -219,10 +139,10 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
// Build the domain user from the V2 response. // Build the domain user from the V2 response.
final domain.User domainUser = domain.User( 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?, email: userData?['email'] as String?,
displayName: userData?['displayName'] 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, status: domain.UserStatus.active,
); );
@@ -238,7 +158,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
// Sign-out should not fail even if the API call fails. // Sign-out should not fail even if the API call fails.
// The local sign-out below will clear the session regardless. // The local sign-out below will clear the session regardless.
} }
await _auth.signOut(); await _firebaseAuthService.signOut();
StaffSessionStore.instance.clear(); StaffSessionStore.instance.clear();
} }
} }

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