Merge dev into feature branch

This commit is contained in:
2026-03-19 13:16:04 +05:30
273 changed files with 7867 additions and 3654 deletions

View File

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

View File

@@ -1,7 +1,6 @@
import 'dart:developer' as developer;
import 'package:client_authentication/src/domain/repositories/auth_repository_interface.dart';
import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'
show
@@ -10,7 +9,6 @@ import 'package:krow_domain/krow_domain.dart'
AppException,
BaseApiService,
ClientSession,
InvalidCredentialsException,
NetworkException,
PasswordMismatchException,
SignInFailedException,
@@ -21,20 +19,23 @@ import 'package:krow_domain/krow_domain.dart'
/// Production implementation of the [AuthRepositoryInterface] for the client app.
///
/// Uses Firebase Auth client-side for sign-in (to maintain local auth state for
/// the [AuthInterceptor]), then calls V2 `GET /auth/session` to retrieve
/// business context. Sign-up provisioning (tenant, business, memberships) is
/// handled entirely server-side by the V2 API.
/// Uses [FirebaseAuthService] from core for local Firebase sign-in (to maintain
/// local auth state for the [AuthInterceptor]), then calls V2 `GET /auth/session`
/// to retrieve business context. Sign-up provisioning (tenant, business,
/// memberships) is handled entirely server-side by the V2 API.
class AuthRepositoryImpl implements AuthRepositoryInterface {
/// Creates an [AuthRepositoryImpl] with the given [BaseApiService].
AuthRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
/// Creates an [AuthRepositoryImpl] with the given dependencies.
AuthRepositoryImpl({
required BaseApiService apiService,
required FirebaseAuthService firebaseAuthService,
}) : _apiService = apiService,
_firebaseAuthService = firebaseAuthService;
/// The V2 API service for backend calls.
final BaseApiService _apiService;
/// Firebase Auth instance for client-side sign-in/sign-up.
firebase.FirebaseAuth get _auth => firebase.FirebaseAuth.instance;
/// Core Firebase Auth service abstraction.
final FirebaseAuthService _firebaseAuthService;
@override
Future<User> signInWithEmail({
@@ -42,38 +43,26 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
required String password,
}) async {
try {
// Step 1: Call V2 sign-in endpoint server handles Firebase Auth
// Step 1: Call V2 sign-in endpoint -- server handles Firebase Auth
// via Identity Toolkit and returns a full auth envelope.
final ApiResponse response = await _apiService.post(
AuthEndpoints.clientSignIn,
data: <String, dynamic>{
'email': email,
'password': password,
},
data: <String, dynamic>{'email': email, 'password': password},
);
final Map<String, dynamic> body =
response.data as Map<String, dynamic>;
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
// Step 2: Sign in locally so AuthInterceptor can attach Bearer tokens
// to subsequent requests. The V2 API already validated credentials, so
// email/password sign-in establishes the local Firebase Auth state.
final firebase.UserCredential credential =
await _auth.signInWithEmailAndPassword(
await _firebaseAuthService.signInWithEmailAndPassword(
email: email,
password: password,
);
final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) {
throw const SignInFailedException(
technicalMessage: 'Local Firebase sign-in failed after V2 sign-in',
);
}
// Step 3: Populate session store from the V2 auth envelope directly
// (no need for a separate GET /auth/session call).
return _populateStoreFromAuthEnvelope(body, firebaseUser, email);
return _populateStoreFromAuthEnvelope(body, email);
} on AppException {
rethrow;
} catch (e) {
@@ -106,38 +95,34 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
// Step 2: Sign in locally to Firebase Auth so AuthInterceptor works
// for subsequent requests. The V2 API already created the Firebase
// account, so this should succeed.
final firebase.UserCredential credential =
await _auth.signInWithEmailAndPassword(
email: email,
password: password,
);
final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) {
try {
await _firebaseAuthService.signInWithEmailAndPassword(
email: email,
password: password,
);
} on SignInFailedException {
throw const SignUpFailedException(
technicalMessage: 'Local Firebase sign-in failed after V2 sign-up',
);
}
// Step 3: Populate store from the sign-up response envelope.
return _populateStoreFromAuthEnvelope(body, firebaseUser, email);
} on firebase.FirebaseAuthException catch (e) {
if (e.code == 'email-already-in-use') {
throw AccountExistsException(
technicalMessage: 'Firebase: ${e.message}',
);
} else if (e.code == 'weak-password') {
throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}');
} else if (e.code == 'network-request-failed') {
throw NetworkException(technicalMessage: 'Firebase: ${e.message}');
} else {
throw SignUpFailedException(
technicalMessage: 'Firebase auth error: ${e.message}',
);
}
return _populateStoreFromAuthEnvelope(body, email);
} on AppException {
rethrow;
} catch (e) {
// Map common Firebase-originated errors from the V2 API response
// to domain exceptions.
final String errorMessage = e.toString();
if (errorMessage.contains('EMAIL_EXISTS') ||
errorMessage.contains('email-already-in-use')) {
throw AccountExistsException(technicalMessage: errorMessage);
} else if (errorMessage.contains('WEAK_PASSWORD') ||
errorMessage.contains('weak-password')) {
throw WeakPasswordException(technicalMessage: errorMessage);
} else if (errorMessage.contains('network-request-failed')) {
throw NetworkException(technicalMessage: errorMessage);
}
throw SignUpFailedException(technicalMessage: 'Unexpected error: $e');
}
}
@@ -155,16 +140,13 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
// Step 1: Call V2 sign-out endpoint for server-side token revocation.
await _apiService.post(AuthEndpoints.clientSignOut);
} catch (e) {
developer.log(
'V2 sign-out request failed: $e',
name: 'AuthRepository',
);
developer.log('V2 sign-out request failed: $e', name: 'AuthRepository');
// Continue with local sign-out even if server-side fails.
}
try {
// Step 2: Sign out from local Firebase Auth.
await _auth.signOut();
// Step 2: Sign out from local Firebase Auth via core service.
await _firebaseAuthService.signOut();
} catch (e) {
throw Exception('Error signing out locally: $e');
}
@@ -181,7 +163,6 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
/// returns a domain [User].
User _populateStoreFromAuthEnvelope(
Map<String, dynamic> envelope,
firebase.User firebaseUser,
String fallbackEmail,
) {
final Map<String, dynamic>? userJson =
@@ -202,14 +183,15 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
'userId': userJson['id'] ?? userJson['userId'],
},
};
final ClientSession clientSession =
ClientSession.fromJson(normalisedEnvelope);
final ClientSession clientSession = ClientSession.fromJson(
normalisedEnvelope,
);
ClientSessionStore.instance.setSession(clientSession);
}
final String userId =
userJson?['id'] as String? ?? firebaseUser.uid;
final String? email = userJson?['email'] as String? ?? fallbackEmail;
final String userId = userJson?['id'] as String? ??
(_firebaseAuthService.currentUserUid ?? '');
final String email = userJson?['email'] as String? ?? fallbackEmail;
return User(
id: userId,

View File

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

View File

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

View File

@@ -1,14 +1,14 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:billing/src/domain/repositories/billing_repository.dart';
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
/// Implementation of [BillingRepository] using the V2 REST API.
/// Implementation of [BillingRepositoryInterface] using the V2 REST API.
///
/// All backend calls go through [BaseApiService] with [ClientEndpoints].
class BillingRepositoryImpl implements BillingRepository {
/// Creates a [BillingRepositoryImpl].
BillingRepositoryImpl({required BaseApiService apiService})
class BillingRepositoryInterfaceImpl implements BillingRepositoryInterface {
/// Creates a [BillingRepositoryInterfaceImpl].
BillingRepositoryInterfaceImpl({required BaseApiService apiService})
: _apiService = apiService;
/// The API service used for all HTTP requests.

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,16 @@
import 'package:krow_core/core.dart';
import 'package:billing/src/domain/repositories/billing_repository.dart';
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
/// Use case for fetching the current bill amount in cents.
///
/// Delegates data retrieval to the [BillingRepository].
/// Delegates data retrieval to the [BillingRepositoryInterface].
class GetCurrentBillAmountUseCase extends NoInputUseCase<int> {
/// Creates a [GetCurrentBillAmountUseCase].
GetCurrentBillAmountUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
final BillingRepositoryInterface _repository;
@override
Future<int> call() => _repository.getCurrentBillCents();

View File

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

View File

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

View File

@@ -1,16 +1,16 @@
import 'package:krow_core/core.dart';
import 'package:billing/src/domain/repositories/billing_repository.dart';
import 'package:billing/src/domain/repositories/billing_repository_interface.dart';
/// Use case for fetching the savings amount in cents.
///
/// Delegates data retrieval to the [BillingRepository].
/// Delegates data retrieval to the [BillingRepositoryInterface].
class GetSavingsAmountUseCase extends NoInputUseCase<int> {
/// Creates a [GetSavingsAmountUseCase].
GetSavingsAmountUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
final BillingRepositoryInterface _repository;
@override
Future<int> call() => _repository.getSavingsCents();

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ class BillingPageSkeleton extends StatelessWidget {
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
children: <Widget>[
// Pending invoices section header
const UiShimmerSectionHeader(),
const SizedBox(height: UiConstants.space3),
@@ -39,7 +39,7 @@ class BillingPageSkeleton extends StatelessWidget {
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
children: <Widget>[
UiShimmerLine(width: 160, height: 16),
SizedBox(height: UiConstants.space4),
// Breakdown rows

View File

@@ -10,7 +10,7 @@ class BreakdownRowSkeleton extends StatelessWidget {
Widget build(BuildContext context) {
return const Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
children: <Widget>[
UiShimmerLine(width: 100, height: 14),
UiShimmerLine(width: 60, height: 14),
],

View File

@@ -16,10 +16,10 @@ class InvoiceCardSkeleton extends StatelessWidget {
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
children: <Widget>[
UiShimmerBox(
width: 72,
height: 24,
@@ -35,10 +35,10 @@ class InvoiceCardSkeleton extends StatelessWidget {
const SizedBox(height: UiConstants.space4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
children: <Widget>[
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
children: <Widget>[
UiShimmerLine(width: 80, height: 10),
SizedBox(height: UiConstants.space1),
UiShimmerLine(width: 100, height: 18),

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ class InvoicesListSkeleton extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
children: List.generate(4, (int index) {
children: List<Widget>.generate(4, (int index) {
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space4),
child: Container(
@@ -26,10 +26,10 @@ class InvoicesListSkeleton extends StatelessWidget {
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
children: <Widget>[
UiShimmerBox(
width: 64,
height: 22,
@@ -47,10 +47,10 @@ class InvoicesListSkeleton extends StatelessWidget {
const SizedBox(height: UiConstants.space3),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
children: <Widget>[
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
children: <Widget>[
UiShimmerLine(width: 80, height: 10),
SizedBox(height: UiConstants.space1),
UiShimmerLine(width: 100, height: 20),

View File

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

View File

@@ -3,7 +3,7 @@ import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:client_coverage/src/data/repositories_impl/coverage_repository_impl.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart';
import 'package:client_coverage/src/domain/usecases/cancel_late_worker_usecase.dart';
import 'package:client_coverage/src/domain/usecases/get_coverage_stats_usecase.dart';
import 'package:client_coverage/src/domain/usecases/get_shifts_for_date_usecase.dart';
@@ -21,8 +21,8 @@ class CoverageModule extends Module {
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<CoverageRepository>(
() => CoverageRepositoryImpl(apiService: i.get<BaseApiService>()),
i.addLazySingleton<CoverageRepositoryInterface>(
() => CoverageRepositoryInterfaceImpl(apiService: i.get<BaseApiService>()),
);
// Use Cases

View File

@@ -1,14 +1,14 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart';
/// V2 API implementation of [CoverageRepository].
/// V2 API implementation of [CoverageRepositoryInterface].
///
/// Uses [BaseApiService] with [ClientEndpoints] for all backend access.
class CoverageRepositoryImpl implements CoverageRepository {
/// Creates a [CoverageRepositoryImpl].
CoverageRepositoryImpl({required BaseApiService apiService})
class CoverageRepositoryInterfaceImpl implements CoverageRepositoryInterface {
/// Creates a [CoverageRepositoryInterfaceImpl].
CoverageRepositoryInterfaceImpl({required BaseApiService apiService})
: _apiService = apiService;
final BaseApiService _apiService;

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,
/// acting as a boundary between the Domain and Data layers.
abstract interface class CoverageRepository {
abstract interface class CoverageRepositoryInterface {
/// Fetches shifts with assigned workers for a specific [date].
Future<List<ShiftWithWorkers>> getShiftsForDate({required DateTime date});

View File

@@ -1,17 +1,17 @@
import 'package:krow_core/core.dart';
import 'package:client_coverage/src/domain/arguments/cancel_late_worker_arguments.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart';
/// Use case for cancelling a late worker's assignment.
///
/// Delegates to [CoverageRepository] to cancel the assignment via V2 API.
/// Delegates to [CoverageRepositoryInterface] to cancel the assignment via V2 API.
class CancelLateWorkerUseCase
implements UseCase<CancelLateWorkerArguments, void> {
/// Creates a [CancelLateWorkerUseCase].
CancelLateWorkerUseCase(this._repository);
final CoverageRepository _repository;
final CoverageRepositoryInterface _repository;
@override
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:client_coverage/src/domain/arguments/get_coverage_stats_arguments.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart';
/// Use case for fetching aggregated coverage statistics for a specific date.
///
/// Delegates to [CoverageRepository] and returns a [CoverageStats] entity.
/// Delegates to [CoverageRepositoryInterface] and returns a [CoverageStats] entity.
class GetCoverageStatsUseCase
implements UseCase<GetCoverageStatsArguments, CoverageStats> {
/// Creates a [GetCoverageStatsUseCase].
GetCoverageStatsUseCase(this._repository);
final CoverageRepository _repository;
final CoverageRepositoryInterface _repository;
@override
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:client_coverage/src/domain/arguments/get_shifts_for_date_arguments.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart';
/// Use case for fetching shifts with workers for a specific date.
///
/// Delegates to [CoverageRepository] and returns V2 [ShiftWithWorkers] entities.
/// Delegates to [CoverageRepositoryInterface] and returns V2 [ShiftWithWorkers] entities.
class GetShiftsForDateUseCase
implements UseCase<GetShiftsForDateArguments, List<ShiftWithWorkers>> {
/// Creates a [GetShiftsForDateUseCase].
GetShiftsForDateUseCase(this._repository);
final CoverageRepository _repository;
final CoverageRepositoryInterface _repository;
@override
Future<List<ShiftWithWorkers>> call(GetShiftsForDateArguments arguments) {

View File

@@ -1,17 +1,17 @@
import 'package:krow_core/core.dart';
import 'package:client_coverage/src/domain/arguments/submit_worker_review_arguments.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository_interface.dart';
/// Use case for submitting a worker review from the coverage page.
///
/// Validates the rating range and delegates to [CoverageRepository].
/// Validates the rating range and delegates to [CoverageRepositoryInterface].
class SubmitWorkerReviewUseCase
implements UseCase<SubmitWorkerReviewArguments, void> {
/// Creates a [SubmitWorkerReviewUseCase].
SubmitWorkerReviewUseCase(this._repository);
final CoverageRepository _repository;
final CoverageRepositoryInterface _repository;
@override
Future<void> call(SubmitWorkerReviewArguments arguments) async {

View File

@@ -10,14 +10,14 @@ 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/widgets/coverage_calendar_selector.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_page_skeleton.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_quick_stats.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_shift_list.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_stats_header.dart';
import 'package:client_coverage/src/presentation/widgets/late_workers_alert.dart';
/// Page for displaying daily coverage information.
///
/// Shows shifts, worker statuses, and coverage statistics for a selected date.
/// Shows shifts, worker statuses, and coverage statistics for a selected date
/// using a collapsible SliverAppBar with gradient header and live activity feed.
class CoveragePage extends StatefulWidget {
/// Creates a [CoveragePage].
const CoveragePage({super.key});
@@ -27,14 +27,13 @@ class CoveragePage extends StatefulWidget {
}
class _CoveragePageState extends State<CoveragePage> {
/// Controller for the [CustomScrollView].
late ScrollController _scrollController;
bool _isScrolled = false;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
_scrollController.addListener(_onScroll);
}
@override
@@ -43,16 +42,6 @@ class _CoveragePageState extends State<CoveragePage> {
super.dispose();
}
void _onScroll() {
if (_scrollController.hasClients) {
if (_scrollController.offset > 180 && !_isScrolled) {
setState(() => _isScrolled = true);
} else if (_scrollController.offset <= 180 && _isScrolled) {
setState(() => _isScrolled = false);
}
}
}
@override
Widget build(BuildContext context) {
return BlocProvider<CoverageBloc>(
@@ -69,6 +58,21 @@ class _CoveragePageState extends State<CoveragePage> {
type: UiSnackbarType.error,
);
}
if (state.writeStatus == CoverageWriteStatus.submitted) {
UiSnackbar.show(
context,
message: context.t.client_coverage.review.success,
type: UiSnackbarType.success,
);
}
if (state.writeStatus == CoverageWriteStatus.submitFailure &&
state.writeErrorMessage != null) {
UiSnackbar.show(
context,
message: translateErrorKey(state.writeErrorMessage!),
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, CoverageState state) {
final DateTime selectedDate = state.selectedDate ?? DateTime.now();
@@ -78,19 +82,26 @@ class _CoveragePageState extends State<CoveragePage> {
slivers: <Widget>[
SliverAppBar(
pinned: true,
expandedHeight: 300.0,
expandedHeight: 316.0,
backgroundColor: UiColors.primary,
title: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text(
_isScrolled
? DateFormat('MMMM d').format(selectedDate)
: context.t.client_coverage.page.daily_coverage,
key: ValueKey<bool>(_isScrolled),
style: UiTypography.title2m.copyWith(
color: UiColors.primaryForeground,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
context.t.client_coverage.page.daily_coverage,
style: UiTypography.title2m.copyWith(
color: UiColors.primaryForeground,
),
),
),
Text(
DateFormat('EEEE, MMMM d').format(selectedDate),
style: UiTypography.body3r.copyWith(
color: UiColors.primaryForeground
.withValues(alpha: 0.6),
),
),
],
),
actions: <Widget>[
IconButton(
@@ -117,10 +128,13 @@ class _CoveragePageState extends State<CoveragePage> {
],
flexibleSpace: Container(
decoration: const BoxDecoration(
// Intentional gradient: the second stop is a darker
// variant of UiColors.primary used only for the
// coverage header visual effect.
gradient: LinearGradient(
colors: <Color>[
UiColors.primary,
UiColors.primary,
Color(0xFF0626A8),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
@@ -154,6 +168,12 @@ class _CoveragePageState extends State<CoveragePage> {
state.stats?.totalPositionsConfirmed ?? 0,
totalNeeded:
state.stats?.totalPositionsNeeded ?? 0,
totalCheckedIn:
state.stats?.totalWorkersCheckedIn ?? 0,
totalEnRoute:
state.stats?.totalWorkersEnRoute ?? 0,
totalLate:
state.stats?.totalWorkersLate ?? 0,
),
],
),
@@ -176,7 +196,10 @@ class _CoveragePageState extends State<CoveragePage> {
);
}
/// Builds the main body content based on the current state.
/// Builds the main body content based on the current [CoverageState].
///
/// Displays a skeleton loader, error state, or the live activity feed
/// with late worker alerts and shift list.
Widget _buildBody({
required BuildContext context,
required CoverageState state,
@@ -227,24 +250,19 @@ class _CoveragePageState extends State<CoveragePage> {
crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space6,
children: <Widget>[
Column(
spacing: UiConstants.space2,
children: <Widget>[
if (state.stats != null &&
state.stats!.totalWorkersLate > 0) ...<Widget>[
LateWorkersAlert(
lateCount: state.stats!.totalWorkersLate,
),
],
if (state.stats != null) ...<Widget>[
CoverageQuickStats(stats: state.stats!),
],
],
),
if (state.stats != null &&
state.stats!.totalWorkersLate > 0) ...<Widget>[
LateWorkersAlert(
lateCount: state.stats!.totalWorkersLate,
),
],
Text(
'${context.t.client_coverage.page.shifts} (${state.shifts.length})',
style: UiTypography.title2b.copyWith(
color: UiColors.textPrimary,
context.t.client_coverage.page.live_activity,
style: UiTypography.body4m.copyWith(
color: UiColors.textSecondary,
letterSpacing: 2.0,
fontWeight: FontWeight.w900,
fontSize: 10,
),
),
CoverageShiftList(shifts: state.shifts),

View File

@@ -0,0 +1,188 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:client_coverage/src/presentation/blocs/coverage_bloc.dart';
import 'package:client_coverage/src/presentation/blocs/coverage_event.dart';
/// Bottom sheet modal for cancelling a late worker's assignment.
///
/// Collects an optional cancellation reason and dispatches a
/// [CoverageCancelLateWorkerRequested] event to the [CoverageBloc].
class CancelLateWorkerSheet extends StatefulWidget {
/// Creates a [CancelLateWorkerSheet].
const CancelLateWorkerSheet({
required this.worker,
super.key,
});
/// The assigned worker to cancel.
final AssignedWorker worker;
/// Shows the cancel-late-worker bottom sheet.
///
/// Captures [CoverageBloc] from [context] before opening so the sheet
/// can dispatch events without relying on an ancestor that may be
/// deactivated.
static void show(BuildContext context, {required AssignedWorker worker}) {
final CoverageBloc bloc = ReadContext(context).read<CoverageBloc>();
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(UiConstants.space4),
),
),
builder: (_) => BlocProvider<CoverageBloc>.value(
value: bloc,
child: CancelLateWorkerSheet(worker: worker),
),
);
}
@override
State<CancelLateWorkerSheet> createState() => _CancelLateWorkerSheetState();
}
class _CancelLateWorkerSheetState extends State<CancelLateWorkerSheet> {
/// Controller for the optional cancellation reason text field.
final TextEditingController _reasonController = TextEditingController();
@override
void dispose() {
_reasonController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final TranslationsClientCoverageCancelEn l10n =
context.t.client_coverage.cancel;
return Padding(
padding: EdgeInsets.only(
left: UiConstants.space4,
right: UiConstants.space4,
top: UiConstants.space3,
bottom: MediaQuery.of(context).viewInsets.bottom + UiConstants.space4,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
// Drag handle
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: UiColors.border,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: UiConstants.space4),
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
spacing: UiConstants.space3,
children: <Widget>[
const Icon(
UiIcons.warning,
color: UiColors.destructive,
size: 28,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(l10n.title, style: UiTypography.title1b.textError),
Text(
l10n.subtitle,
style: UiTypography.body2r.textSecondary,
),
],
),
],
),
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: const Icon(
UiIcons.close,
color: UiColors.textSecondary,
size: 24,
),
),
],
),
const SizedBox(height: UiConstants.space4),
// Body
Text(
l10n.confirm_message(name: widget.worker.fullName),
style: UiTypography.body1r,
),
const SizedBox(height: UiConstants.space1),
Text(
l10n.helper_text,
style: UiTypography.body2r.textSecondary,
),
const SizedBox(height: UiConstants.space4),
// Reason field
UiTextField(
hintText: l10n.reason_placeholder,
maxLines: 2,
controller: _reasonController,
),
const SizedBox(height: UiConstants.space4),
// Action buttons
Row(
children: <Widget>[
Expanded(
child: UiButton.secondary(
text: l10n.keep_worker,
onPressed: () => Navigator.of(context).pop(),
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: UiButton.primary(
text: l10n.confirm,
onPressed: () => _onConfirm(context),
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.destructive,
foregroundColor: UiColors.primaryForeground,
),
),
),
],
),
const SizedBox(height: UiConstants.space24),
],
),
);
}
/// Dispatches the cancel event and closes the sheet.
void _onConfirm(BuildContext context) {
final String reason = _reasonController.text.trim();
ReadContext(context).read<CoverageBloc>().add(
CoverageCancelLateWorkerRequested(
assignmentId: widget.worker.assignmentId,
reason: reason.isNotEmpty ? reason : null,
),
);
Navigator.of(context).pop();
}
}

View File

@@ -110,7 +110,7 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
decoration: BoxDecoration(
color: isSelected
? UiColors.primaryForeground
: UiColors.primaryForeground.withOpacity(0.1),
: UiColors.primaryForeground.withAlpha(25),
borderRadius: UiConstants.radiusLg,
border: isToday && !isSelected
? Border.all(
@@ -122,6 +122,14 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
DateFormat('E').format(date),
style: UiTypography.body4m.copyWith(
color: isSelected
? UiColors.primary
: UiColors.primaryForeground.withAlpha(179),
),
),
Text(
date.day.toString().padLeft(2, '0'),
style: UiTypography.body1b.copyWith(
@@ -130,14 +138,6 @@ class _CoverageCalendarSelectorState extends State<CoverageCalendarSelector> {
: UiColors.primaryForeground,
),
),
Text(
DateFormat('E').format(date),
style: UiTypography.body4m.copyWith(
color: isSelected
? UiColors.mutedForeground
: UiColors.primaryForeground.withOpacity(0.7),
),
),
],
),
),

View File

@@ -5,40 +5,30 @@ import 'package:client_coverage/src/presentation/widgets/coverage_page_skeleton/
/// Shimmer loading skeleton that mimics the coverage page loaded layout.
///
/// Shows placeholder shapes for the quick stats row, shift section header,
/// and a list of shift cards with worker rows.
/// Shows placeholder shapes for the live activity section label and a list
/// of shift cards with worker rows.
class CoveragePageSkeleton extends StatelessWidget {
/// Creates a [CoveragePageSkeleton].
const CoveragePageSkeleton({super.key});
@override
Widget build(BuildContext context) {
return UiShimmer(
return const UiShimmer(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space5),
padding: EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Quick stats row (2 stat cards)
const Row(
children: [
Expanded(child: UiShimmerStatsCard()),
SizedBox(width: UiConstants.space2),
Expanded(child: UiShimmerStatsCard()),
],
),
const SizedBox(height: UiConstants.space6),
// Shifts section header
const UiShimmerLine(width: 140, height: 18),
const SizedBox(height: UiConstants.space6),
children: <Widget>[
// "LIVE ACTIVITY" section label placeholder
UiShimmerLine(width: 100, height: 10),
SizedBox(height: UiConstants.space6),
// Shift cards with worker rows
const ShiftCardSkeleton(),
const SizedBox(height: UiConstants.space3),
const ShiftCardSkeleton(),
const SizedBox(height: UiConstants.space3),
const ShiftCardSkeleton(),
ShiftCardSkeleton(),
SizedBox(height: UiConstants.space3),
ShiftCardSkeleton(),
SizedBox(height: UiConstants.space3),
ShiftCardSkeleton(),
],
),
),

View File

@@ -14,20 +14,20 @@ class ShiftCardSkeleton extends StatelessWidget {
borderRadius: UiConstants.radiusLg,
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
child: Column(
children: <Widget>[
// Shift header
Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
children: <Widget>[
const UiShimmerLine(width: 180, height: 16),
const SizedBox(height: UiConstants.space2),
const UiShimmerLine(width: 120, height: 12),
const SizedBox(height: UiConstants.space2),
Row(
children: [
children: <Widget>[
const UiShimmerLine(width: 80, height: 12),
const Spacer(),
UiShimmerBox(
@@ -47,7 +47,7 @@ class ShiftCardSkeleton extends StatelessWidget {
horizontal: UiConstants.space3,
).copyWith(bottom: UiConstants.space3),
child: const Column(
children: [
children: <Widget>[
UiShimmerListItem(),
UiShimmerListItem(),
],

View File

@@ -1,45 +0,0 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_stat_card.dart';
/// Quick statistics cards showing coverage metrics.
///
/// Displays checked-in and en-route worker counts.
class CoverageQuickStats extends StatelessWidget {
/// Creates a [CoverageQuickStats].
const CoverageQuickStats({
required this.stats,
super.key,
});
/// The coverage statistics to display.
final CoverageStats stats;
@override
Widget build(BuildContext context) {
return Row(
spacing: UiConstants.space2,
children: <Widget>[
Expanded(
child: CoverageStatCard(
icon: UiIcons.success,
label: context.t.client_coverage.stats.checked_in,
value: stats.totalWorkersCheckedIn.toString(),
color: UiColors.iconSuccess,
),
),
Expanded(
child: CoverageStatCard(
icon: UiIcons.clock,
label: context.t.client_coverage.stats.en_route,
value: stats.totalWorkersEnRoute.toString(),
color: UiColors.textWarning,
),
),
],
);
}
}

View File

@@ -4,13 +4,17 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:client_coverage/src/presentation/widgets/cancel_late_worker_sheet.dart';
import 'package:client_coverage/src/presentation/widgets/shift_header.dart';
import 'package:client_coverage/src/presentation/widgets/worker_row.dart';
import 'package:client_coverage/src/presentation/widgets/worker_review_sheet.dart';
/// List of shifts with their workers.
/// Displays a list of shifts as collapsible cards with worker details.
///
/// Displays all shifts for the selected date, or an empty state if none exist.
class CoverageShiftList extends StatelessWidget {
/// Each shift is rendered as a card with a tappable [ShiftHeader] that toggles
/// visibility of the worker rows beneath it. All cards start expanded.
/// Shows an empty state when [shifts] is empty.
class CoverageShiftList extends StatefulWidget {
/// Creates a [CoverageShiftList].
const CoverageShiftList({
required this.shifts,
@@ -20,17 +24,73 @@ class CoverageShiftList extends StatelessWidget {
/// The list of shifts to display.
final List<ShiftWithWorkers> shifts;
@override
State<CoverageShiftList> createState() => _CoverageShiftListState();
}
/// State for [CoverageShiftList] managing which shift cards are expanded.
class _CoverageShiftListState extends State<CoverageShiftList> {
/// Set of shift IDs whose cards are currently expanded.
final Set<String> _expandedShiftIds = <String>{};
/// Whether the expanded set has been initialised from the first build.
bool _initialised = false;
/// Formats a [DateTime] to a readable time string (h:mm a).
String _formatTime(DateTime? time) {
if (time == null) return '';
return DateFormat('h:mm a').format(time);
}
/// Toggles the expanded / collapsed state for the shift with [shiftId].
void _toggleShift(String shiftId) {
setState(() {
if (_expandedShiftIds.contains(shiftId)) {
_expandedShiftIds.remove(shiftId);
} else {
_expandedShiftIds.add(shiftId);
}
});
}
/// Seeds [_expandedShiftIds] with all current shift IDs on first build,
/// and adds any new shift IDs when the widget is rebuilt with new data.
void _ensureInitialised() {
if (!_initialised) {
_expandedShiftIds.addAll(
widget.shifts.map((ShiftWithWorkers s) => s.shiftId),
);
_initialised = true;
return;
}
// Add any new shift IDs that arrived after initial build.
for (final ShiftWithWorkers shift in widget.shifts) {
if (!_expandedShiftIds.contains(shift.shiftId)) {
_expandedShiftIds.add(shift.shiftId);
}
}
}
@override
void didUpdateWidget(covariant CoverageShiftList oldWidget) {
super.didUpdateWidget(oldWidget);
// Add newly-appeared shift IDs so they start expanded.
for (final ShiftWithWorkers shift in widget.shifts) {
if (!oldWidget.shifts.any(
(ShiftWithWorkers old) => old.shiftId == shift.shiftId,
)) {
_expandedShiftIds.add(shift.shiftId);
}
}
}
@override
Widget build(BuildContext context) {
_ensureInitialised();
final TranslationsClientCoverageEn l10n = context.t.client_coverage;
if (shifts.isEmpty) {
if (widget.shifts.isEmpty) {
return Container(
padding: const EdgeInsets.all(UiConstants.space8),
width: double.infinity,
@@ -57,66 +117,137 @@ class CoverageShiftList extends StatelessWidget {
}
return Column(
children: shifts.map((ShiftWithWorkers shift) {
children: widget.shifts.map((ShiftWithWorkers shift) {
final int coveragePercent = shift.requiredWorkerCount > 0
? ((shift.assignedWorkerCount / shift.requiredWorkerCount) * 100)
.round()
: 0;
// Per-shift worker status counts.
final int onSite = shift.assignedWorkers
.where(
(AssignedWorker w) => w.status == AssignmentStatus.checkedIn,
)
.length;
final int enRoute = shift.assignedWorkers
.where(
(AssignedWorker w) =>
w.status == AssignmentStatus.accepted && w.checkInAt == null,
)
.length;
final int lateCount = shift.assignedWorkers
.where(
(AssignedWorker w) => w.status == AssignmentStatus.noShow,
)
.length;
final bool isExpanded = _expandedShiftIds.contains(shift.shiftId);
return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.bgPopup,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
borderRadius: UiConstants.radius2xl,
boxShadow: <BoxShadow>[
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
children: <Widget>[
ShiftHeader(
title: shift.roleName,
location: '', // V2 API does not return location on coverage
startTime: _formatTime(shift.timeRange.startsAt),
current: shift.assignedWorkerCount,
total: shift.requiredWorkerCount,
coveragePercent: coveragePercent,
shiftId: shift.shiftId,
onSiteCount: onSite,
enRouteCount: enRoute,
lateCount: lateCount,
isExpanded: isExpanded,
onToggle: () => _toggleShift(shift.shiftId),
),
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: _buildWorkerSection(shift, l10n),
crossFadeState: isExpanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
if (shift.assignedWorkers.isNotEmpty)
Padding(
padding: const EdgeInsets.all(UiConstants.space3),
child: Column(
children: shift.assignedWorkers
.map<Widget>((AssignedWorker worker) {
final bool isLast =
worker == shift.assignedWorkers.last;
return Padding(
padding: EdgeInsets.only(
bottom: isLast ? 0 : UiConstants.space2,
),
child: WorkerRow(
worker: worker,
shiftStartTime:
_formatTime(shift.timeRange.startsAt),
),
);
}).toList(),
),
)
else
Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Text(
l10n.no_workers_assigned,
style: UiTypography.body3r.copyWith(
color: UiColors.mutedForeground,
),
),
),
],
),
);
}).toList(),
);
}
/// Builds the expanded worker section for a shift including divider.
Widget _buildWorkerSection(
ShiftWithWorkers shift,
TranslationsClientCoverageEn l10n,
) {
if (shift.assignedWorkers.isEmpty) {
return Column(
children: <Widget>[
const Divider(height: 1, color: UiColors.border),
Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Text(
l10n.no_workers_assigned,
style: UiTypography.body3r.copyWith(
color: UiColors.mutedForeground,
),
),
),
],
);
}
return Column(
children: <Widget>[
const Divider(height: 1, color: UiColors.border),
Padding(
padding: const EdgeInsets.all(UiConstants.space3),
child: Column(
children:
shift.assignedWorkers.map<Widget>((AssignedWorker worker) {
final bool isLast = worker == shift.assignedWorkers.last;
return Padding(
padding: EdgeInsets.only(
bottom: isLast ? 0 : UiConstants.space2,
),
child: WorkerRow(
worker: worker,
shiftStartTime: _formatTime(shift.timeRange.startsAt),
showRateButton:
worker.status == AssignmentStatus.checkedIn ||
worker.status == AssignmentStatus.checkedOut ||
worker.status == AssignmentStatus.completed,
showCancelButton:
DateTime.now().isAfter(shift.timeRange.startsAt) &&
(worker.status == AssignmentStatus.noShow ||
worker.status == AssignmentStatus.assigned ||
worker.status == AssignmentStatus.accepted),
onRate: () => WorkerReviewSheet.show(
context,
worker: worker,
),
onCancel: () => CancelLateWorkerSheet.show(
context,
worker: worker,
),
),
);
}).toList(),
),
),
],
);
}
}

View File

@@ -1,64 +0,0 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Stat card displaying an icon, value, and label with an accent color.
class CoverageStatCard extends StatelessWidget {
/// Creates a [CoverageStatCard].
const CoverageStatCard({
required this.icon,
required this.label,
required this.value,
required this.color,
super.key,
});
/// The icon to display.
final IconData icon;
/// The label text describing the stat.
final String label;
/// The numeric value to display.
final String value;
/// The accent color for the card border, icon, and text.
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: color.withAlpha(10),
borderRadius: UiConstants.radiusLg,
border: Border.all(
color: color,
width: 0.5,
),
),
child: Row(
spacing: UiConstants.space2,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Icon(
icon,
color: color,
size: UiConstants.space6,
),
Text(
value,
style: UiTypography.title1b.copyWith(
color: color,
),
),
Text(
label,
style: UiTypography.body3r.copyWith(
color: color,
),
),
],
),
);
}
}

View File

@@ -2,72 +2,176 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Displays coverage percentage and worker ratio in the app bar header.
/// Displays overall coverage statistics in the SliverAppBar expanded header.
///
/// Shows the coverage percentage, a progress bar, and real-time worker
/// status counts (on site, en route, late) on a primary blue gradient
/// background with a semi-transparent white container.
class CoverageStatsHeader extends StatelessWidget {
/// Creates a [CoverageStatsHeader].
/// Creates a [CoverageStatsHeader] with coverage and worker status data.
const CoverageStatsHeader({
required this.coveragePercent,
required this.totalConfirmed,
required this.totalNeeded,
required this.totalCheckedIn,
required this.totalEnRoute,
required this.totalLate,
super.key,
});
/// The current coverage percentage.
/// The current overall coverage percentage (0-100).
final double coveragePercent;
/// The number of confirmed workers.
final int totalConfirmed;
/// The total number of workers needed.
/// The total number of workers needed for full coverage.
final int totalNeeded;
/// The number of workers currently checked in and on site.
final int totalCheckedIn;
/// The number of workers currently en route.
final int totalEnRoute;
/// The number of workers currently marked as late.
final int totalLate;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.primaryForeground.withOpacity(0.1),
borderRadius: UiConstants.radiusLg,
color: UiColors.primaryForeground.withValues(alpha: 0.12),
borderRadius: UiConstants.radiusXl,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Column(
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
context.t.client_coverage.page.coverage_status,
style: UiTypography.body2r.copyWith(
color: UiColors.primaryForeground.withOpacity(0.7),
),
),
Text(
'${coveragePercent.toStringAsFixed(0)}%',
style: UiTypography.display1b.copyWith(
color: UiColors.primaryForeground,
),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
context.t.client_coverage.page.workers,
style: UiTypography.body2r.copyWith(
color: UiColors.primaryForeground.withOpacity(0.7),
),
),
Text(
'$totalConfirmed/$totalNeeded',
style: UiTypography.title2m.copyWith(
color: UiColors.primaryForeground,
),
Expanded(
child: _buildCoverageColumn(context),
),
_buildStatusColumn(context),
],
),
const SizedBox(height: UiConstants.space3),
_buildProgressBar(),
],
),
);
}
/// Builds the left column with the "Overall Coverage" label and percentage.
Widget _buildCoverageColumn(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
context.t.client_coverage.page.overall_coverage,
style: UiTypography.body3r.copyWith(
color: UiColors.primaryForeground.withValues(alpha: 0.6),
),
),
Text(
'${coveragePercent.toStringAsFixed(0)}%',
style: UiTypography.display1b.copyWith(
color: UiColors.primaryForeground,
),
),
],
);
}
/// Builds the right column with on-site, en-route, and late stat items.
Widget _buildStatusColumn(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
_buildStatRow(
context: context,
value: totalCheckedIn,
label: context.t.client_coverage.stats.on_site,
valueColor: UiColors.primaryForeground,
),
const SizedBox(height: UiConstants.space1),
_buildStatRow(
context: context,
value: totalEnRoute,
label: context.t.client_coverage.stats.en_route,
valueColor: UiColors.accent,
),
const SizedBox(height: UiConstants.space1),
_buildStatRow(
context: context,
value: totalLate,
label: context.t.client_coverage.stats.late,
valueColor: UiColors.tagError,
),
],
);
}
/// Builds a single stat row with a colored number and a muted label.
Widget _buildStatRow({
required BuildContext context,
required int value,
required String label,
required Color valueColor,
}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
value.toString(),
style: UiTypography.title2b.copyWith(
color: valueColor,
),
),
const SizedBox(width: UiConstants.space2),
Text(
label,
style: UiTypography.body4m.copyWith(
color: UiColors.primaryForeground.withValues(alpha: 0.6),
),
),
],
);
}
/// Builds the horizontal progress bar indicating coverage fill.
Widget _buildProgressBar() {
final double clampedFraction =
(coveragePercent / 100).clamp(0.0, 1.0);
return ClipRRect(
borderRadius: UiConstants.radiusFull,
child: SizedBox(
height: 8,
width: double.infinity,
child: Stack(
children: <Widget>[
Container(
decoration: BoxDecoration(
color: UiColors.primaryForeground.withValues(alpha: 0.2),
borderRadius: UiConstants.radiusFull,
),
),
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: clampedFraction,
child: Container(
decoration: BoxDecoration(
color: UiColors.primaryForeground,
borderRadius: UiConstants.radiusFull,
),
),
),
],
),
),
);
}
}

View File

@@ -2,38 +2,54 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Alert widget for displaying late workers warning.
/// Alert banner displayed when workers are running late.
///
/// Shows a warning banner when workers are running late.
/// Renders a solid red container with a warning icon, late worker count,
/// and auto-backup status message in white text.
class LateWorkersAlert extends StatelessWidget {
/// Creates a [LateWorkersAlert].
/// Creates a [LateWorkersAlert] with the given [lateCount].
const LateWorkersAlert({
required this.lateCount,
super.key,
});
/// The number of late workers.
/// The number of workers currently marked as late.
final int lateCount;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space3),
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space3,
),
decoration: BoxDecoration(
color: UiColors.destructive.withValues(alpha: 0.1),
color: UiColors.destructive,
borderRadius: UiConstants.radiusLg,
border: Border.all(
color: UiColors.destructive,
width: 0.5,
),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.destructive.withValues(alpha: 0.2),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Row(
spacing: UiConstants.space4,
children: <Widget>[
const Icon(
UiIcons.warning,
color: UiColors.destructive,
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
UiIcons.warning,
color: Colors.white,
size: 16,
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -41,12 +57,14 @@ class LateWorkersAlert extends StatelessWidget {
Text(
context.t.client_coverage.alert
.workers_running_late(n: lateCount, count: lateCount),
style: UiTypography.body1b.textError,
style: UiTypography.body1b.copyWith(
color: Colors.white,
),
),
Text(
context.t.client_coverage.alert.auto_backup_searching,
style: UiTypography.body3r.copyWith(
color: UiColors.textError.withValues(alpha: 0.7),
color: Colors.white.withValues(alpha: 0.8),
),
),
],

View File

@@ -1,124 +1,198 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_badge.dart';
/// Header section for a shift card showing title, location, time, and coverage.
/// Tappable header for a collapsible shift card.
///
/// Displays a status dot colour-coded by coverage, the shift title and time,
/// a filled/total badge, a linear progress bar, and per-shift worker summary
/// counts (on site, en route, late). Tapping anywhere triggers [onToggle].
class ShiftHeader extends StatelessWidget {
/// Creates a [ShiftHeader].
const ShiftHeader({
required this.title,
required this.location,
required this.startTime,
required this.current,
required this.total,
required this.coveragePercent,
required this.shiftId,
required this.onSiteCount,
required this.enRouteCount,
required this.lateCount,
required this.isExpanded,
required this.onToggle,
super.key,
});
/// The shift title.
/// The shift role or title.
final String title;
/// The shift location.
final String location;
/// The formatted shift start time.
/// Formatted shift start time (e.g. "8:00 AM").
final String startTime;
/// Current number of assigned workers.
final int current;
/// Total workers needed for the shift.
/// Total workers required for the shift.
final int total;
/// Coverage percentage (0-100+).
final int coveragePercent;
/// The shift identifier.
/// Unique shift identifier.
final String shiftId;
/// Number of workers currently on site (checked in).
final int onSiteCount;
/// Number of workers en route (accepted but not checked in).
final int enRouteCount;
/// Number of workers marked as late / no-show.
final int lateCount;
/// Whether the shift card is currently expanded to show workers.
final bool isExpanded;
/// Callback invoked when the header is tapped to expand or collapse.
final VoidCallback onToggle;
/// Returns the status colour based on [coveragePercent].
///
/// Green for >= 100 %, yellow for >= 80 %, red otherwise.
Color _statusColor() {
if (coveragePercent >= 100) {
return UiColors.textSuccess;
} else if (coveragePercent >= 80) {
return UiColors.textWarning;
}
return UiColors.destructive;
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: const BoxDecoration(
color: UiColors.muted,
border: Border(
bottom: BorderSide(
color: UiColors.border,
),
),
),
child: Row(
spacing: UiConstants.space4,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space2,
final Color statusColor = _statusColor();
final TranslationsClientCoverageStatsEn stats =
context.t.client_coverage.stats;
final double fillFraction =
total > 0 ? (current / total).clamp(0.0, 1.0) : 0.0;
return InkWell(
onTap: onToggle,
child: Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Row 1: status dot, title + time, badge, chevron.
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Row(
spacing: UiConstants.space2,
children: <Widget>[
Container(
width: UiConstants.space2,
height: UiConstants.space2,
decoration: const BoxDecoration(
color: UiColors.primary,
shape: BoxShape.circle,
),
// Status dot.
Padding(
padding: const EdgeInsets.only(top: UiConstants.space1),
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: statusColor,
shape: BoxShape.circle,
),
Text(
title,
style: UiTypography.body1b.textPrimary,
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
spacing: UiConstants.space1,
children: <Widget>[
const Icon(
UiIcons.mapPin,
size: UiConstants.space3,
color: UiColors.iconSecondary,
),
Expanded(
child: Text(
location,
style: UiTypography.body3r.textSecondary,
overflow: TextOverflow.ellipsis,
)),
],
),
Row(
spacing: UiConstants.space1,
children: <Widget>[
const Icon(
UiIcons.clock,
size: UiConstants.space3,
color: UiColors.iconSecondary,
),
Text(
startTime,
style: UiTypography.body3r.textSecondary,
),
],
),
],
const SizedBox(width: UiConstants.space4),
// Title and start time.
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: UiTypography.body1b.textPrimary,
),
const SizedBox(height: UiConstants.space1),
Row(
children: <Widget>[
const Icon(
UiIcons.clock,
size: 10,
color: UiColors.textSecondary,
),
const SizedBox(width: 4),
Text(
startTime,
style: UiTypography.body3r.copyWith(
color: UiColors.textSecondary,
),
),
],
),
],
),
),
// Coverage badge.
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1,
),
decoration: BoxDecoration(
color: statusColor.withAlpha(26),
borderRadius: UiConstants.radiusSm,
),
child: Text(
'$current/$total',
style: UiTypography.body3b.copyWith(color: statusColor),
),
),
const SizedBox(width: UiConstants.space2),
// Expand / collapse chevron.
Icon(
isExpanded ? UiIcons.chevronUp : UiIcons.chevronDown,
size: 16,
color: UiColors.textSecondary,
),
],
),
),
CoverageBadge(
current: current,
total: total,
coveragePercent: coveragePercent,
),
],
const SizedBox(height: UiConstants.space3),
// Progress bar.
ClipRRect(
borderRadius: UiConstants.radiusFull,
child: SizedBox(
height: 8,
width: double.infinity,
child: Stack(
children: <Widget>[
Container(
decoration: BoxDecoration(
color: UiColors.muted,
borderRadius: UiConstants.radiusFull,
),
),
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: fillFraction,
child: Container(
decoration: BoxDecoration(
color: statusColor,
borderRadius: UiConstants.radiusFull,
),
),
),
],
),
),
),
const SizedBox(height: UiConstants.space2),
// Summary text: on site / en route / late.
Text(
'$onSiteCount ${stats.on_site} · '
'$enRouteCount ${stats.en_route} · '
'$lateCount ${stats.late}',
style: UiTypography.body3r.textSecondary,
),
],
),
),
);
}

View File

@@ -0,0 +1,333 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:client_coverage/src/presentation/blocs/coverage_bloc.dart';
import 'package:client_coverage/src/presentation/blocs/coverage_event.dart';
import 'package:client_coverage/src/presentation/blocs/coverage_state.dart';
/// Semantic color for the "favorite" toggle, representing a pink/heart accent.
/// No matching token in [UiColors] — kept as a local constant intentionally.
const Color _kFavoriteColor = Color(0xFFE91E63);
/// Bottom sheet for submitting a worker review with rating, feedback, and flags.
class WorkerReviewSheet extends StatefulWidget {
const WorkerReviewSheet({required this.worker, super.key});
final AssignedWorker worker;
static void show(BuildContext context, {required AssignedWorker worker}) {
final CoverageBloc bloc = ReadContext(context).read<CoverageBloc>();
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(UiConstants.space4),
),
),
builder: (_) => BlocProvider<CoverageBloc>.value(
value: bloc,
child: WorkerReviewSheet(worker: worker),
),
);
}
@override
State<WorkerReviewSheet> createState() => _WorkerReviewSheetState();
}
class _WorkerReviewSheetState extends State<WorkerReviewSheet> {
int _rating = 0;
bool _isFavorite = false;
bool _isBlocked = false;
final Set<ReviewIssueFlag> _selectedFlags = <ReviewIssueFlag>{};
final TextEditingController _feedbackController = TextEditingController();
@override
void dispose() {
_feedbackController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final TranslationsClientCoverageReviewEn l10n =
context.t.client_coverage.review;
final List<String> ratingLabels = <String>[
l10n.rating_labels.poor,
l10n.rating_labels.fair,
l10n.rating_labels.good,
l10n.rating_labels.great,
l10n.rating_labels.excellent,
];
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
child: Padding(
padding: EdgeInsets.only(
left: UiConstants.space4,
right: UiConstants.space4,
top: UiConstants.space3,
bottom: MediaQuery.of(context).viewInsets.bottom + UiConstants.space4,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_buildDragHandle(),
const SizedBox(height: UiConstants.space4),
_buildHeader(context, l10n),
const SizedBox(height: UiConstants.space5),
Flexible(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_buildStarRating(ratingLabels),
const SizedBox(height: UiConstants.space5),
_buildToggles(l10n),
const SizedBox(height: UiConstants.space5),
_buildIssueFlags(l10n),
const SizedBox(height: UiConstants.space4),
UiTextField(
hintText: l10n.feedback_placeholder,
maxLines: 3,
controller: _feedbackController,
),
],
),
),
),
const SizedBox(height: UiConstants.space4),
BlocBuilder<CoverageBloc, CoverageState>(
buildWhen: (CoverageState previous, CoverageState current) =>
previous.writeStatus != current.writeStatus,
builder: (BuildContext context, CoverageState state) {
return UiButton.primary(
text: l10n.submit,
fullWidth: true,
isLoading:
state.writeStatus == CoverageWriteStatus.submitting,
onPressed: _rating > 0 ? () => _onSubmit(context) : null,
);
},
),
const SizedBox(height: UiConstants.space24),
],
),
),
);
}
Widget _buildDragHandle() {
return Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: UiColors.textDisabled,
borderRadius: BorderRadius.circular(2),
),
),
);
}
Widget _buildHeader(
BuildContext context,
TranslationsClientCoverageReviewEn l10n,
) {
return Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(widget.worker.fullName, style: UiTypography.title1b),
Text(l10n.title, style: UiTypography.body2r.textSecondary),
],
),
),
IconButton(
icon: const Icon(UiIcons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
);
}
Widget _buildStarRating(List<String> ratingLabels) {
return Column(
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List<Widget>.generate(5, (int index) {
final bool isFilled = index < _rating;
return GestureDetector(
onTap: () => setState(() => _rating = index + 1),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space1,
),
child: Icon(
UiIcons.star,
size: UiConstants.space8,
color: isFilled
? UiColors.textWarning
: UiColors.textDisabled,
),
),
);
}),
),
if (_rating > 0) ...<Widget>[
const SizedBox(height: UiConstants.space2),
Text(
ratingLabels[_rating - 1],
style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center,
),
],
],
);
}
Widget _buildToggles(TranslationsClientCoverageReviewEn l10n) {
return Row(
children: <Widget>[
Expanded(
child: _buildToggleButton(
icon: Icons.favorite,
label: l10n.favorite_label,
isActive: _isFavorite,
activeColor: _kFavoriteColor,
onTap: () => setState(() => _isFavorite = !_isFavorite),
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: _buildToggleButton(
icon: UiIcons.ban,
label: l10n.block_label,
isActive: _isBlocked,
activeColor: UiColors.destructive,
onTap: () => setState(() => _isBlocked = !_isBlocked),
),
),
],
);
}
Widget _buildToggleButton({
required IconData icon,
required String label,
required bool isActive,
required Color activeColor,
required VoidCallback onTap,
}) {
final Color bgColor =
isActive ? activeColor.withAlpha(26) : UiColors.muted;
final Color fgColor =
isActive ? activeColor : UiColors.textDisabled;
return InkWell(
onTap: onTap,
borderRadius: UiConstants.radiusMd,
child: Container(
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space3,
),
decoration: BoxDecoration(
color: bgColor,
borderRadius: UiConstants.radiusMd,
border: isActive ? Border.all(color: activeColor, width: 0.5) : null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(icon, size: UiConstants.space5, color: fgColor),
const SizedBox(width: UiConstants.space2),
Text(
label,
style: UiTypography.body2r.copyWith(color: fgColor),
),
],
),
),
);
}
Widget _buildIssueFlags(TranslationsClientCoverageReviewEn l10n) {
final Map<ReviewIssueFlag, String> flagLabels =
<ReviewIssueFlag, String>{
ReviewIssueFlag.late: l10n.issue_flags.late,
ReviewIssueFlag.uniform: l10n.issue_flags.uniform,
ReviewIssueFlag.misconduct: l10n.issue_flags.misconduct,
ReviewIssueFlag.noShow: l10n.issue_flags.no_show,
ReviewIssueFlag.attitude: l10n.issue_flags.attitude,
ReviewIssueFlag.performance: l10n.issue_flags.performance,
ReviewIssueFlag.leftEarly: l10n.issue_flags.left_early,
};
return Wrap(
spacing: UiConstants.space2,
runSpacing: UiConstants.space2,
children: ReviewIssueFlag.values.map((ReviewIssueFlag flag) {
final bool isSelected = _selectedFlags.contains(flag);
final String label = flagLabels[flag] ?? flag.value;
return FilterChip(
label: Text(label),
selected: isSelected,
onSelected: (bool selected) {
setState(() {
if (selected) {
_selectedFlags.add(flag);
} else {
_selectedFlags.remove(flag);
}
});
},
selectedColor: UiColors.primary,
labelStyle: isSelected
? UiTypography.body3r.copyWith(color: UiColors.primaryForeground)
: UiTypography.body3r.textSecondary,
backgroundColor: UiColors.muted,
checkmarkColor: UiColors.primaryForeground,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.space5),
),
side: isSelected
? const BorderSide(color: UiColors.primary)
: BorderSide.none,
);
}).toList(),
);
}
void _onSubmit(BuildContext context) {
ReadContext(context).read<CoverageBloc>().add(
CoverageSubmitReviewRequested(
staffId: widget.worker.staffId,
rating: _rating,
assignmentId: widget.worker.assignmentId,
feedback: _feedbackController.text.trim().isNotEmpty
? _feedbackController.text.trim()
: null,
issueFlags: _selectedFlags.isNotEmpty
? _selectedFlags
.map((ReviewIssueFlag f) => f.value)
.toList()
: null,
markAsFavorite: _isFavorite ? true : null,
),
);
Navigator.of(context).pop();
}
}

View File

@@ -10,6 +10,10 @@ class WorkerRow extends StatelessWidget {
const WorkerRow({
required this.worker,
required this.shiftStartTime,
this.showRateButton = false,
this.showCancelButton = false,
this.onRate,
this.onCancel,
super.key,
});
@@ -19,6 +23,18 @@ class WorkerRow extends StatelessWidget {
/// The formatted shift start time.
final String shiftStartTime;
/// Whether to show the rate action button.
final bool showRateButton;
/// Whether to show the cancel action button.
final bool showCancelButton;
/// Callback invoked when the rate button is tapped.
final VoidCallback? onRate;
/// Callback invoked when the cancel button is tapped.
final VoidCallback? onCancel;
/// Formats a [DateTime] to a readable time string (h:mm a).
String _formatCheckInTime(DateTime? time) {
if (time == null) return '';
@@ -35,10 +51,6 @@ class WorkerRow extends StatelessWidget {
Color textColor;
IconData icon;
String statusText;
Color badgeBg;
Color badgeText;
Color badgeBorder;
String badgeLabel;
switch (worker.status) {
case AssignmentStatus.checkedIn:
@@ -50,10 +62,6 @@ class WorkerRow extends StatelessWidget {
statusText = l10n.status_checked_in_at(
time: _formatCheckInTime(worker.checkInAt),
);
badgeBg = UiColors.textSuccess.withAlpha(40);
badgeText = UiColors.textSuccess;
badgeBorder = badgeText;
badgeLabel = l10n.status_on_site;
case AssignmentStatus.accepted:
if (worker.checkInAt == null) {
bg = UiColors.textWarning.withAlpha(26);
@@ -62,10 +70,6 @@ class WorkerRow extends StatelessWidget {
textColor = UiColors.textWarning;
icon = UiIcons.clock;
statusText = l10n.status_en_route_expected(time: shiftStartTime);
badgeBg = UiColors.textWarning.withAlpha(40);
badgeText = UiColors.textWarning;
badgeBorder = badgeText;
badgeLabel = l10n.status_en_route;
} else {
bg = UiColors.muted.withAlpha(26);
border = UiColors.border;
@@ -73,10 +77,6 @@ class WorkerRow extends StatelessWidget {
textColor = UiColors.textSecondary;
icon = UiIcons.success;
statusText = l10n.status_confirmed;
badgeBg = UiColors.textSecondary.withAlpha(40);
badgeText = UiColors.textSecondary;
badgeBorder = badgeText;
badgeLabel = l10n.status_confirmed;
}
case AssignmentStatus.noShow:
bg = UiColors.destructive.withAlpha(26);
@@ -85,10 +85,6 @@ class WorkerRow extends StatelessWidget {
textColor = UiColors.destructive;
icon = UiIcons.warning;
statusText = l10n.status_no_show;
badgeBg = UiColors.destructive.withAlpha(40);
badgeText = UiColors.destructive;
badgeBorder = badgeText;
badgeLabel = l10n.status_no_show;
case AssignmentStatus.checkedOut:
bg = UiColors.muted.withAlpha(26);
border = UiColors.border;
@@ -96,10 +92,6 @@ class WorkerRow extends StatelessWidget {
textColor = UiColors.textSecondary;
icon = UiIcons.success;
statusText = l10n.status_checked_out;
badgeBg = UiColors.textSecondary.withAlpha(40);
badgeText = UiColors.textSecondary;
badgeBorder = badgeText;
badgeLabel = l10n.status_done;
case AssignmentStatus.completed:
bg = UiColors.iconSuccess.withAlpha(26);
border = UiColors.iconSuccess;
@@ -107,10 +99,6 @@ class WorkerRow extends StatelessWidget {
textColor = UiColors.textSuccess;
icon = UiIcons.success;
statusText = l10n.status_completed;
badgeBg = UiColors.textSuccess.withAlpha(40);
badgeText = UiColors.textSuccess;
badgeBorder = badgeText;
badgeLabel = l10n.status_completed;
case AssignmentStatus.assigned:
case AssignmentStatus.swapRequested:
case AssignmentStatus.cancelled:
@@ -121,10 +109,6 @@ class WorkerRow extends StatelessWidget {
textColor = UiColors.textSecondary;
icon = UiIcons.clock;
statusText = worker.status.value;
badgeBg = UiColors.textSecondary.withAlpha(40);
badgeText = UiColors.textSecondary;
badgeBorder = badgeText;
badgeLabel = worker.status.value;
}
return Container(
@@ -197,23 +181,25 @@ class WorkerRow extends StatelessWidget {
Column(
spacing: UiConstants.space2,
children: <Widget>[
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1 / 2,
),
decoration: BoxDecoration(
color: badgeBg,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: badgeBorder, width: 0.5),
),
child: Text(
badgeLabel,
style: UiTypography.footnote2b.copyWith(
color: badgeText,
if (showRateButton && onRate != null)
GestureDetector(
onTap: onRate,
child: UiChip(
label: l10n.actions.rate,
size: UiChipSize.small,
leadingIcon: UiIcons.star,
),
),
if (showCancelButton && onCancel != null)
GestureDetector(
onTap: onCancel,
child: UiChip(
label: l10n.actions.cancel,
size: UiChipSize.small,
leadingIcon: UiIcons.close,
variant: UiChipVariant.destructive,
),
),
),
],
),
],

View File

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

View File

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

View File

@@ -1,25 +1,21 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:client_home/src/presentation/blocs/client_home_bloc.dart';
import 'package:client_home/src/presentation/blocs/client_home_event.dart';
import 'package:client_home/src/presentation/blocs/client_home_state.dart';
import 'package:client_home/src/presentation/widgets/dashboard_widget_builder.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
/// Widget that displays the home dashboard in edit mode with drag-and-drop support.
///
/// Allows users to reorder and rearrange dashboard widgets.
class ClientHomeEditModeBody extends StatelessWidget {
/// Creates a [ClientHomeEditModeBody].
const ClientHomeEditModeBody({required this.state, super.key});
/// The current home state.
final ClientHomeState state;
/// Creates a [ClientHomeEditModeBody].
const ClientHomeEditModeBody({
required this.state,
super.key,
});
@override
Widget build(BuildContext context) {
return ReorderableListView(
@@ -30,18 +26,15 @@ class ClientHomeEditModeBody extends StatelessWidget {
100,
),
onReorder: (int oldIndex, int newIndex) {
BlocProvider.of<ClientHomeBloc>(context)
.add(ClientHomeWidgetReordered(oldIndex, newIndex));
BlocProvider.of<ClientHomeBloc>(
context,
).add(ClientHomeWidgetReordered(oldIndex, newIndex));
},
children: state.widgetOrder.map((String id) {
return Container(
key: ValueKey<String>(id),
margin: const EdgeInsets.only(bottom: UiConstants.space4),
child: DashboardWidgetBuilder(
id: id,
state: state,
isEditMode: true,
),
child: DashboardWidgetBuilder(id: id, state: state, isEditMode: true),
);
}).toList(),
);

View File

@@ -10,9 +10,9 @@ class ClientHomeHeaderSkeleton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return UiShimmer(
return const UiShimmer(
child: Padding(
padding: const EdgeInsets.fromLTRB(
padding: EdgeInsets.fromLTRB(
UiConstants.space4,
UiConstants.space4,
UiConstants.space4,
@@ -23,11 +23,11 @@ class ClientHomeHeaderSkeleton extends StatelessWidget {
children: <Widget>[
Row(
children: <Widget>[
const UiShimmerCircle(size: UiConstants.space10),
const SizedBox(width: UiConstants.space3),
UiShimmerCircle(size: UiConstants.space10),
SizedBox(width: UiConstants.space3),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const <Widget>[
children: <Widget>[
UiShimmerLine(width: 80, height: 12),
SizedBox(height: UiConstants.space1),
UiShimmerLine(width: 120, height: 16),
@@ -37,7 +37,7 @@ class ClientHomeHeaderSkeleton extends StatelessWidget {
),
Row(
spacing: UiConstants.space2,
children: const <Widget>[
children: <Widget>[
UiShimmerBox(width: 36, height: 36),
UiShimmerBox(width: 36, height: 36),
],

View File

@@ -10,9 +10,9 @@ class ReorderSectionSkeleton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
return const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const <Widget>[
children: <Widget>[
UiShimmerSectionHeader(),
SizedBox(height: UiConstants.space2),
SizedBox(

View File

@@ -20,7 +20,8 @@ dependencies:
path: ../../../design_system
core_localization:
path: ../../../core_localization
krow_domain: ^0.0.1
krow_domain:
path: ../../../domain
krow_core:
path: ../../../core

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ class HubsPageSkeleton extends StatelessWidget {
Widget build(BuildContext context) {
return UiShimmer(
child: Column(
children: List.generate(5, (int index) {
children: List<Widget>.generate(5, (int index) {
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: Container(
@@ -23,7 +23,7 @@ class HubsPageSkeleton extends StatelessWidget {
),
padding: const EdgeInsets.all(UiConstants.space4),
child: Row(
children: [
children: <Widget>[
// Leading icon placeholder
UiShimmerBox(
width: 52,
@@ -35,7 +35,7 @@ class HubsPageSkeleton extends StatelessWidget {
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
children: <Widget>[
UiShimmerLine(width: 160, height: 16),
SizedBox(height: UiConstants.space2),
UiShimmerLine(width: 200, height: 12),

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_rapid_order_usecase.dart';
import 'domain/usecases/create_recurring_order_usecase.dart';
import 'domain/usecases/get_hubs_usecase.dart';
import 'domain/usecases/get_managers_by_hub_usecase.dart';
import 'domain/usecases/get_order_details_for_reorder_usecase.dart';
import 'domain/usecases/get_roles_by_vendor_usecase.dart';
import 'domain/usecases/get_vendors_usecase.dart';
import 'domain/usecases/parse_rapid_order_usecase.dart';
import 'domain/usecases/transcribe_rapid_order_usecase.dart';
import 'presentation/blocs/index.dart';
@@ -46,7 +50,7 @@ class ClientCreateOrderModule extends Module {
),
);
// UseCases
// Command UseCases (order creation)
i.addLazySingleton(CreateOneTimeOrderUseCase.new);
i.addLazySingleton(CreatePermanentOrderUseCase.new);
i.addLazySingleton(CreateRecurringOrderUseCase.new);
@@ -55,6 +59,12 @@ class ClientCreateOrderModule extends Module {
i.addLazySingleton(ParseRapidOrderTextToOrderUseCase.new);
i.addLazySingleton(GetOrderDetailsForReorderUseCase.new);
// Query UseCases (reference data loading)
i.addLazySingleton(GetVendorsUseCase.new);
i.addLazySingleton(GetRolesByVendorUseCase.new);
i.addLazySingleton(GetHubsUseCase.new);
i.addLazySingleton(GetManagersByHubUseCase.new);
// BLoCs
i.add<RapidOrderBloc>(
() => RapidOrderBloc(
@@ -63,15 +73,36 @@ class ClientCreateOrderModule extends Module {
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>(
() => PermanentOrderBloc(
i.get<CreatePermanentOrderUseCase>(),
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

View File

@@ -1,15 +1,69 @@
import 'package:krow_core/core.dart';
/// Arguments for the [CreateOneTimeOrderUseCase].
///
/// Wraps the V2 API payload map for a one-time order.
class OneTimeOrderArguments extends UseCaseArgument {
/// Creates a [OneTimeOrderArguments] with the given [payload].
const OneTimeOrderArguments({required this.payload});
/// A single position entry for a one-time order submission.
class OneTimeOrderPositionArgument extends UseCaseArgument {
/// Creates a [OneTimeOrderPositionArgument].
const OneTimeOrderPositionArgument({
required this.roleId,
required this.workerCount,
required this.startTime,
required this.endTime,
this.roleName,
this.lunchBreak,
});
/// The V2 API payload map.
final Map<String, dynamic> payload;
/// The role ID for this position.
final String roleId;
/// Human-readable role name, if available.
final String? roleName;
/// Number of workers needed for this position.
final int workerCount;
/// Shift start time in HH:mm format.
final String startTime;
/// Shift end time in HH:mm format.
final String endTime;
/// Break duration label (e.g. `'MIN_30'`, `'NO_BREAK'`), if set.
final String? lunchBreak;
@override
List<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].
///
/// Wraps the V2 API payload map for a permanent order.
class PermanentOrderArguments {
/// Creates a [PermanentOrderArguments] with the given [payload].
const PermanentOrderArguments({required this.payload});
import 'package:krow_core/core.dart';
/// The V2 API payload map.
final Map<String, dynamic> payload;
/// A single position entry for a permanent order submission.
class PermanentOrderPositionArgument extends UseCaseArgument {
/// Creates a [PermanentOrderPositionArgument].
const PermanentOrderPositionArgument({
required this.roleId,
required this.workerCount,
required this.startTime,
required this.endTime,
this.roleName,
});
/// The role ID for this position.
final String roleId;
/// Human-readable role name, if available.
final String? roleName;
/// Number of workers needed for this position.
final int workerCount;
/// Shift start time in HH:mm format.
final String startTime;
/// Shift end time in HH:mm format.
final String endTime;
@override
List<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].
///
/// Wraps the V2 API payload map for a recurring order.
class RecurringOrderArguments {
/// Creates a [RecurringOrderArguments] with the given [payload].
const RecurringOrderArguments({required this.payload});
import 'package:krow_core/core.dart';
/// The V2 API payload map.
final Map<String, dynamic> payload;
/// A single position entry for a recurring order submission.
class RecurringOrderPositionArgument extends UseCaseArgument {
/// Creates a [RecurringOrderPositionArgument].
const RecurringOrderPositionArgument({
required this.roleId,
required this.workerCount,
required this.startTime,
required this.endTime,
this.roleName,
});
/// The role ID for this position.
final String roleId;
/// Human-readable role name, if available.
final String? roleName;
/// Number of workers needed for this position.
final int workerCount;
/// Shift start time in HH:mm format.
final String startTime;
/// Shift end time in HH:mm format.
final String endTime;
@override
List<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.
///
/// Delegates the V2 API payload to the repository.
/// Builds the V2 API payload from typed [OneTimeOrderArguments] and
/// delegates submission to the repository. Payload construction (date
/// formatting, position mapping, break-minutes conversion) is business
/// logic that belongs here, not in the BLoC.
class CreateOneTimeOrderUseCase
implements UseCase<OneTimeOrderArguments, void> {
/// Creates a [CreateOneTimeOrderUseCase].
const CreateOneTimeOrderUseCase(this._repository);
/// The create-order repository.
final ClientCreateOrderRepositoryInterface _repository;
@override
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 '../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.
///
/// Delegates the V2 API payload to the repository.
class CreatePermanentOrderUseCase {
/// Builds the V2 API payload from typed [PermanentOrderArguments] and
/// delegates submission to the repository. Payload construction (date
/// formatting, day-of-week mapping, position mapping) is business
/// logic that belongs here, not in the BLoC.
class CreatePermanentOrderUseCase
implements UseCase<PermanentOrderArguments, void> {
/// Creates a [CreatePermanentOrderUseCase].
const CreatePermanentOrderUseCase(this._repository);
/// The create-order repository.
final ClientCreateOrderRepositoryInterface _repository;
/// Executes the use case with the given [args].
Future<void> call(PermanentOrderArguments args) {
return _repository.createPermanentOrder(args.payload);
@override
Future<void> call(PermanentOrderArguments input) {
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 '../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.
///
/// Delegates the V2 API payload to the repository.
class CreateRecurringOrderUseCase {
/// Builds the V2 API payload from typed [RecurringOrderArguments] and
/// delegates submission to the repository. Payload construction (date
/// formatting, recurrence-day mapping, position mapping) is business
/// logic that belongs here, not in the BLoC.
class CreateRecurringOrderUseCase
implements UseCase<RecurringOrderArguments, void> {
/// Creates a [CreateRecurringOrderUseCase].
const CreateRecurringOrderUseCase(this._repository);
/// The create-order repository.
final ClientCreateOrderRepositoryInterface _repository;
/// Executes the use case with the given [args].
Future<void> call(RecurringOrderArguments args) {
return _repository.createRecurringOrder(args.payload);
@override
Future<void> call(RecurringOrderArguments input) {
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_manager.dart';
import 'package:client_create_order/src/domain/models/order_role.dart';
import 'package:client_create_order/src/domain/repositories/client_order_query_repository_interface.dart';
import 'package:client_create_order/src/domain/usecases/create_one_time_order_usecase.dart';
import 'package:client_create_order/src/domain/usecases/get_hubs_usecase.dart';
import 'package:client_create_order/src/domain/usecases/get_managers_by_hub_usecase.dart';
import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart';
import 'package:client_create_order/src/domain/usecases/get_roles_by_vendor_usecase.dart';
import 'package:client_create_order/src/domain/usecases/get_vendors_usecase.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
@@ -14,16 +17,20 @@ import 'one_time_order_state.dart';
/// BLoC for managing the multi-step one-time order creation form.
///
/// Builds V2 API payloads and uses [OrderPreview] for reorder.
/// Delegates all data fetching to query use cases and order submission
/// to [CreateOneTimeOrderUseCase]. Uses [OrderPreview] for reorder.
class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
with
BlocErrorHandler<OneTimeOrderState>,
SafeBloc<OneTimeOrderEvent, OneTimeOrderState> {
/// Creates the BLoC with required dependencies.
/// Creates the BLoC with required use case dependencies.
OneTimeOrderBloc(
this._createOneTimeOrderUseCase,
this._getOrderDetailsForReorderUseCase,
this._queryRepository,
this._getVendorsUseCase,
this._getRolesByVendorUseCase,
this._getHubsUseCase,
this._getManagersByHubUseCase,
) : super(OneTimeOrderState.initial()) {
on<OneTimeOrderVendorsLoaded>(_onVendorsLoaded);
on<OneTimeOrderVendorChanged>(_onVendorChanged);
@@ -45,16 +52,21 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
final CreateOneTimeOrderUseCase _createOneTimeOrderUseCase;
final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase;
final ClientOrderQueryRepositoryInterface _queryRepository;
final GetVendorsUseCase _getVendorsUseCase;
final GetRolesByVendorUseCase _getRolesByVendorUseCase;
final GetHubsUseCase _getHubsUseCase;
final GetManagersByHubUseCase _getManagersByHubUseCase;
/// Loads available vendors via the use case.
Future<void> _loadVendors() async {
final List<Vendor>? vendors = await handleErrorWithResult(
action: () => _queryRepository.getVendors(),
action: () => _getVendorsUseCase(),
onError: (_) => add(const OneTimeOrderVendorsLoaded(<Vendor>[])),
);
if (vendors != null) add(OneTimeOrderVendorsLoaded(vendors));
}
/// Loads roles for [vendorId] and maps them to presentation option models.
Future<void> _loadRolesForVendor(
String vendorId,
Emitter<OneTimeOrderState> emit,
@@ -62,7 +74,7 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
final List<OneTimeOrderRoleOption>? roles = await handleErrorWithResult(
action: () async {
final List<OrderRole> result =
await _queryRepository.getRolesByVendor(vendorId);
await _getRolesByVendorUseCase(vendorId);
return result
.map((OrderRole r) => OneTimeOrderRoleOption(
id: r.id, name: r.name, costPerHour: r.costPerHour))
@@ -74,10 +86,11 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
if (roles != null) emit(state.copyWith(roles: roles));
}
/// Loads hubs via the use case and maps to presentation option models.
Future<void> _loadHubs() async {
final List<OneTimeOrderHubOption>? hubs = await handleErrorWithResult(
action: () async {
final List<OrderHub> result = await _queryRepository.getHubs();
final List<OrderHub> result = await _getHubsUseCase();
return result
.map((OrderHub h) => OneTimeOrderHubOption(
id: h.id,
@@ -100,12 +113,13 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
if (hubs != null) add(OneTimeOrderHubsLoaded(hubs));
}
/// Loads managers for [hubId] via the use case.
Future<void> _loadManagersForHub(String hubId) async {
final List<OneTimeOrderManagerOption>? managers =
await handleErrorWithResult(
action: () async {
final List<OrderManager> result =
await _queryRepository.getManagersByHub(hubId);
await _getManagersByHubUseCase(hubId);
return result
.map((OrderManager m) =>
OneTimeOrderManagerOption(id: m.id, name: m.name))
@@ -224,7 +238,7 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
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(
OneTimeOrderSubmitted event,
Emitter<OneTimeOrderState> emit,
@@ -236,12 +250,7 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
final OneTimeOrderHubOption? selectedHub = state.selectedHub;
if (selectedHub == null) throw const OrderMissingHubException();
final String orderDate =
'${state.date.year.toString().padLeft(4, '0')}-'
'${state.date.month.toString().padLeft(2, '0')}-'
'${state.date.day.toString().padLeft(2, '0')}';
final List<Map<String, dynamic>> positions =
final List<OneTimeOrderPositionArgument> positionArgs =
state.positions.map((OneTimeOrderPosition p) {
final OneTimeOrderRoleOption? role = state.roles
.cast<OneTimeOrderRoleOption?>()
@@ -249,28 +258,24 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
(OneTimeOrderRoleOption? r) => r != null && r.id == p.role,
orElse: () => null,
);
return <String, dynamic>{
if (role != null) 'roleName': role.name,
if (p.role.isNotEmpty) 'roleId': p.role,
'workerCount': p.count,
'startTime': p.startTime,
'endTime': p.endTime,
if (p.lunchBreak != 'NO_BREAK' && p.lunchBreak.isNotEmpty)
'lunchBreakMinutes': _breakMinutes(p.lunchBreak),
};
return OneTimeOrderPositionArgument(
roleId: p.role,
roleName: role?.name,
workerCount: p.count,
startTime: p.startTime,
endTime: p.endTime,
lunchBreak: p.lunchBreak,
);
}).toList();
final Map<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(
OneTimeOrderArguments(payload: payload),
OneTimeOrderArguments(
hubId: selectedHub.id,
eventName: state.eventName,
orderDate: state.date,
positions: positionArgs,
vendorId: state.selectedVendor?.id,
),
);
emit(state.copyWith(status: OneTimeOrderStatus.success));
},
@@ -339,8 +344,8 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
positions.add(OneTimeOrderPosition(
role: role.roleId,
count: role.workersNeeded,
startTime: _formatTime(shift.startsAt),
endTime: _formatTime(shift.endsAt),
startTime: formatTimeHHmm(shift.startsAt),
endTime: formatTimeHHmm(shift.endsAt),
));
}
}
@@ -357,29 +362,4 @@ class OneTimeOrderBloc extends Bloc<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_manager.dart';
import 'package:client_create_order/src/domain/models/order_role.dart';
import 'package:client_create_order/src/domain/repositories/client_order_query_repository_interface.dart';
import 'package:client_create_order/src/domain/usecases/create_permanent_order_usecase.dart';
import 'package:client_create_order/src/domain/usecases/get_hubs_usecase.dart';
import 'package:client_create_order/src/domain/usecases/get_managers_by_hub_usecase.dart';
import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart';
import 'package:client_create_order/src/domain/usecases/get_roles_by_vendor_usecase.dart';
import 'package:client_create_order/src/domain/usecases/get_vendors_usecase.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:client_create_order/src/domain/arguments/permanent_order_arguments.dart';
import 'package:krow_domain/krow_domain.dart' as domain;
import 'permanent_order_event.dart';
import 'permanent_order_state.dart';
/// BLoC for managing the permanent order creation form.
///
/// Delegates all data fetching to query use cases and order submission
/// to [CreatePermanentOrderUseCase].
class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
with
BlocErrorHandler<PermanentOrderState>,
SafeBloc<PermanentOrderEvent, PermanentOrderState> {
/// Creates a BLoC with required use case dependencies.
PermanentOrderBloc(
this._createPermanentOrderUseCase,
this._getOrderDetailsForReorderUseCase,
this._queryRepository,
this._getVendorsUseCase,
this._getRolesByVendorUseCase,
this._getHubsUseCase,
this._getManagersByHubUseCase,
) : super(PermanentOrderState.initial()) {
on<PermanentOrderVendorsLoaded>(_onVendorsLoaded);
on<PermanentOrderVendorChanged>(_onVendorChanged);
@@ -43,7 +53,10 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
final CreatePermanentOrderUseCase _createPermanentOrderUseCase;
final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase;
final ClientOrderQueryRepositoryInterface _queryRepository;
final GetVendorsUseCase _getVendorsUseCase;
final GetRolesByVendorUseCase _getRolesByVendorUseCase;
final GetHubsUseCase _getHubsUseCase;
final GetManagersByHubUseCase _getManagersByHubUseCase;
static const List<String> _dayLabels = <String>[
'SUN',
@@ -55,9 +68,10 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
'SAT',
];
/// Loads available vendors via the use case.
Future<void> _loadVendors() async {
final List<domain.Vendor>? vendors = await handleErrorWithResult(
action: () => _queryRepository.getVendors(),
action: () => _getVendorsUseCase(),
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(
String vendorId,
Emitter<PermanentOrderState> emit,
@@ -73,7 +89,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
final List<PermanentOrderRoleOption>? roles = await handleErrorWithResult(
action: () async {
final List<OrderRole> orderRoles =
await _queryRepository.getRolesByVendor(vendorId);
await _getRolesByVendorUseCase(vendorId);
return orderRoles
.map(
(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 {
final List<PermanentOrderHubOption>? hubs = await handleErrorWithResult(
action: () async {
final List<OrderHub> orderHubs = await _queryRepository.getHubs();
final List<OrderHub> orderHubs = await _getHubsUseCase();
return orderHubs
.map(
(OrderHub hub) => PermanentOrderHubOption(
@@ -193,6 +210,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
emit(state.copyWith(managers: event.managers));
}
/// Loads managers for [hubId] via the use case.
Future<void> _loadManagersForHub(
String hubId,
Emitter<PermanentOrderState> emit,
@@ -201,7 +219,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
await handleErrorWithResult(
action: () async {
final List<OrderManager> orderManagers =
await _queryRepository.getManagersByHub(hubId);
await _getManagersByHubUseCase(hubId);
return orderManagers
.map(
(OrderManager m) => PermanentOrderManagerOption(
@@ -221,7 +239,6 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
}
}
void _onEventNameChanged(
PermanentOrderEventNameChanged event,
Emitter<PermanentOrderState> emit,
@@ -315,6 +332,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
emit(state.copyWith(positions: newPositions));
}
/// Builds typed arguments from form state and submits via the use case.
Future<void> _onSubmitted(
PermanentOrderSubmitted event,
Emitter<PermanentOrderState> emit,
@@ -328,16 +346,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
throw const domain.OrderMissingHubException();
}
final String startDate =
'${state.startDate.year.toString().padLeft(4, '0')}-'
'${state.startDate.month.toString().padLeft(2, '0')}-'
'${state.startDate.day.toString().padLeft(2, '0')}';
final List<int> daysOfWeek = state.permanentDays
.map((String day) => _dayLabels.indexOf(day) % 7)
.toList();
final List<Map<String, dynamic>> positions =
final List<PermanentOrderPositionArgument> positionArgs =
state.positions.map((PermanentOrderPosition p) {
final PermanentOrderRoleOption? role = state.roles
.cast<PermanentOrderRoleOption?>()
@@ -345,27 +354,24 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
(PermanentOrderRoleOption? r) => r != null && r.id == p.role,
orElse: () => null,
);
return <String, dynamic>{
if (role != null) 'roleName': role.name,
if (p.role.isNotEmpty) 'roleId': p.role,
'workerCount': p.count,
'startTime': p.startTime,
'endTime': p.endTime,
};
return PermanentOrderPositionArgument(
roleId: p.role,
roleName: role?.name,
workerCount: p.count,
startTime: p.startTime,
endTime: p.endTime,
);
}).toList();
final Map<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(
PermanentOrderArguments(payload: payload),
PermanentOrderArguments(
hubId: selectedHub.id,
eventName: state.eventName,
startDate: state.startDate,
daysOfWeek: state.permanentDays,
positions: positionArgs,
vendorId: state.selectedVendor?.id,
),
);
emit(state.copyWith(status: PermanentOrderStatus.success));
},
@@ -376,6 +382,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
);
}
/// Initializes the form from route arguments or reorder preview data.
Future<void> _onInitialized(
PermanentOrderInitialized event,
Emitter<PermanentOrderState> emit,
@@ -406,8 +413,8 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
positions.add(PermanentOrderPosition(
role: role.roleId,
count: role.workersNeeded,
startTime: _formatTime(shift.startsAt),
endTime: _formatTime(shift.endsAt),
startTime: formatTimeHHmm(shift.startsAt),
endTime: formatTimeHHmm(shift.endsAt),
));
}
}
@@ -430,13 +437,6 @@ class PermanentOrderBloc extends Bloc<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) {
days.sort(
(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_manager.dart';
import 'package:client_create_order/src/domain/models/order_role.dart';
import 'package:client_create_order/src/domain/repositories/client_order_query_repository_interface.dart';
import 'package:client_create_order/src/domain/usecases/create_recurring_order_usecase.dart';
import 'package:client_create_order/src/domain/usecases/get_hubs_usecase.dart';
import 'package:client_create_order/src/domain/usecases/get_managers_by_hub_usecase.dart';
import 'package:client_create_order/src/domain/usecases/get_order_details_for_reorder_usecase.dart';
import 'package:client_create_order/src/domain/usecases/get_roles_by_vendor_usecase.dart';
import 'package:client_create_order/src/domain/usecases/get_vendors_usecase.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:client_create_order/src/domain/arguments/recurring_order_arguments.dart';
import 'package:krow_domain/krow_domain.dart' as domain;
import 'recurring_order_event.dart';
@@ -14,19 +17,20 @@ import 'recurring_order_state.dart';
/// BLoC for managing the recurring order creation form.
///
/// Delegates all backend queries to [ClientOrderQueryRepositoryInterface]
/// and order submission to [CreateRecurringOrderUseCase].
/// Builds V2 API payloads from form state.
/// Delegates all data fetching to query use cases and order submission
/// to [CreateRecurringOrderUseCase]. Builds V2 API payloads from form state.
class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
with
BlocErrorHandler<RecurringOrderState>,
SafeBloc<RecurringOrderEvent, RecurringOrderState> {
/// Creates a [RecurringOrderBloc] with the required use cases and
/// query repository.
/// Creates a [RecurringOrderBloc] with the required use case dependencies.
RecurringOrderBloc(
this._createRecurringOrderUseCase,
this._getOrderDetailsForReorderUseCase,
this._queryRepository,
this._getVendorsUseCase,
this._getRolesByVendorUseCase,
this._getHubsUseCase,
this._getManagersByHubUseCase,
) : super(RecurringOrderState.initial()) {
on<RecurringOrderVendorsLoaded>(_onVendorsLoaded);
on<RecurringOrderVendorChanged>(_onVendorChanged);
@@ -50,7 +54,10 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
final CreateRecurringOrderUseCase _createRecurringOrderUseCase;
final GetOrderDetailsForReorderUseCase _getOrderDetailsForReorderUseCase;
final ClientOrderQueryRepositoryInterface _queryRepository;
final GetVendorsUseCase _getVendorsUseCase;
final GetRolesByVendorUseCase _getRolesByVendorUseCase;
final GetHubsUseCase _getHubsUseCase;
final GetManagersByHubUseCase _getManagersByHubUseCase;
static const List<String> _dayLabels = <String>[
'SUN',
@@ -62,12 +69,10 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
'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 {
final List<domain.Vendor>? vendors = await handleErrorWithResult(
action: () async {
return _queryRepository.getVendors();
},
action: () => _getVendorsUseCase(),
onError: (_) =>
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
/// option models.
/// Loads roles for [vendorId] via the use case and maps them to
/// presentation option models.
Future<void> _loadRolesForVendor(
String vendorId,
Emitter<RecurringOrderState> emit,
@@ -86,7 +91,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
final List<RecurringOrderRoleOption>? roles = await handleErrorWithResult(
action: () async {
final List<OrderRole> orderRoles =
await _queryRepository.getRolesByVendor(vendorId);
await _getRolesByVendorUseCase(vendorId);
return orderRoles
.map(
(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
/// presentation option models.
/// Loads team hubs via the use case and maps them to presentation
/// option models.
Future<void> _loadHubs() async {
final List<RecurringOrderHubOption>? hubs = await handleErrorWithResult(
action: () async {
final List<OrderHub> orderHubs = await _queryRepository.getHubs();
final List<OrderHub> orderHubs = await _getHubsUseCase();
return orderHubs
.map(
(OrderHub hub) => RecurringOrderHubOption(
@@ -208,8 +213,8 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
emit(state.copyWith(managers: event.managers));
}
/// Loads managers for the given [hubId] and maps them to presentation
/// option models.
/// Loads managers for [hubId] via the use case and maps them to
/// presentation option models.
Future<void> _loadManagersForHub(
String hubId,
Emitter<RecurringOrderState> emit,
@@ -218,7 +223,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
await handleErrorWithResult(
action: () async {
final List<OrderManager> orderManagers =
await _queryRepository.getManagersByHub(hubId);
await _getManagersByHubUseCase(hubId);
return orderManagers
.map(
(OrderManager m) => RecurringOrderManagerOption(
@@ -347,6 +352,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
emit(state.copyWith(positions: newPositions));
}
/// Builds typed arguments from form state and submits via the use case.
Future<void> _onSubmitted(
RecurringOrderSubmitted event,
Emitter<RecurringOrderState> emit,
@@ -360,21 +366,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
throw const domain.OrderMissingHubException();
}
final String startDate =
'${state.startDate.year.toString().padLeft(4, '0')}-'
'${state.startDate.month.toString().padLeft(2, '0')}-'
'${state.startDate.day.toString().padLeft(2, '0')}';
final String endDate =
'${state.endDate.year.toString().padLeft(4, '0')}-'
'${state.endDate.month.toString().padLeft(2, '0')}-'
'${state.endDate.day.toString().padLeft(2, '0')}';
// Map day labels (MON=1, TUE=2, ..., SUN=0) to V2 int format
final List<int> recurrenceDays = state.recurringDays
.map((String day) => _dayLabels.indexOf(day) % 7)
.toList();
final List<Map<String, dynamic>> positions =
final List<RecurringOrderPositionArgument> positionArgs =
state.positions.map((RecurringOrderPosition p) {
final RecurringOrderRoleOption? role = state.roles
.cast<RecurringOrderRoleOption?>()
@@ -382,28 +374,25 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
(RecurringOrderRoleOption? r) => r != null && r.id == p.role,
orElse: () => null,
);
return <String, dynamic>{
if (role != null) 'roleName': role.name,
if (p.role.isNotEmpty) 'roleId': p.role,
'workerCount': p.count,
'startTime': p.startTime,
'endTime': p.endTime,
};
return RecurringOrderPositionArgument(
roleId: p.role,
roleName: role?.name,
workerCount: p.count,
startTime: p.startTime,
endTime: p.endTime,
);
}).toList();
final Map<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(
RecurringOrderArguments(payload: payload),
RecurringOrderArguments(
hubId: selectedHub.id,
eventName: state.eventName,
startDate: state.startDate,
endDate: state.endDate,
recurringDays: state.recurringDays,
positions: positionArgs,
vendorId: state.selectedVendor?.id,
),
);
emit(state.copyWith(status: RecurringOrderStatus.success));
},
@@ -414,6 +403,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
);
}
/// Initializes the form from route arguments or reorder preview data.
Future<void> _onInitialized(
RecurringOrderInitialized event,
Emitter<RecurringOrderState> emit,
@@ -445,8 +435,8 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
positions.add(RecurringOrderPosition(
role: role.roleId,
count: role.workersNeeded,
startTime: _formatTime(shift.startsAt),
endTime: _formatTime(shift.endsAt),
startTime: formatTimeHHmm(shift.startsAt),
endTime: formatTimeHHmm(shift.endsAt),
));
}
}
@@ -470,13 +460,6 @@ class RecurringOrderBloc extends Bloc<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) {
days.sort(
(String a, String b) =>

View File

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

View File

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

View File

@@ -1,18 +1,18 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/i_view_orders_repository.dart';
import '../repositories/view_orders_repository_interface.dart';
import '../arguments/orders_range_arguments.dart';
/// Use case for retrieving the list of client orders.
///
/// This use case encapsulates the business rule of fetching orders
/// and delegates the data retrieval to the [IViewOrdersRepository].
/// and delegates the data retrieval to the [ViewOrdersRepositoryInterface].
class GetOrdersUseCase
implements UseCase<OrdersRangeArguments, List<OrderItem>> {
/// Creates a [GetOrdersUseCase] with the required [IViewOrdersRepository].
/// Creates a [GetOrdersUseCase] with the required [ViewOrdersRepositoryInterface].
GetOrdersUseCase(this._repository);
final IViewOrdersRepository _repository;
final ViewOrdersRepositoryInterface _repository;
@override
Future<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:krow_domain/krow_domain.dart';
import '../../domain/repositories/i_view_orders_repository.dart';
import '../../domain/repositories/view_orders_repository_interface.dart';
/// Bottom sheet for editing an existing order via the V2 API.
///
/// Delegates all backend calls through [IViewOrdersRepository].
/// Delegates all backend calls through [ViewOrdersRepositoryInterface].
/// The V2 `clientOrderEdit` endpoint creates an edited copy.
class OrderEditSheet extends StatefulWidget {
/// Creates an [OrderEditSheet] for the given [order].
@@ -39,12 +39,12 @@ class OrderEditSheetState extends State<OrderEditSheet> {
List<Map<String, dynamic>> _hubs = const <Map<String, dynamic>>[];
Map<String, dynamic>? _selectedHub;
late IViewOrdersRepository _repository;
late ViewOrdersRepositoryInterface _repository;
@override
void initState() {
super.initState();
_repository = Modular.get<IViewOrdersRepository>();
_repository = Modular.get<ViewOrdersRepositoryInterface>();
_orderNameController = TextEditingController(text: widget.order.roleName);
final String startHH =
@@ -441,9 +441,9 @@ class OrderEditSheetState extends State<OrderEditSheet> {
const SizedBox(height: UiConstants.space3),
// Role selector
_buildSectionHeader('ROLE'),
_buildSectionHeader('ROLE'), // TODO: localize
_buildDropdown(
hint: 'Select role',
hint: 'Select role', // TODO: localize
value: roleName.isNotEmpty ? roleName : null,
items: _roles
.map((Map<String, dynamic> r) => r['roleName'] as String? ?? r['name'] as String? ?? '')
@@ -495,7 +495,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
children: <Widget>[
Expanded(
child: _buildInlineTimeInput(
label: 'Start Time',
label: 'Start Time', // TODO: localize
value: pos['startTime'] as String? ?? '09:00',
onTap: () async {
final TimeOfDay? picked = await showTimePicker(
@@ -513,7 +513,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
const SizedBox(width: UiConstants.space3),
Expanded(
child: _buildInlineTimeInput(
label: 'End Time',
label: 'End Time', // TODO: localize
value: pos['endTime'] as String? ?? '17:00',
onTap: () async {
final TimeOfDay? picked = await showTimePicker(
@@ -825,6 +825,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
style: UiTypography.body2b.textPrimary,
),
Text(
// TODO: localize
'${pos['workerCount']} worker${(pos['workerCount'] as int? ?? 1) > 1 ? 's' : ''}',
style: UiTypography.footnote2r.textSecondary,
),

View File

@@ -4,7 +4,7 @@ import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'data/repositories/view_orders_repository_impl.dart';
import 'domain/repositories/i_view_orders_repository.dart';
import 'domain/repositories/view_orders_repository_interface.dart';
import 'domain/usecases/get_orders_use_case.dart';
import 'presentation/blocs/view_orders_cubit.dart';
import 'presentation/pages/view_orders_page.dart';
@@ -20,7 +20,7 @@ class ViewOrdersModule extends Module {
@override
void binds(Injector i) {
// Repositories
i.add<IViewOrdersRepository>(
i.add<ViewOrdersRepositoryInterface>(
() => ViewOrdersRepositoryImpl(
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';
/// V2 API implementation of [ReportsRepository].
/// V2 API implementation of [ReportsRepositoryInterface].
///
/// Each method hits its corresponding `ClientEndpoints.reports*` endpoint,
/// passing date-range query parameters, and deserialises the JSON response
/// into the relevant domain entity.
class ReportsRepositoryImpl implements ReportsRepository {
class ReportsRepositoryImpl implements ReportsRepositoryInterface {
/// Creates a [ReportsRepositoryImpl].
ReportsRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -100,12 +100,13 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
);
}
/// Builds the gradient header with back button and title.
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.only(top: 60, left: 20, right: 20, bottom: 40),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: <Color>[UiColors.primary, Color(0xFF0020A0)],
colors: <Color>[UiColors.primary, UiColors.buttonPrimaryHover],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
@@ -150,6 +151,7 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
);
}
/// Builds the 2x2 metrics grid.
Widget _buildMetricsGrid(BuildContext context, ForecastReport report) {
final TranslationsClientReportsForecastReportEn t =
context.t.client_reports.forecast_report;
@@ -186,8 +188,8 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
label: t.metrics.total_shifts,
value: report.totalShifts.toString(),
badgeText: t.badges.scheduled,
iconColor: const Color(0xFF9333EA),
badgeColor: const Color(0xFFF3E8FF),
iconColor: UiColors.primary,
badgeColor: UiColors.tagInProgress,
),
_MetricCard(
icon: UiIcons.users,
@@ -201,6 +203,7 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
);
}
/// Builds the chart section with weekly spend trend.
Widget _buildChartSection(BuildContext context, ForecastReport report) {
return Container(
height: 320,
@@ -231,13 +234,14 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
for (int i = 0; i < report.weeks.length; i++) ...<Widget>[
Text('W${i + 1}',
style: const TextStyle(
color: UiColors.textSecondary, fontSize: 12)),
Text(
'W${i + 1}',
style: UiTypography.body3r.copyWith(
color: UiColors.textSecondary,
),
),
if (i < report.weeks.length - 1)
const Text('',
style: TextStyle(
color: UiColors.transparent, fontSize: 12)),
const SizedBox.shrink(),
],
],
),
@@ -247,6 +251,7 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
}
}
/// Metric card widget for the forecast grid.
class _MetricCard extends StatelessWidget {
const _MetricCard({
required this.icon,
@@ -257,11 +262,22 @@ class _MetricCard extends StatelessWidget {
required this.badgeColor,
});
/// The metric icon.
final IconData icon;
/// The metric label text.
final String label;
/// The metric value text.
final String value;
/// The badge text.
final String badgeText;
/// The icon tint color.
final Color iconColor;
/// The badge background color.
final Color badgeColor;
@override
@@ -308,11 +324,7 @@ class _MetricCard extends StatelessWidget {
),
child: Text(
badgeText,
style: UiTypography.footnote1r.copyWith(
color: UiColors.textPrimary,
fontSize: 10,
fontWeight: FontWeight.w600,
),
style: UiTypography.footnote2b,
),
),
],
@@ -328,7 +340,10 @@ class _WeeklyBreakdownItem extends StatelessWidget {
required this.weekIndex,
});
/// The forecast week data.
final ForecastWeek week;
/// The 1-based week index.
final int weekIndex;
@override
@@ -386,6 +401,7 @@ class _WeeklyBreakdownItem extends StatelessWidget {
);
}
/// Builds a label/value stat column.
Widget _buildStat(String label, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -402,6 +418,7 @@ class _WeeklyBreakdownItem extends StatelessWidget {
class _ForecastChart extends StatelessWidget {
const _ForecastChart({required this.weeks});
/// The weekly forecast data points.
final List<ForecastWeek> weeks;
@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_event.dart';
import 'package:client_reports/src/presentation/blocs/no_show/no_show_state.dart';
import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart';
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart';
/// Page displaying the no-show report with summary metrics and worker cards.
class NoShowReportPage extends StatefulWidget {
/// Creates a [NoShowReportPage].
const NoShowReportPage({super.key});
@override
@@ -26,7 +26,7 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
@override
Widget build(BuildContext context) {
return BlocProvider(
return BlocProvider<NoShowBloc>(
create: (BuildContext context) => Modular.get<NoShowBloc>()
..add(LoadNoShowReport(startDate: _startDate, endDate: _endDate)),
child: Scaffold(
@@ -90,16 +90,13 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
children: <Widget>[
Text(
context.t.client_reports.no_show_report.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
style: UiTypography.title1b.copyWith(
color: UiColors.white,
),
),
Text(
context.t.client_reports.no_show_report.subtitle,
style: TextStyle(
fontSize: 12,
style: UiTypography.body3r.copyWith(
color: UiColors.white.withOpacity(0.6),
),
),
@@ -107,47 +104,6 @@ class _NoShowReportPageState extends State<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(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 3-chip summary row (matches prototype)
// 3-chip summary row
Row(
children: <Widget>[
Expanded(
@@ -198,9 +154,7 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
Text(
context.t.client_reports.no_show_report
.workers_list_title,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
style: UiTypography.body3b.copyWith(
color: UiColors.textSecondary,
letterSpacing: 1.2,
),
@@ -214,7 +168,7 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
alignment: Alignment.center,
child: Text(
context.t.client_reports.no_show_report.empty_state,
style: const TextStyle(
style: UiTypography.body2r.copyWith(
color: UiColors.textSecondary,
),
),
@@ -241,18 +195,25 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
}
}
// Summary chip (top 3 stats)
/// Summary chip showing a single metric with icon.
class _SummaryChip extends StatelessWidget {
const _SummaryChip({
required this.icon,
required this.iconColor,
required this.label,
required this.value,
});
/// The icon to display.
final IconData icon;
/// The icon and label color.
final Color iconColor;
/// The metric label text.
final String label;
/// The metric value text.
final String value;
@override
@@ -280,10 +241,8 @@ class _SummaryChip extends StatelessWidget {
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 10,
style: UiTypography.footnote2b.copyWith(
color: iconColor,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
@@ -293,11 +252,7 @@ class _SummaryChip extends StatelessWidget {
const SizedBox(height: 8),
Text(
value,
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
style: UiTypography.display1b,
),
],
),
@@ -305,24 +260,28 @@ class _SummaryChip extends StatelessWidget {
}
}
// Worker card with risk badge + latest incident ””””””””””””””
/// Worker card with risk badge and latest incident date.
class _WorkerCard extends StatelessWidget {
const _WorkerCard({required this.worker});
/// The worker item data.
final NoShowWorkerItem worker;
/// Returns the localized risk label.
String _riskLabel(BuildContext context, String riskStatus) {
if (riskStatus == 'HIGH') return context.t.client_reports.no_show_report.risks.high;
if (riskStatus == 'MEDIUM') return context.t.client_reports.no_show_report.risks.medium;
return context.t.client_reports.no_show_report.risks.low;
}
/// Returns the color for the given risk status.
Color _riskColor(String riskStatus) {
if (riskStatus == 'HIGH') return UiColors.error;
if (riskStatus == 'MEDIUM') return UiColors.textWarning;
return UiColors.success;
}
/// Returns the background color for the given risk status.
Color _riskBg(String riskStatus) {
if (riskStatus == 'HIGH') return UiColors.tagError;
if (riskStatus == 'MEDIUM') return UiColors.tagPending;
@@ -374,16 +333,11 @@ class _WorkerCard extends StatelessWidget {
children: <Widget>[
Text(
worker.staffName,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: UiColors.textPrimary,
),
style: UiTypography.body2b,
),
Text(
context.t.client_reports.no_show_report.no_show_count(count: worker.incidentCount.toString()),
style: const TextStyle(
fontSize: 12,
style: UiTypography.body3r.copyWith(
color: UiColors.textSecondary,
),
),
@@ -403,9 +357,7 @@ class _WorkerCard extends StatelessWidget {
),
child: Text(
riskLabel,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
style: UiTypography.titleUppercase4b.copyWith(
color: riskColor,
),
),
@@ -420,8 +372,7 @@ class _WorkerCard extends StatelessWidget {
children: <Widget>[
Text(
context.t.client_reports.no_show_report.latest_incident,
style: const TextStyle(
fontSize: 11,
style: UiTypography.titleUppercase4m.copyWith(
color: UiColors.textSecondary,
),
),
@@ -430,10 +381,8 @@ class _WorkerCard extends StatelessWidget {
? DateFormat('MMM dd, yyyy')
.format(worker.incidents.first.date)
: '-',
style: const TextStyle(
fontSize: 11,
style: UiTypography.titleUppercase4m.copyWith(
color: UiColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
@@ -443,6 +392,3 @@ class _WorkerCard extends StatelessWidget {
);
}
}
// Insight line

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