feat: Migrate staff profile features from Data Connect to V2 REST API

- Removed data_connect package from mobile pubspec.yaml.
- Added documentation for V2 profile migration status and QA findings.
- Implemented new session management with ClientSessionStore and StaffSessionStore.
- Created V2SessionService for handling user sessions via the V2 API.
- Developed use cases for cancelling late worker assignments and submitting worker reviews.
- Added arguments and use cases for payment chart retrieval and profile completion checks.
- Implemented repository interfaces and their implementations for staff main and profile features.
- Ensured proper error handling and validation in use cases.
This commit is contained in:
Achintha Isuru
2026-03-16 22:45:06 -04:00
parent 4834266986
commit b31a615092
478 changed files with 10512 additions and 19854 deletions

View File

@@ -2,7 +2,8 @@ library;
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart';
import 'src/data/repositories_impl/auth_repository_impl.dart';
import 'src/domain/repositories/auth_repository_interface.dart';
import 'src/domain/usecases/sign_in_with_email_use_case.dart';
@@ -21,14 +22,19 @@ export 'src/presentation/pages/client_sign_up_page.dart';
export 'package:core_localization/core_localization.dart';
/// A [Module] for the client authentication feature.
///
/// Imports [CoreModule] for [BaseApiService] and registers repositories,
/// use cases, and BLoCs for the client authentication flow.
class ClientAuthenticationModule extends Module {
@override
List<Module> get imports => <Module>[DataConnectModule()];
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<AuthRepositoryInterface>(AuthRepositoryImpl.new);
i.addLazySingleton<AuthRepositoryInterface>(
() => AuthRepositoryImpl(apiService: i.get<BaseApiService>()),
);
// UseCases
i.addLazySingleton(

View File

@@ -1,68 +1,96 @@
import 'dart:developer' as developer;
import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'
show
AccountExistsException,
ApiResponse,
AppException,
BaseApiService,
ClientSession,
InvalidCredentialsException,
NetworkException,
PasswordMismatchException,
SignInFailedException,
SignUpFailedException,
WeakPasswordException,
AccountExistsException,
UserNotFoundException,
UnauthorizedAppException,
PasswordMismatchException,
NetworkException;
import 'package:krow_domain/krow_domain.dart' as domain;
User,
UserStatus,
WeakPasswordException;
import '../../domain/repositories/auth_repository_interface.dart';
import 'package:client_authentication/src/domain/repositories/auth_repository_interface.dart';
/// Production-ready implementation of the [AuthRepositoryInterface] for the client app.
/// Production implementation of the [AuthRepositoryInterface] for the client app.
///
/// This implementation integrates with Firebase Authentication for user
/// identity management and KROW's Data Connect SDK for storing user profile data.
/// Uses Firebase Auth client-side for sign-in (to maintain local auth state for
/// the [AuthInterceptor]), then calls V2 `GET /auth/session` to retrieve
/// business context. Sign-up provisioning (tenant, business, memberships) is
/// handled entirely server-side by the V2 API.
class AuthRepositoryImpl implements AuthRepositoryInterface {
/// Creates an [AuthRepositoryImpl] with the real dependencies.
AuthRepositoryImpl({dc.DataConnectService? service})
: _service = service ?? dc.DataConnectService.instance;
/// Creates an [AuthRepositoryImpl] with the given [BaseApiService].
AuthRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
final dc.DataConnectService _service;
/// The V2 API service for backend calls.
final BaseApiService _apiService;
/// Firebase Auth instance for client-side sign-in/sign-up.
firebase.FirebaseAuth get _auth => firebase.FirebaseAuth.instance;
@override
Future<domain.User> signInWithEmail({
Future<User> signInWithEmail({
required String email,
required String password,
}) async {
try {
final firebase.UserCredential credential = await _service.auth
.signInWithEmailAndPassword(email: email, password: password);
// Step 1: Call V2 sign-in endpoint — server handles Firebase Auth
// via Identity Toolkit and returns a full auth envelope.
final ApiResponse response = await _apiService.post(
V2ApiEndpoints.clientSignIn,
data: <String, dynamic>{
'email': email,
'password': password,
},
);
final Map<String, dynamic> body =
response.data as Map<String, dynamic>;
// Check for V2 error responses.
if (response.code != '200' && response.code != '201') {
final String errorCode = body['code']?.toString() ?? response.code;
if (errorCode == 'INVALID_CREDENTIALS' ||
response.message.contains('INVALID_LOGIN_CREDENTIALS')) {
throw InvalidCredentialsException(
technicalMessage: response.message,
);
}
throw SignInFailedException(
technicalMessage: '$errorCode: ${response.message}',
);
}
// Step 2: Sign in locally so AuthInterceptor can attach Bearer tokens
// to subsequent requests. The V2 API already validated credentials, so
// email/password sign-in establishes the local Firebase Auth state.
final firebase.UserCredential credential =
await _auth.signInWithEmailAndPassword(
email: email,
password: password,
);
final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) {
throw const SignInFailedException(
technicalMessage: 'No Firebase user received after sign-in',
technicalMessage: 'Local Firebase sign-in failed after V2 sign-in',
);
}
return _getUserProfile(
firebaseUserId: firebaseUser.uid,
fallbackEmail: firebaseUser.email ?? email,
requireBusinessRole: true,
);
} on firebase.FirebaseAuthException catch (e) {
if (e.code == 'invalid-credential' || e.code == 'wrong-password') {
throw InvalidCredentialsException(
technicalMessage: 'Firebase error code: ${e.code}',
);
} else if (e.code == 'network-request-failed') {
throw NetworkException(technicalMessage: 'Firebase: ${e.message}');
} else {
throw SignInFailedException(
technicalMessage: 'Firebase auth error: ${e.message}',
);
}
} on domain.AppException {
// Step 3: Populate session store from the V2 auth envelope directly
// (no need for a separate GET /auth/session call).
return _populateStoreFromAuthEnvelope(body, firebaseUser, email);
} on AppException {
rethrow;
} catch (e) {
throw SignInFailedException(technicalMessage: 'Unexpected error: $e');
@@ -70,50 +98,57 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
}
@override
Future<domain.User> signUpWithEmail({
Future<User> signUpWithEmail({
required String companyName,
required String email,
required String password,
}) async {
firebase.User? firebaseUser;
String? createdBusinessId;
try {
// Step 1: Try to create Firebase Auth user
final firebase.UserCredential credential = await _service.auth
.createUserWithEmailAndPassword(email: email, password: password);
// Step 1: Call V2 sign-up endpoint which handles everything server-side:
// - Creates Firebase Auth account via Identity Toolkit
// - Creates user, tenant, business, memberships in one transaction
// - Returns full auth envelope with session tokens
final ApiResponse response = await _apiService.post(
V2ApiEndpoints.clientSignUp,
data: <String, dynamic>{
'companyName': companyName,
'email': email,
'password': password,
},
);
firebaseUser = credential.user;
// Check for V2 error responses.
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
if (response.code != '201' && response.code != '200') {
final String errorCode = body['code']?.toString() ?? response.code;
_throwSignUpError(errorCode, response.message);
}
// Step 2: Sign in locally to Firebase Auth so AuthInterceptor works
// for subsequent requests. The V2 API already created the Firebase
// account, so this should succeed.
final firebase.UserCredential credential =
await _auth.signInWithEmailAndPassword(
email: email,
password: password,
);
final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) {
throw const SignUpFailedException(
technicalMessage: 'Firebase user could not be created',
technicalMessage: 'Local Firebase sign-in failed after V2 sign-up',
);
}
// Force-refresh the ID token so the Data Connect SDK has a valid bearer
// token before we fire any mutations. Without this, there is a race
// condition where the gRPC layer sends the request unauthenticated
// immediately after account creation (gRPC code 16 UNAUTHENTICATED).
await firebaseUser.getIdToken(true);
// New user created successfully, proceed to create PostgreSQL entities
return await _createBusinessAndUser(
firebaseUser: firebaseUser,
companyName: companyName,
email: email,
onBusinessCreated: (String businessId) =>
createdBusinessId = businessId,
);
// Step 3: Populate store from the sign-up response envelope.
return _populateStoreFromAuthEnvelope(body, firebaseUser, email);
} on firebase.FirebaseAuthException catch (e) {
if (e.code == 'weak-password') {
throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}');
} else if (e.code == 'email-already-in-use') {
// Email exists in Firebase Auth - try to sign in and complete registration
return await _handleExistingFirebaseAccount(
email: email,
password: password,
companyName: companyName,
if (e.code == 'email-already-in-use') {
throw AccountExistsException(
technicalMessage: 'Firebase: ${e.message}',
);
} else if (e.code == 'weak-password') {
throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}');
} else if (e.code == 'network-request-failed') {
throw NetworkException(technicalMessage: 'Firebase: ${e.message}');
} else {
@@ -121,304 +156,103 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
technicalMessage: 'Firebase auth error: ${e.message}',
);
}
} on domain.AppException {
// Rollback for our known exceptions
await _rollbackSignUp(
firebaseUser: firebaseUser,
businessId: createdBusinessId,
);
} on AppException {
rethrow;
} catch (e) {
// Rollback: Clean up any partially created resources
await _rollbackSignUp(
firebaseUser: firebaseUser,
businessId: createdBusinessId,
);
throw SignUpFailedException(technicalMessage: 'Unexpected error: $e');
}
}
/// Handles the case where email already exists in Firebase Auth.
///
/// This can happen when:
/// 1. User signed up with Google in another app sharing the same Firebase project
/// 2. User already has a KROW account
///
/// The flow:
/// 1. Try to sign in with provided password
/// 2. If sign-in succeeds, check if BUSINESS user exists in PostgreSQL
/// 3. If not, create Business + User (user is new to KROW)
/// 4. If yes, they already have a KROW account
Future<domain.User> _handleExistingFirebaseAccount({
required String email,
required String password,
required String companyName,
}) async {
developer.log(
'Email exists in Firebase, attempting sign-in: $email',
name: 'AuthRepository',
);
try {
// Try to sign in with the provided password
final firebase.UserCredential credential = await _service.auth
.signInWithEmailAndPassword(email: email, password: password);
final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) {
throw const SignUpFailedException(
technicalMessage: 'Sign-in succeeded but no user returned',
);
}
// Force-refresh the ID token so the Data Connect SDK receives a valid
// bearer token before any subsequent Data Connect queries run.
await firebaseUser.getIdToken(true);
// Sign-in succeeded! Check if user already has a BUSINESS account in PostgreSQL
final bool hasBusinessAccount = await _checkBusinessUserExists(
firebaseUser.uid,
);
if (hasBusinessAccount) {
// User already has a KROW Client account
developer.log(
'User already has BUSINESS account: ${firebaseUser.uid}',
name: 'AuthRepository',
);
throw AccountExistsException(
technicalMessage:
'User ${firebaseUser.uid} already has BUSINESS role',
);
}
// User exists in Firebase but not in KROW PostgreSQL - create the entities
developer.log(
'Creating BUSINESS account for existing Firebase user: ${firebaseUser.uid}',
name: 'AuthRepository',
);
return await _createBusinessAndUser(
firebaseUser: firebaseUser,
companyName: companyName,
email: email,
onBusinessCreated:
(_) {}, // No rollback needed for existing Firebase user
);
} on firebase.FirebaseAuthException catch (e) {
// Sign-in failed - check why
developer.log(
'Sign-in failed with code: ${e.code}',
name: 'AuthRepository',
);
if (e.code == 'wrong-password' || e.code == 'invalid-credential') {
// Password doesn't match - check what providers are available
return await _handlePasswordMismatch(email);
} else {
throw SignUpFailedException(
technicalMessage: 'Firebase sign-in error: ${e.message}',
);
}
} on domain.AppException {
rethrow;
}
}
/// Handles the case where the password doesn't match the existing account.
///
/// Note: fetchSignInMethodsForEmail was deprecated by Firebase for security
/// reasons (email enumeration). We show a combined message that covers both
/// cases: wrong password OR account uses different sign-in method (Google).
Future<Never> _handlePasswordMismatch(String email) async {
// We can't distinguish between "wrong password" and "no password provider"
// due to Firebase deprecating fetchSignInMethodsForEmail.
// The PasswordMismatchException message covers both scenarios.
developer.log(
'Password mismatch or different provider for: $email',
name: 'AuthRepository',
);
throw PasswordMismatchException(
technicalMessage:
'Email $email: password mismatch or different auth provider',
);
}
/// Checks if a user with BUSINESS role exists in PostgreSQL.
Future<bool> _checkBusinessUserExists(String firebaseUserId) async {
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response =
await _service.run(
() => _service.connector.getUserById(id: firebaseUserId).execute(),
);
final dc.GetUserByIdUser? user = response.data.user;
return user != null &&
(user.userRole == 'BUSINESS' || user.userRole == 'BOTH');
}
/// Creates Business and User entities in PostgreSQL for a Firebase user.
Future<domain.User> _createBusinessAndUser({
required firebase.User firebaseUser,
required String companyName,
required String email,
required void Function(String businessId) onBusinessCreated,
}) async {
// Create Business entity in PostgreSQL
final OperationResult<dc.CreateBusinessData, dc.CreateBusinessVariables>
createBusinessResponse = await _service.run(
() => _service.connector
.createBusiness(
businessName: companyName,
userId: firebaseUser.uid,
rateGroup: dc.BusinessRateGroup.STANDARD,
status: dc.BusinessStatus.PENDING,
)
.execute(),
);
final dc.CreateBusinessBusinessInsert businessData =
createBusinessResponse.data.business_insert;
onBusinessCreated(businessData.id);
// Check if User entity already exists in PostgreSQL
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> userResult =
await _service.run(
() => _service.connector.getUserById(id: firebaseUser.uid).execute(),
);
final dc.GetUserByIdUser? existingUser = userResult.data.user;
if (existingUser != null) {
// User exists (likely in another app like STAFF). Update role to BOTH.
await _service.run(
() => _service.connector
.updateUser(id: firebaseUser.uid)
.userRole('BOTH')
.execute(),
);
} else {
// Create new User entity in PostgreSQL
await _service.run(
() => _service.connector
.createUser(id: firebaseUser.uid, role: dc.UserBaseRole.USER)
.email(email)
.userRole('BUSINESS')
.execute(),
);
}
return _getUserProfile(
firebaseUserId: firebaseUser.uid,
fallbackEmail: firebaseUser.email ?? email,
);
}
/// Rollback helper to clean up partially created resources during sign-up.
Future<void> _rollbackSignUp({
firebase.User? firebaseUser,
String? businessId,
}) async {
// Delete business first (if created)
if (businessId != null) {
try {
await _service.connector.deleteBusiness(id: businessId).execute();
} catch (_) {
// Log but don't throw - we're already in error recovery
}
}
// Delete Firebase user (if created)
if (firebaseUser != null) {
try {
await firebaseUser.delete();
} catch (_) {
// Log but don't throw - we're already in error recovery
}
}
}
@override
Future<void> signOut() async {
try {
await _service.signOut();
} catch (e) {
throw Exception('Error signing out: ${e.toString()}');
}
}
@override
Future<domain.User> signInWithSocial({required String provider}) {
Future<User> signInWithSocial({required String provider}) {
throw UnimplementedError(
'Social authentication with $provider is not yet implemented.',
);
}
Future<domain.User> _getUserProfile({
required String firebaseUserId,
required String? fallbackEmail,
bool requireBusinessRole = false,
}) async {
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response =
await _service.run(
() => _service.connector.getUserById(id: firebaseUserId).execute(),
);
final dc.GetUserByIdUser? user = response.data.user;
if (user == null) {
throw UserNotFoundException(
technicalMessage:
'Firebase UID $firebaseUserId not found in users table',
);
}
if (requireBusinessRole &&
user.userRole != 'BUSINESS' &&
user.userRole != 'BOTH') {
await _service.signOut();
throw UnauthorizedAppException(
technicalMessage:
'User role is ${user.userRole}, expected BUSINESS or BOTH',
@override
Future<void> signOut() async {
try {
// Step 1: Call V2 sign-out endpoint for server-side token revocation.
await _apiService.post(V2ApiEndpoints.clientSignOut);
} catch (e) {
developer.log(
'V2 sign-out request failed: $e',
name: 'AuthRepository',
);
// Continue with local sign-out even if server-side fails.
}
final String? email = user.email ?? fallbackEmail;
if (email == null || email.isEmpty) {
throw UserNotFoundException(
technicalMessage: 'User email missing for UID $firebaseUserId',
);
try {
// Step 2: Sign out from local Firebase Auth.
await _auth.signOut();
} catch (e) {
throw Exception('Error signing out locally: $e');
}
final domain.User domainUser = domain.User(
id: user.id,
// Step 3: Clear the client session store.
ClientSessionStore.instance.clear();
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
/// Populates the session store from a V2 auth envelope response and
/// returns a domain [User].
User _populateStoreFromAuthEnvelope(
Map<String, dynamic> envelope,
firebase.User firebaseUser,
String fallbackEmail,
) {
final Map<String, dynamic>? userJson =
envelope['user'] as Map<String, dynamic>?;
final Map<String, dynamic>? businessJson =
envelope['business'] as Map<String, dynamic>?;
if (businessJson != null) {
final ClientSession clientSession = ClientSession.fromJson(envelope);
ClientSessionStore.instance.setSession(clientSession);
}
final String userId =
userJson?['id'] as String? ?? firebaseUser.uid;
final String? email = userJson?['email'] as String? ?? fallbackEmail;
return User(
id: userId,
email: email,
role: user.role.stringValue,
displayName: userJson?['displayName'] as String?,
phone: userJson?['phone'] as String?,
status: _parseUserStatus(userJson?['status'] as String?),
);
}
final QueryResult<
dc.GetBusinessesByUserIdData,
dc.GetBusinessesByUserIdVariables
>
businessResponse = await _service.run(
() => _service.connector
.getBusinessesByUserId(userId: firebaseUserId)
.execute(),
);
final dc.GetBusinessesByUserIdBusinesses? business =
businessResponse.data.businesses.isNotEmpty
? businessResponse.data.businesses.first
: null;
/// Maps a V2 error code to the appropriate domain exception for sign-up.
Never _throwSignUpError(String errorCode, String message) {
switch (errorCode) {
case 'AUTH_PROVIDER_ERROR' when message.contains('EMAIL_EXISTS'):
throw AccountExistsException(technicalMessage: message);
case 'AUTH_PROVIDER_ERROR' when message.contains('WEAK_PASSWORD'):
throw WeakPasswordException(technicalMessage: message);
case 'FORBIDDEN':
throw PasswordMismatchException(technicalMessage: message);
default:
throw SignUpFailedException(technicalMessage: '$errorCode: $message');
}
}
dc.ClientSessionStore.instance.setSession(
dc.ClientSession(
business: business == null
? null
: dc.ClientBusinessSession(
id: business.id,
businessName: business.businessName,
email: business.email,
city: business.city,
contactName: business.contactName,
companyLogoUrl: business.companyLogoUrl,
),
),
);
return domainUser;
/// Parses a status string from the API into a [UserStatus].
static UserStatus _parseUserStatus(String? value) {
switch (value?.toUpperCase()) {
case 'ACTIVE':
return UserStatus.active;
case 'INVITED':
return UserStatus.invited;
case 'DISABLED':
return UserStatus.disabled;
default:
return UserStatus.active;
}
}
}

View File

@@ -14,17 +14,13 @@ dependencies:
flutter_bloc: ^8.1.0
flutter_modular: ^6.3.0
equatable: ^2.0.5
firebase_core: ^4.2.1
firebase_auth: ^6.1.2 # Updated for compatibility
firebase_data_connect: ^0.2.2+1
firebase_auth: ^6.1.2
# Architecture Packages
design_system:
path: ../../../design_system
core_localization:
path: ../../../core_localization
krow_data_connect:
path: ../../../data_connect
krow_domain:
path: ../../../domain
krow_core:
@@ -35,7 +31,6 @@ dev_dependencies:
sdk: flutter
bloc_test: ^9.1.0
mocktail: ^1.0.0
build_runner: ^2.4.15
flutter:
uses-material-design: true

View File

@@ -1,30 +1,37 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'data/repositories_impl/billing_repository_impl.dart';
import 'domain/repositories/billing_repository.dart';
import 'domain/usecases/get_bank_accounts.dart';
import 'domain/usecases/get_current_bill_amount.dart';
import 'domain/usecases/get_invoice_history.dart';
import 'domain/usecases/get_pending_invoices.dart';
import 'domain/usecases/get_savings_amount.dart';
import 'domain/usecases/get_spending_breakdown.dart';
import 'domain/usecases/approve_invoice.dart';
import 'domain/usecases/dispute_invoice.dart';
import 'presentation/blocs/billing_bloc.dart';
import 'presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
import 'presentation/models/billing_invoice_model.dart';
import 'presentation/pages/billing_page.dart';
import 'presentation/pages/completion_review_page.dart';
import 'presentation/pages/invoice_ready_page.dart';
import 'presentation/pages/pending_invoices_page.dart';
import 'package:billing/src/data/repositories_impl/billing_repository_impl.dart';
import 'package:billing/src/domain/repositories/billing_repository.dart';
import 'package:billing/src/domain/usecases/approve_invoice.dart';
import 'package:billing/src/domain/usecases/dispute_invoice.dart';
import 'package:billing/src/domain/usecases/get_bank_accounts.dart';
import 'package:billing/src/domain/usecases/get_current_bill_amount.dart';
import 'package:billing/src/domain/usecases/get_invoice_history.dart';
import 'package:billing/src/domain/usecases/get_pending_invoices.dart';
import 'package:billing/src/domain/usecases/get_savings_amount.dart';
import 'package:billing/src/domain/usecases/get_spending_breakdown.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
import 'package:billing/src/presentation/pages/billing_page.dart';
import 'package:billing/src/presentation/pages/completion_review_page.dart';
import 'package:billing/src/presentation/pages/invoice_ready_page.dart';
import 'package:billing/src/presentation/pages/pending_invoices_page.dart';
/// Modular module for the billing feature.
///
/// Uses [BaseApiService] for all backend access via V2 REST API.
class BillingModule extends Module {
@override
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<BillingRepository>(BillingRepositoryImpl.new);
i.addLazySingleton<BillingRepository>(
() => BillingRepositoryImpl(apiService: i.get<BaseApiService>()),
);
// Use Cases
i.addLazySingleton(GetBankAccountsUseCase.new);
@@ -32,7 +39,7 @@ class BillingModule extends Module {
i.addLazySingleton(GetSavingsAmountUseCase.new);
i.addLazySingleton(GetPendingInvoicesUseCase.new);
i.addLazySingleton(GetInvoiceHistoryUseCase.new);
i.addLazySingleton(GetSpendingBreakdownUseCase.new);
i.addLazySingleton(GetSpendBreakdownUseCase.new);
i.addLazySingleton(ApproveInvoiceUseCase.new);
i.addLazySingleton(DisputeInvoiceUseCase.new);
@@ -44,7 +51,7 @@ class BillingModule extends Module {
getSavingsAmount: i.get<GetSavingsAmountUseCase>(),
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),
getInvoiceHistory: i.get<GetInvoiceHistoryUseCase>(),
getSpendingBreakdown: i.get<GetSpendingBreakdownUseCase>(),
getSpendBreakdown: i.get<GetSpendBreakdownUseCase>(),
),
);
i.add<ShiftCompletionReviewBloc>(
@@ -62,16 +69,20 @@ class BillingModule extends Module {
child: (_) => const BillingPage(),
);
r.child(
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.completionReview),
child: (_) =>
ShiftCompletionReviewPage(invoice: r.args.data as BillingInvoice?),
ClientPaths.childRoute(
ClientPaths.billing, ClientPaths.completionReview),
child: (_) => ShiftCompletionReviewPage(
invoice:
r.args.data is Invoice ? r.args.data as Invoice : null,
),
);
r.child(
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.invoiceReady),
child: (_) => const InvoiceReadyPage(),
);
r.child(
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.awaitingApproval),
ClientPaths.childRoute(
ClientPaths.billing, ClientPaths.awaitingApproval),
child: (_) => const PendingInvoicesPage(),
);
}

View File

@@ -1,70 +1,103 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/billing_repository.dart';
/// Implementation of [BillingRepository] that delegates to [dc.BillingConnectorRepository].
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Implementation of [BillingRepository] using the V2 REST API.
///
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
/// connector repository from the data_connect package.
/// All backend calls go through [BaseApiService] with [V2ApiEndpoints].
class BillingRepositoryImpl implements BillingRepository {
/// Creates a [BillingRepositoryImpl].
BillingRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
BillingRepositoryImpl({
dc.BillingConnectorRepository? connectorRepository,
dc.DataConnectService? service,
}) : _connectorRepository = connectorRepository ??
dc.DataConnectService.instance.getBillingRepository(),
_service = service ?? dc.DataConnectService.instance;
final dc.BillingConnectorRepository _connectorRepository;
final dc.DataConnectService _service;
/// The API service used for all HTTP requests.
final BaseApiService _apiService;
@override
Future<List<BusinessBankAccount>> getBankAccounts() async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.getBankAccounts(businessId: businessId);
}
@override
Future<double> getCurrentBillAmount() async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.getCurrentBillAmount(businessId: businessId);
}
@override
Future<List<Invoice>> getInvoiceHistory() async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.getInvoiceHistory(businessId: businessId);
Future<List<BillingAccount>> getBankAccounts() async {
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientBillingAccounts);
final List<dynamic> items =
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
return items
.map((dynamic json) =>
BillingAccount.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<List<Invoice>> getPendingInvoices() async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.getPendingInvoices(businessId: businessId);
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientBillingInvoicesPending);
final List<dynamic> items =
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
return items
.map(
(dynamic json) => Invoice.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<double> getSavingsAmount() async {
// Simulating savings calculation
return 0.0;
Future<List<Invoice>> getInvoiceHistory() async {
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientBillingInvoicesHistory);
final List<dynamic> items =
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
return items
.map(
(dynamic json) => Invoice.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period) async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.getSpendingBreakdown(
businessId: businessId,
period: period,
Future<int> getCurrentBillCents() async {
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientBillingCurrentBill);
final Map<String, dynamic> data =
response.data as Map<String, dynamic>;
return (data['currentBillCents'] as num).toInt();
}
@override
Future<int> getSavingsCents() async {
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientBillingSavings);
final Map<String, dynamic> data =
response.data as Map<String, dynamic>;
return (data['savingsCents'] as num).toInt();
}
@override
Future<List<SpendItem>> getSpendBreakdown({
required String startDate,
required String endDate,
}) async {
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.clientBillingSpendBreakdown,
params: <String, dynamic>{
'startDate': startDate,
'endDate': endDate,
},
);
final List<dynamic> items =
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
return items
.map((dynamic json) =>
SpendItem.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<void> approveInvoice(String id) async {
return _connectorRepository.approveInvoice(id: id);
await _apiService.post(V2ApiEndpoints.clientInvoiceApprove(id));
}
@override
Future<void> disputeInvoice(String id, String reason) async {
return _connectorRepository.disputeInvoice(id: id, reason: reason);
await _apiService.post(
V2ApiEndpoints.clientInvoiceDispute(id),
data: <String, dynamic>{'reason': reason},
);
}
}

View File

@@ -7,7 +7,7 @@ import 'package:krow_domain/krow_domain.dart';
/// It allows the Domain layer to remain independent of specific data sources.
abstract class BillingRepository {
/// Fetches bank accounts associated with the business.
Future<List<BusinessBankAccount>> getBankAccounts();
Future<List<BillingAccount>> getBankAccounts();
/// Fetches invoices that are pending approval or payment.
Future<List<Invoice>> getPendingInvoices();
@@ -15,14 +15,17 @@ abstract class BillingRepository {
/// Fetches historically paid invoices.
Future<List<Invoice>> getInvoiceHistory();
/// Fetches the current bill amount for the period.
Future<double> getCurrentBillAmount();
/// Fetches the current bill amount in cents for the period.
Future<int> getCurrentBillCents();
/// Fetches the savings amount.
Future<double> getSavingsAmount();
/// Fetches the savings amount in cents.
Future<int> getSavingsCents();
/// Fetches invoice items for spending breakdown analysis.
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period);
/// Fetches spending breakdown by category for a date range.
Future<List<SpendItem>> getSpendBreakdown({
required String startDate,
required String endDate,
});
/// Approves an invoice.
Future<void> approveInvoice(String id);

View File

@@ -1,11 +1,13 @@
import 'package:krow_core/core.dart';
import '../repositories/billing_repository.dart';
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Use case for approving an invoice.
class ApproveInvoiceUseCase extends UseCase<String, void> {
/// Creates an [ApproveInvoiceUseCase].
ApproveInvoiceUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override

View File

@@ -1,10 +1,16 @@
import 'package:krow_core/core.dart';
import '../repositories/billing_repository.dart';
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Params for [DisputeInvoiceUseCase].
class DisputeInvoiceParams {
/// Creates [DisputeInvoiceParams].
const DisputeInvoiceParams({required this.id, required this.reason});
/// The invoice ID to dispute.
final String id;
/// The reason for the dispute.
final String reason;
}
@@ -13,6 +19,7 @@ class DisputeInvoiceUseCase extends UseCase<DisputeInvoiceParams, void> {
/// Creates a [DisputeInvoiceUseCase].
DisputeInvoiceUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override

View File

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

View File

@@ -1,16 +1,17 @@
import 'package:krow_core/core.dart';
import '../repositories/billing_repository.dart';
/// Use case for fetching the current bill amount.
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Use case for fetching the current bill amount in cents.
///
/// This use case encapsulates the logic for retrieving the total amount due for the current billing period.
/// It delegates the data retrieval to the [BillingRepository].
class GetCurrentBillAmountUseCase extends NoInputUseCase<double> {
/// Delegates data retrieval to the [BillingRepository].
class GetCurrentBillAmountUseCase extends NoInputUseCase<int> {
/// Creates a [GetCurrentBillAmountUseCase].
GetCurrentBillAmountUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override
Future<double> call() => _repository.getCurrentBillAmount();
Future<int> call() => _repository.getCurrentBillCents();
}

View File

@@ -1,15 +1,16 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/billing_repository.dart';
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Use case for fetching the invoice history.
///
/// This use case encapsulates the logic for retrieving the list of past paid invoices.
/// It delegates the data retrieval to the [BillingRepository].
/// Retrieves the list of past paid invoices.
class GetInvoiceHistoryUseCase extends NoInputUseCase<List<Invoice>> {
/// Creates a [GetInvoiceHistoryUseCase].
GetInvoiceHistoryUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override

View File

@@ -1,15 +1,16 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/billing_repository.dart';
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Use case for fetching the pending invoices.
///
/// This use case encapsulates the logic for retrieving invoices that are currently open or disputed.
/// It delegates the data retrieval to the [BillingRepository].
/// Retrieves invoices that are currently open or disputed.
class GetPendingInvoicesUseCase extends NoInputUseCase<List<Invoice>> {
/// Creates a [GetPendingInvoicesUseCase].
GetPendingInvoicesUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override

View File

@@ -1,16 +1,17 @@
import 'package:krow_core/core.dart';
import '../repositories/billing_repository.dart';
/// Use case for fetching the savings amount.
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Use case for fetching the savings amount in cents.
///
/// This use case encapsulates the logic for retrieving the estimated savings for the client.
/// It delegates the data retrieval to the [BillingRepository].
class GetSavingsAmountUseCase extends NoInputUseCase<double> {
/// Delegates data retrieval to the [BillingRepository].
class GetSavingsAmountUseCase extends NoInputUseCase<int> {
/// Creates a [GetSavingsAmountUseCase].
GetSavingsAmountUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override
Future<double> call() => _repository.getSavingsAmount();
Future<int> call() => _repository.getSavingsCents();
}

View File

@@ -1,19 +1,38 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/billing_repository.dart';
/// Use case for fetching the spending breakdown items.
import 'package:billing/src/domain/repositories/billing_repository.dart';
/// Parameters for [GetSpendBreakdownUseCase].
class SpendBreakdownParams {
/// Creates [SpendBreakdownParams].
const SpendBreakdownParams({
required this.startDate,
required this.endDate,
});
/// ISO-8601 start date for the range.
final String startDate;
/// ISO-8601 end date for the range.
final String endDate;
}
/// Use case for fetching the spending breakdown by category.
///
/// This use case encapsulates the logic for retrieving the spending breakdown by category or item.
/// It delegates the data retrieval to the [BillingRepository].
class GetSpendingBreakdownUseCase
extends UseCase<BillingPeriod, List<InvoiceItem>> {
/// Creates a [GetSpendingBreakdownUseCase].
GetSpendingBreakdownUseCase(this._repository);
/// Delegates data retrieval to the [BillingRepository].
class GetSpendBreakdownUseCase
extends UseCase<SpendBreakdownParams, List<SpendItem>> {
/// Creates a [GetSpendBreakdownUseCase].
GetSpendBreakdownUseCase(this._repository);
/// The billing repository.
final BillingRepository _repository;
@override
Future<List<InvoiceItem>> call(BillingPeriod period) =>
_repository.getSpendingBreakdown(period);
Future<List<SpendItem>> call(SpendBreakdownParams input) =>
_repository.getSpendBreakdown(
startDate: input.startDate,
endDate: input.endDate,
);
}

View File

@@ -1,17 +1,17 @@
import 'dart:developer' as developer;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/usecases/get_bank_accounts.dart';
import '../../domain/usecases/get_current_bill_amount.dart';
import '../../domain/usecases/get_invoice_history.dart';
import '../../domain/usecases/get_pending_invoices.dart';
import '../../domain/usecases/get_savings_amount.dart';
import '../../domain/usecases/get_spending_breakdown.dart';
import '../models/billing_invoice_model.dart';
import '../models/spending_breakdown_model.dart';
import 'billing_event.dart';
import 'billing_state.dart';
import 'package:billing/src/domain/usecases/get_bank_accounts.dart';
import 'package:billing/src/domain/usecases/get_current_bill_amount.dart';
import 'package:billing/src/domain/usecases/get_invoice_history.dart';
import 'package:billing/src/domain/usecases/get_pending_invoices.dart';
import 'package:billing/src/domain/usecases/get_savings_amount.dart';
import 'package:billing/src/domain/usecases/get_spending_breakdown.dart';
import 'package:billing/src/presentation/blocs/billing_event.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
/// BLoC for managing billing state and data loading.
class BillingBloc extends Bloc<BillingEvent, BillingState>
@@ -23,14 +23,14 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
required GetSavingsAmountUseCase getSavingsAmount,
required GetPendingInvoicesUseCase getPendingInvoices,
required GetInvoiceHistoryUseCase getInvoiceHistory,
required GetSpendingBreakdownUseCase getSpendingBreakdown,
}) : _getBankAccounts = getBankAccounts,
_getCurrentBillAmount = getCurrentBillAmount,
_getSavingsAmount = getSavingsAmount,
_getPendingInvoices = getPendingInvoices,
_getInvoiceHistory = getInvoiceHistory,
_getSpendingBreakdown = getSpendingBreakdown,
super(const BillingState()) {
required GetSpendBreakdownUseCase getSpendBreakdown,
}) : _getBankAccounts = getBankAccounts,
_getCurrentBillAmount = getCurrentBillAmount,
_getSavingsAmount = getSavingsAmount,
_getPendingInvoices = getPendingInvoices,
_getInvoiceHistory = getInvoiceHistory,
_getSpendBreakdown = getSpendBreakdown,
super(const BillingState()) {
on<BillingLoadStarted>(_onLoadStarted);
on<BillingPeriodChanged>(_onPeriodChanged);
}
@@ -40,61 +40,60 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
final GetSavingsAmountUseCase _getSavingsAmount;
final GetPendingInvoicesUseCase _getPendingInvoices;
final GetInvoiceHistoryUseCase _getInvoiceHistory;
final GetSpendingBreakdownUseCase _getSpendingBreakdown;
final GetSpendBreakdownUseCase _getSpendBreakdown;
/// Executes [loader] and returns null on failure, logging the error.
Future<T?> _loadSafe<T>(Future<T> Function() loader) async {
try {
return await loader();
} catch (e, stackTrace) {
developer.log(
'Partial billing load failed: $e',
name: 'BillingBloc',
error: e,
stackTrace: stackTrace,
);
return null;
}
}
Future<void> _onLoadStarted(
BillingLoadStarted event,
Emitter<BillingState> emit,
) async {
emit(state.copyWith(status: BillingStatus.loading));
await handleError(
emit: emit.call,
action: () async {
final List<dynamic> results =
await Future.wait<dynamic>(<Future<dynamic>>[
_getCurrentBillAmount.call(),
_getSavingsAmount.call(),
_getPendingInvoices.call(),
_getInvoiceHistory.call(),
_getSpendingBreakdown.call(state.period),
_getBankAccounts.call(),
]);
final double savings = results[1] as double;
final List<Invoice> pendingInvoices = results[2] as List<Invoice>;
final List<Invoice> invoiceHistory = results[3] as List<Invoice>;
final List<InvoiceItem> spendingItems = results[4] as List<InvoiceItem>;
final List<BusinessBankAccount> bankAccounts =
results[5] as List<BusinessBankAccount>;
final SpendBreakdownParams spendParams = _dateRangeFor(state.periodTab);
// Map Domain Entities to Presentation Models
final List<BillingInvoice> uiPendingInvoices = pendingInvoices
.map(_mapInvoiceToUiModel)
.toList();
final List<BillingInvoice> uiInvoiceHistory = invoiceHistory
.map(_mapInvoiceToUiModel)
.toList();
final List<SpendingBreakdownItem> uiSpendingBreakdown =
_mapSpendingItemsToUiModel(spendingItems);
final double periodTotal = uiSpendingBreakdown.fold(
0.0,
(double sum, SpendingBreakdownItem item) => sum + item.amount,
);
final List<Object?> results = await Future.wait<Object?>(
<Future<Object?>>[
_loadSafe<int>(() => _getCurrentBillAmount.call()),
_loadSafe<int>(() => _getSavingsAmount.call()),
_loadSafe<List<Invoice>>(() => _getPendingInvoices.call()),
_loadSafe<List<Invoice>>(() => _getInvoiceHistory.call()),
_loadSafe<List<SpendItem>>(() => _getSpendBreakdown.call(spendParams)),
_loadSafe<List<BillingAccount>>(() => _getBankAccounts.call()),
],
);
emit(
state.copyWith(
status: BillingStatus.success,
currentBill: periodTotal,
savings: savings,
pendingInvoices: uiPendingInvoices,
invoiceHistory: uiInvoiceHistory,
spendingBreakdown: uiSpendingBreakdown,
bankAccounts: bankAccounts,
),
);
},
onError: (String errorKey) =>
state.copyWith(status: BillingStatus.failure, errorMessage: errorKey),
final int? currentBillCents = results[0] as int?;
final int? savingsCents = results[1] as int?;
final List<Invoice>? pendingInvoices = results[2] as List<Invoice>?;
final List<Invoice>? invoiceHistory = results[3] as List<Invoice>?;
final List<SpendItem>? spendBreakdown = results[4] as List<SpendItem>?;
final List<BillingAccount>? bankAccounts =
results[5] as List<BillingAccount>?;
emit(
state.copyWith(
status: BillingStatus.success,
currentBillCents: currentBillCents ?? state.currentBillCents,
savingsCents: savingsCents ?? state.savingsCents,
pendingInvoices: pendingInvoices ?? state.pendingInvoices,
invoiceHistory: invoiceHistory ?? state.invoiceHistory,
spendBreakdown: spendBreakdown ?? state.spendBreakdown,
bankAccounts: bankAccounts ?? state.bankAccounts,
),
);
}
@@ -105,19 +104,15 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
await handleError(
emit: emit.call,
action: () async {
final List<InvoiceItem> spendingItems = await _getSpendingBreakdown
.call(event.period);
final List<SpendingBreakdownItem> uiSpendingBreakdown =
_mapSpendingItemsToUiModel(spendingItems);
final double periodTotal = uiSpendingBreakdown.fold(
0.0,
(double sum, SpendingBreakdownItem item) => sum + item.amount,
);
final SpendBreakdownParams params =
_dateRangeFor(event.periodTab);
final List<SpendItem> spendBreakdown =
await _getSpendBreakdown.call(params);
emit(
state.copyWith(
period: event.period,
spendingBreakdown: uiSpendingBreakdown,
currentBill: periodTotal,
periodTab: event.periodTab,
spendBreakdown: spendBreakdown,
),
);
},
@@ -126,98 +121,14 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
);
}
BillingInvoice _mapInvoiceToUiModel(Invoice invoice) {
final DateFormat formatter = DateFormat('EEEE, MMMM d');
final String dateLabel = invoice.issueDate == null
? 'N/A'
: formatter.format(invoice.issueDate!);
final List<BillingWorkerRecord> workers = invoice.workers.map((
InvoiceWorker w,
) {
final DateFormat timeFormat = DateFormat('h:mm a');
return BillingWorkerRecord(
workerName: w.name,
roleName: w.role,
totalAmount: w.amount,
hours: w.hours,
rate: w.rate,
startTime: w.checkIn != null ? timeFormat.format(w.checkIn!) : '--:--',
endTime: w.checkOut != null ? timeFormat.format(w.checkOut!) : '--:--',
breakMinutes: w.breakMinutes,
workerAvatarUrl: w.avatarUrl,
);
}).toList();
String? overallStart;
String? overallEnd;
// Find valid times from actual DateTime checks to ensure chronological sorting
final List<DateTime> validCheckIns = invoice.workers
.where((InvoiceWorker w) => w.checkIn != null)
.map((InvoiceWorker w) => w.checkIn!)
.toList();
final List<DateTime> validCheckOuts = invoice.workers
.where((InvoiceWorker w) => w.checkOut != null)
.map((InvoiceWorker w) => w.checkOut!)
.toList();
final DateFormat timeFormat = DateFormat('h:mm a');
if (validCheckIns.isNotEmpty) {
validCheckIns.sort();
overallStart = timeFormat.format(validCheckIns.first);
} else if (workers.isNotEmpty) {
overallStart = workers.first.startTime;
}
if (validCheckOuts.isNotEmpty) {
validCheckOuts.sort();
overallEnd = timeFormat.format(validCheckOuts.last);
} else if (workers.isNotEmpty) {
overallEnd = workers.first.endTime;
}
return BillingInvoice(
id: invoice.id,
title: invoice.title ?? 'N/A',
locationAddress: invoice.locationAddress ?? 'Remote',
clientName: invoice.clientName ?? 'N/A',
date: dateLabel,
totalAmount: invoice.totalAmount,
workersCount: invoice.staffCount ?? 0,
totalHours: invoice.totalHours ?? 0.0,
status: invoice.status.name.toUpperCase(),
workers: workers,
startTime: overallStart,
endTime: overallEnd,
/// Computes ISO-8601 date range for the selected period tab.
SpendBreakdownParams _dateRangeFor(BillingPeriodTab tab) {
final DateTime now = DateTime.now().toUtc();
final int days = tab == BillingPeriodTab.week ? 7 : 30;
final DateTime start = now.subtract(Duration(days: days));
return SpendBreakdownParams(
startDate: start.toIso8601String(),
endDate: now.toIso8601String(),
);
}
List<SpendingBreakdownItem> _mapSpendingItemsToUiModel(
List<InvoiceItem> items,
) {
final Map<String, SpendingBreakdownItem> aggregation =
<String, SpendingBreakdownItem>{};
for (final InvoiceItem item in items) {
final String category = item.staffId;
final SpendingBreakdownItem? existing = aggregation[category];
if (existing != null) {
aggregation[category] = SpendingBreakdownItem(
category: category,
hours: existing.hours + item.workHours.round(),
amount: existing.amount + item.amount,
);
} else {
aggregation[category] = SpendingBreakdownItem(
category: category,
hours: item.workHours.round(),
amount: item.amount,
);
}
}
return aggregation.values.toList();
}
}

View File

@@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
/// Base class for all billing events.
abstract class BillingEvent extends Equatable {
@@ -16,11 +17,14 @@ class BillingLoadStarted extends BillingEvent {
const BillingLoadStarted();
}
/// Event triggered when the spend breakdown period tab changes.
class BillingPeriodChanged extends BillingEvent {
const BillingPeriodChanged(this.period);
/// Creates a [BillingPeriodChanged] event.
const BillingPeriodChanged(this.periodTab);
final BillingPeriod period;
/// The selected period tab.
final BillingPeriodTab periodTab;
@override
List<Object?> get props => <Object?>[period];
List<Object?> get props => <Object?>[periodTab];
}

View File

@@ -1,7 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
import '../models/billing_invoice_model.dart';
import '../models/spending_breakdown_model.dart';
/// The loading status of the billing feature.
enum BillingStatus {
@@ -18,83 +16,104 @@ enum BillingStatus {
failure,
}
/// Which period the spend breakdown covers.
enum BillingPeriodTab {
/// Last 7 days.
week,
/// Last 30 days.
month,
}
/// Represents the state of the billing feature.
class BillingState extends Equatable {
/// Creates a [BillingState].
const BillingState({
this.status = BillingStatus.initial,
this.currentBill = 0.0,
this.savings = 0.0,
this.pendingInvoices = const <BillingInvoice>[],
this.invoiceHistory = const <BillingInvoice>[],
this.spendingBreakdown = const <SpendingBreakdownItem>[],
this.bankAccounts = const <BusinessBankAccount>[],
this.period = BillingPeriod.week,
this.currentBillCents = 0,
this.savingsCents = 0,
this.pendingInvoices = const <Invoice>[],
this.invoiceHistory = const <Invoice>[],
this.spendBreakdown = const <SpendItem>[],
this.bankAccounts = const <BillingAccount>[],
this.periodTab = BillingPeriodTab.week,
this.errorMessage,
});
/// The current feature status.
final BillingStatus status;
/// The total amount for the current billing period.
final double currentBill;
/// The total amount for the current billing period in cents.
final int currentBillCents;
/// Total savings achieved compared to traditional agencies.
final double savings;
/// Total savings in cents.
final int savingsCents;
/// Invoices awaiting client approval.
final List<BillingInvoice> pendingInvoices;
final List<Invoice> pendingInvoices;
/// History of paid invoices.
final List<BillingInvoice> invoiceHistory;
final List<Invoice> invoiceHistory;
/// Breakdown of spending by category.
final List<SpendingBreakdownItem> spendingBreakdown;
final List<SpendItem> spendBreakdown;
/// Bank accounts associated with the business.
final List<BusinessBankAccount> bankAccounts;
final List<BillingAccount> bankAccounts;
/// Selected period for the breakdown.
final BillingPeriod period;
/// Selected period tab for the breakdown.
final BillingPeriodTab periodTab;
/// Error message if loading failed.
final String? errorMessage;
/// Current bill formatted as dollars.
double get currentBillDollars => currentBillCents / 100.0;
/// Savings formatted as dollars.
double get savingsDollars => savingsCents / 100.0;
/// Total spend across the breakdown in cents.
int get spendTotalCents => spendBreakdown.fold(
0,
(int sum, SpendItem item) => sum + item.amountCents,
);
/// Creates a copy of this state with updated fields.
BillingState copyWith({
BillingStatus? status,
double? currentBill,
double? savings,
List<BillingInvoice>? pendingInvoices,
List<BillingInvoice>? invoiceHistory,
List<SpendingBreakdownItem>? spendingBreakdown,
List<BusinessBankAccount>? bankAccounts,
BillingPeriod? period,
int? currentBillCents,
int? savingsCents,
List<Invoice>? pendingInvoices,
List<Invoice>? invoiceHistory,
List<SpendItem>? spendBreakdown,
List<BillingAccount>? bankAccounts,
BillingPeriodTab? periodTab,
String? errorMessage,
}) {
return BillingState(
status: status ?? this.status,
currentBill: currentBill ?? this.currentBill,
savings: savings ?? this.savings,
currentBillCents: currentBillCents ?? this.currentBillCents,
savingsCents: savingsCents ?? this.savingsCents,
pendingInvoices: pendingInvoices ?? this.pendingInvoices,
invoiceHistory: invoiceHistory ?? this.invoiceHistory,
spendingBreakdown: spendingBreakdown ?? this.spendingBreakdown,
spendBreakdown: spendBreakdown ?? this.spendBreakdown,
bankAccounts: bankAccounts ?? this.bankAccounts,
period: period ?? this.period,
periodTab: periodTab ?? this.periodTab,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => <Object?>[
status,
currentBill,
savings,
pendingInvoices,
invoiceHistory,
spendingBreakdown,
bankAccounts,
period,
errorMessage,
];
status,
currentBillCents,
savingsCents,
pendingInvoices,
invoiceHistory,
spendBreakdown,
bankAccounts,
periodTab,
errorMessage,
];
}

View File

@@ -1,19 +1,22 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import '../../../domain/usecases/approve_invoice.dart';
import '../../../domain/usecases/dispute_invoice.dart';
import 'shift_completion_review_event.dart';
import 'shift_completion_review_state.dart';
import 'package:billing/src/domain/usecases/approve_invoice.dart';
import 'package:billing/src/domain/usecases/dispute_invoice.dart';
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_event.dart';
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_state.dart';
/// BLoC for approving or disputing an invoice from the review page.
class ShiftCompletionReviewBloc
extends Bloc<ShiftCompletionReviewEvent, ShiftCompletionReviewState>
with BlocErrorHandler<ShiftCompletionReviewState> {
/// Creates a [ShiftCompletionReviewBloc].
ShiftCompletionReviewBloc({
required ApproveInvoiceUseCase approveInvoice,
required DisputeInvoiceUseCase disputeInvoice,
}) : _approveInvoice = approveInvoice,
_disputeInvoice = disputeInvoice,
super(const ShiftCompletionReviewState()) {
}) : _approveInvoice = approveInvoice,
_disputeInvoice = disputeInvoice,
super(const ShiftCompletionReviewState()) {
on<ShiftCompletionReviewApproved>(_onApproved);
on<ShiftCompletionReviewDisputed>(_onDisputed);
}

View File

@@ -1,84 +0,0 @@
import 'package:equatable/equatable.dart';
class BillingInvoice extends Equatable {
const BillingInvoice({
required this.id,
required this.title,
required this.locationAddress,
required this.clientName,
required this.date,
required this.totalAmount,
required this.workersCount,
required this.totalHours,
required this.status,
this.workers = const <BillingWorkerRecord>[],
this.startTime,
this.endTime,
});
final String id;
final String title;
final String locationAddress;
final String clientName;
final String date;
final double totalAmount;
final int workersCount;
final double totalHours;
final String status;
final List<BillingWorkerRecord> workers;
final String? startTime;
final String? endTime;
@override
List<Object?> get props => <Object?>[
id,
title,
locationAddress,
clientName,
date,
totalAmount,
workersCount,
totalHours,
status,
workers,
startTime,
endTime,
];
}
class BillingWorkerRecord extends Equatable {
const BillingWorkerRecord({
required this.workerName,
required this.roleName,
required this.totalAmount,
required this.hours,
required this.rate,
required this.startTime,
required this.endTime,
required this.breakMinutes,
this.workerAvatarUrl,
});
final String workerName;
final String roleName;
final double totalAmount;
final double hours;
final double rate;
final String startTime;
final String endTime;
final int breakMinutes;
final String? workerAvatarUrl;
@override
List<Object?> get props => <Object?>[
workerName,
roleName,
totalAmount,
hours,
rate,
startTime,
endTime,
breakMinutes,
workerAvatarUrl,
];
}

View File

@@ -1,23 +0,0 @@
import 'package:equatable/equatable.dart';
/// Represents a single item in the spending breakdown.
class SpendingBreakdownItem extends Equatable {
/// Creates a [SpendingBreakdownItem].
const SpendingBreakdownItem({
required this.category,
required this.hours,
required this.amount,
});
/// The category name (e.g., "Server Staff").
final String category;
/// The total hours worked in this category.
final int hours;
/// The total amount spent in this category.
final double amount;
@override
List<Object?> get props => <Object?>[category, hours, amount];
}

View File

@@ -5,13 +5,13 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_event.dart';
import '../blocs/billing_state.dart';
import '../widgets/billing_page_skeleton.dart';
import '../widgets/invoice_history_section.dart';
import '../widgets/pending_invoices_section.dart';
import '../widgets/spending_breakdown_card.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/billing_event.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
import 'package:billing/src/presentation/widgets/billing_page_skeleton.dart';
import 'package:billing/src/presentation/widgets/invoice_history_section.dart';
import 'package:billing/src/presentation/widgets/pending_invoices_section.dart';
import 'package:billing/src/presentation/widgets/spending_breakdown_card.dart';
/// The entry point page for the client billing feature.
///
@@ -32,8 +32,7 @@ class BillingPage extends StatelessWidget {
/// The main view for the client billing feature.
///
/// This widget displays the billing dashboard content based on the current
/// state of the [BillingBloc].
/// Displays the billing dashboard content based on the current [BillingState].
class BillingView extends StatefulWidget {
/// Creates a [BillingView].
const BillingView({super.key});
@@ -125,7 +124,7 @@ class _BillingViewState extends State<BillingView> {
),
const SizedBox(height: UiConstants.space1),
Text(
'\$${state.currentBill.toStringAsFixed(2)}',
'\$${state.currentBillDollars.toStringAsFixed(2)}',
style: UiTypography.displayM.copyWith(
color: UiColors.white,
fontSize: 40,
@@ -152,7 +151,8 @@ class _BillingViewState extends State<BillingView> {
const SizedBox(width: UiConstants.space2),
Text(
t.client_billing.saved_amount(
amount: state.savings.toStringAsFixed(0),
amount: state.savingsDollars
.toStringAsFixed(0),
),
style: UiTypography.footnote2b.copyWith(
color: UiColors.accentForeground,
@@ -221,7 +221,6 @@ class _BillingViewState extends State<BillingView> {
if (state.pendingInvoices.isNotEmpty) ...<Widget>[
PendingInvoicesSection(invoices: state.pendingInvoices),
],
// const PaymentMethodCard(),
const SpendingBreakdownCard(),
if (state.invoiceHistory.isNotEmpty)
InvoiceHistorySection(invoices: state.invoiceHistory),

View File

@@ -1,19 +1,21 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import '../models/billing_invoice_model.dart';
import '../widgets/completion_review/completion_review_actions.dart';
import '../widgets/completion_review/completion_review_amount.dart';
import '../widgets/completion_review/completion_review_info.dart';
import '../widgets/completion_review/completion_review_search_and_tabs.dart';
import '../widgets/completion_review/completion_review_worker_card.dart';
import '../widgets/completion_review/completion_review_workers_header.dart';
import 'package:billing/src/presentation/widgets/completion_review/completion_review_actions.dart';
import 'package:billing/src/presentation/widgets/completion_review/completion_review_amount.dart';
import 'package:billing/src/presentation/widgets/completion_review/completion_review_info.dart';
/// Page for reviewing and approving/disputing an invoice.
class ShiftCompletionReviewPage extends StatefulWidget {
/// Creates a [ShiftCompletionReviewPage].
const ShiftCompletionReviewPage({this.invoice, super.key});
final BillingInvoice? invoice;
/// The invoice to review.
final Invoice? invoice;
@override
State<ShiftCompletionReviewPage> createState() =>
@@ -21,31 +23,45 @@ class ShiftCompletionReviewPage extends StatefulWidget {
}
class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
late BillingInvoice invoice;
String searchQuery = '';
int selectedTab = 0; // 0: Needs Review (mocked as empty), 1: All
/// The resolved invoice, or null if route data is missing/invalid.
late final Invoice? invoice;
@override
void initState() {
super.initState();
// Use widget.invoice if provided, else try to get from arguments
invoice = widget.invoice ?? Modular.args.data as BillingInvoice;
invoice = widget.invoice ??
(Modular.args.data is Invoice
? Modular.args.data as Invoice
: null);
}
@override
Widget build(BuildContext context) {
final List<BillingWorkerRecord> filteredWorkers = invoice.workers.where((
BillingWorkerRecord w,
) {
if (searchQuery.isEmpty) return true;
return w.workerName.toLowerCase().contains(searchQuery.toLowerCase()) ||
w.roleName.toLowerCase().contains(searchQuery.toLowerCase());
}).toList();
final Invoice? resolvedInvoice = invoice;
if (resolvedInvoice == null) {
return Scaffold(
appBar: UiAppBar(
title: t.client_billing.review_and_approve,
showBackButton: true,
),
body: Center(
child: Text(
t.errors.generic.unknown,
style: UiTypography.body1m.textError,
),
),
);
}
final DateFormat formatter = DateFormat('EEEE, MMMM d');
final String dateLabel = resolvedInvoice.dueDate != null
? formatter.format(resolvedInvoice.dueDate!)
: 'N/A';
return Scaffold(
appBar: UiAppBar(
title: invoice.title,
subtitle: invoice.clientName,
title: resolvedInvoice.invoiceNumber,
subtitle: resolvedInvoice.vendorName ?? '',
showBackButton: true,
),
body: SafeArea(
@@ -55,26 +71,13 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const SizedBox(height: UiConstants.space4),
CompletionReviewInfo(invoice: invoice),
CompletionReviewInfo(
dateLabel: dateLabel,
vendorName: resolvedInvoice.vendorName,
),
const SizedBox(height: UiConstants.space4),
CompletionReviewAmount(invoice: invoice),
CompletionReviewAmount(amountCents: resolvedInvoice.amountCents),
const SizedBox(height: UiConstants.space6),
// CompletionReviewWorkersHeader(workersCount: invoice.workersCount),
// const SizedBox(height: UiConstants.space4),
// CompletionReviewSearchAndTabs(
// selectedTab: selectedTab,
// workersCount: invoice.workersCount,
// onTabChanged: (int index) =>
// setState(() => selectedTab = index),
// onSearchChanged: (String val) =>
// setState(() => searchQuery = val),
// ),
// const SizedBox(height: UiConstants.space4),
// ...filteredWorkers.map(
// (BillingWorkerRecord worker) =>
// CompletionReviewWorkerCard(worker: worker),
// ),
// const SizedBox(height: UiConstants.space4),
],
),
),
@@ -87,7 +90,9 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
),
),
child: SafeArea(child: CompletionReviewActions(invoiceId: invoice.id)),
child: SafeArea(
child: CompletionReviewActions(invoiceId: resolvedInvoice.invoiceId),
),
),
);
}

View File

@@ -2,14 +2,17 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_event.dart';
import '../blocs/billing_state.dart';
import '../models/billing_invoice_model.dart';
import '../widgets/invoices_list_skeleton.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/billing_event.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
import 'package:billing/src/presentation/widgets/invoices_list_skeleton.dart';
/// Page displaying invoices that are ready.
class InvoiceReadyPage extends StatelessWidget {
/// Creates an [InvoiceReadyPage].
const InvoiceReadyPage({super.key});
@override
@@ -21,7 +24,9 @@ class InvoiceReadyPage extends StatelessWidget {
}
}
/// View for the invoice ready page.
class InvoiceReadyView extends StatelessWidget {
/// Creates an [InvoiceReadyView].
const InvoiceReadyView({super.key});
@override
@@ -60,7 +65,7 @@ class InvoiceReadyView extends StatelessWidget {
separatorBuilder: (BuildContext context, int index) =>
const SizedBox(height: 16),
itemBuilder: (BuildContext context, int index) {
final BillingInvoice invoice = state.invoiceHistory[index];
final Invoice invoice = state.invoiceHistory[index];
return _InvoiceSummaryCard(invoice: invoice);
},
);
@@ -72,10 +77,17 @@ class InvoiceReadyView extends StatelessWidget {
class _InvoiceSummaryCard extends StatelessWidget {
const _InvoiceSummaryCard({required this.invoice});
final BillingInvoice invoice;
final Invoice invoice;
@override
Widget build(BuildContext context) {
final DateFormat formatter = DateFormat('MMM d, yyyy');
final String dateLabel = invoice.dueDate != null
? formatter.format(invoice.dueDate!)
: 'N/A';
final double amountDollars = invoice.amountCents / 100.0;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
@@ -106,22 +118,26 @@ class _InvoiceSummaryCard extends StatelessWidget {
borderRadius: BorderRadius.circular(20),
),
child: Text(
'READY',
invoice.status.value.toUpperCase(),
style: UiTypography.titleUppercase4b.copyWith(
color: UiColors.success,
),
),
),
Text(invoice.date, style: UiTypography.footnote2r.textTertiary),
Text(dateLabel, style: UiTypography.footnote2r.textTertiary),
],
),
const SizedBox(height: 16),
Text(invoice.title, style: UiTypography.title2b.textPrimary),
const SizedBox(height: 8),
Text(
invoice.locationAddress,
style: UiTypography.body2r.textSecondary,
invoice.invoiceNumber,
style: UiTypography.title2b.textPrimary,
),
const SizedBox(height: 8),
if (invoice.vendorName != null)
Text(
invoice.vendorName!,
style: UiTypography.body2r.textSecondary,
),
const Divider(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -134,7 +150,7 @@ class _InvoiceSummaryCard extends StatelessWidget {
style: UiTypography.titleUppercase4m.textSecondary,
),
Text(
'\$${invoice.totalAmount.toStringAsFixed(2)}',
'\$${amountDollars.toStringAsFixed(2)}',
style: UiTypography.title2b.primary,
),
],

View File

@@ -5,12 +5,14 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_state.dart';
import '../widgets/invoices_list_skeleton.dart';
import '../widgets/pending_invoices_section.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
import 'package:billing/src/presentation/widgets/invoices_list_skeleton.dart';
import 'package:billing/src/presentation/widgets/pending_invoices_section.dart';
/// Page listing all invoices awaiting client approval.
class PendingInvoicesPage extends StatelessWidget {
/// Creates a [PendingInvoicesPage].
const PendingInvoicesPage({super.key});
@override
@@ -44,7 +46,7 @@ class PendingInvoicesPage extends StatelessWidget {
UiConstants.space5,
UiConstants.space5,
UiConstants.space5,
100, // Bottom padding for scroll clearance
100,
),
itemCount: state.pendingInvoices.length,
itemBuilder: (BuildContext context, int index) {
@@ -87,6 +89,3 @@ class PendingInvoicesPage extends StatelessWidget {
);
}
}
// We need to export the card widget from the section file if we want to reuse it,
// or move it to its own file. I'll move it to a shared file or just make it public in the section file.

View File

@@ -6,23 +6,26 @@ import 'package:flutter/material.dart';
class BillingHeader extends StatelessWidget {
/// Creates a [BillingHeader].
const BillingHeader({
required this.currentBill,
required this.savings,
required this.currentBillCents,
required this.savingsCents,
required this.onBack,
super.key,
});
/// The amount of the current bill.
final double currentBill;
/// The amount of the current bill in cents.
final int currentBillCents;
/// The amount saved in the current period.
final double savings;
/// The savings amount in cents.
final int savingsCents;
/// Callback when the back button is pressed.
final VoidCallback onBack;
@override
Widget build(BuildContext context) {
final double billDollars = currentBillCents / 100.0;
final double savingsDollars = savingsCents / 100.0;
return Container(
padding: EdgeInsets.fromLTRB(
UiConstants.space5,
@@ -54,10 +57,9 @@ class BillingHeader extends StatelessWidget {
),
const SizedBox(height: UiConstants.space1),
Text(
'\$${currentBill.toStringAsFixed(2)}',
'\$${billDollars.toStringAsFixed(2)}',
style: UiTypography.display1b.copyWith(color: UiColors.white),
),
const SizedBox(height: UiConstants.space2),
Container(
padding: const EdgeInsets.symmetric(
@@ -79,7 +81,7 @@ class BillingHeader extends StatelessWidget {
const SizedBox(width: UiConstants.space1),
Text(
t.client_billing.saved_amount(
amount: savings.toStringAsFixed(0),
amount: savingsDollars.toStringAsFixed(0),
),
style: UiTypography.footnote2b.copyWith(
color: UiColors.foreground,

View File

@@ -5,87 +5,91 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../../blocs/shift_completion_review/shift_completion_review_bloc.dart';
import '../../blocs/shift_completion_review/shift_completion_review_event.dart';
import '../../blocs/shift_completion_review/shift_completion_review_state.dart';
import '../../blocs/billing_bloc.dart';
import '../../blocs/billing_event.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/billing_event.dart';
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_event.dart';
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_state.dart';
/// Action buttons (approve / flag) at the bottom of the review page.
class CompletionReviewActions extends StatelessWidget {
/// Creates a [CompletionReviewActions].
const CompletionReviewActions({required this.invoiceId, super.key});
/// The invoice ID to act upon.
final String invoiceId;
@override
Widget build(BuildContext context) {
return BlocProvider<ShiftCompletionReviewBloc>.value(
value: Modular.get<ShiftCompletionReviewBloc>(),
return BlocProvider<ShiftCompletionReviewBloc>(
create: (_) => Modular.get<ShiftCompletionReviewBloc>(),
child:
BlocConsumer<ShiftCompletionReviewBloc, ShiftCompletionReviewState>(
listener: (BuildContext context, ShiftCompletionReviewState state) {
if (state.status == ShiftCompletionReviewStatus.success) {
final String message = state.message == 'approved'
? t.client_billing.approved_success
: t.client_billing.flagged_success;
final UiSnackbarType type = state.message == 'approved'
? UiSnackbarType.success
: UiSnackbarType.warning;
listener: (BuildContext context, ShiftCompletionReviewState state) {
if (state.status == ShiftCompletionReviewStatus.success) {
final String message = state.message == 'approved'
? t.client_billing.approved_success
: t.client_billing.flagged_success;
final UiSnackbarType type = state.message == 'approved'
? UiSnackbarType.success
: UiSnackbarType.warning;
UiSnackbar.show(context, message: message, type: type);
Modular.get<BillingBloc>().add(const BillingLoadStarted());
Modular.to.toAwaitingApproval();
} else if (state.status == ShiftCompletionReviewStatus.failure) {
UiSnackbar.show(
context,
message: state.errorMessage ?? t.errors.generic.unknown,
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, ShiftCompletionReviewState state) {
final bool isLoading =
state.status == ShiftCompletionReviewStatus.loading;
UiSnackbar.show(context, message: message, type: type);
Modular.get<BillingBloc>().add(const BillingLoadStarted());
Modular.to.toAwaitingApproval();
} else if (state.status == ShiftCompletionReviewStatus.failure) {
UiSnackbar.show(
context,
message: state.errorMessage ?? t.errors.generic.unknown,
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, ShiftCompletionReviewState state) {
final bool isLoading =
state.status == ShiftCompletionReviewStatus.loading;
return Row(
spacing: UiConstants.space2,
children: <Widget>[
Expanded(
child: UiButton.secondary(
text: t.client_billing.actions.flag_review,
leadingIcon: UiIcons.warning,
onPressed: isLoading
? null
: () => _showFlagDialog(context, state),
size: UiButtonSize.large,
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.destructive,
side: BorderSide.none,
),
),
return Row(
spacing: UiConstants.space2,
children: <Widget>[
Expanded(
child: UiButton.secondary(
text: t.client_billing.actions.flag_review,
leadingIcon: UiIcons.warning,
onPressed: isLoading
? null
: () => _showFlagDialog(context, state),
size: UiButtonSize.large,
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.destructive,
side: BorderSide.none,
),
Expanded(
child: UiButton.primary(
text: t.client_billing.actions.approve_pay,
leadingIcon: isLoading ? null : UiIcons.checkCircle,
isLoading: isLoading,
onPressed: isLoading
? null
: () {
BlocProvider.of<ShiftCompletionReviewBloc>(
context,
).add(ShiftCompletionReviewApproved(invoiceId));
},
size: UiButtonSize.large,
),
),
],
);
},
),
),
),
Expanded(
child: UiButton.primary(
text: t.client_billing.actions.approve_pay,
leadingIcon: isLoading ? null : UiIcons.checkCircle,
isLoading: isLoading,
onPressed: isLoading
? null
: () {
BlocProvider.of<ShiftCompletionReviewBloc>(
context,
).add(ShiftCompletionReviewApproved(invoiceId));
},
size: UiButtonSize.large,
),
),
],
);
},
),
);
}
void _showFlagDialog(BuildContext context, ShiftCompletionReviewState state) {
void _showFlagDialog(
BuildContext context, ShiftCompletionReviewState state) {
final TextEditingController controller = TextEditingController();
showDialog(
context: context,

View File

@@ -2,15 +2,18 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../models/billing_invoice_model.dart';
/// Displays the total invoice amount on the review page.
class CompletionReviewAmount extends StatelessWidget {
const CompletionReviewAmount({required this.invoice, super.key});
/// Creates a [CompletionReviewAmount].
const CompletionReviewAmount({required this.amountCents, super.key});
final BillingInvoice invoice;
/// The invoice total in cents.
final int amountCents;
@override
Widget build(BuildContext context) {
final double amountDollars = amountCents / 100.0;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(UiConstants.space6),
@@ -27,13 +30,9 @@ class CompletionReviewAmount extends StatelessWidget {
),
const SizedBox(height: UiConstants.space1),
Text(
'\$${invoice.totalAmount.toStringAsFixed(2)}',
'\$${amountDollars.toStringAsFixed(2)}',
style: UiTypography.headline1b.textPrimary.copyWith(fontSize: 40),
),
Text(
'${invoice.totalHours.toStringAsFixed(1)} ${t.client_billing.hours_suffix}\$${(invoice.totalAmount / (invoice.totalHours > 0.1 ? invoice.totalHours : 1)).toStringAsFixed(2)}${t.client_billing.avg_rate_suffix}',
style: UiTypography.footnote2b.textSecondary,
),
],
),
);

View File

@@ -1,12 +1,20 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../models/billing_invoice_model.dart';
/// Displays invoice metadata (date, vendor) on the review page.
class CompletionReviewInfo extends StatelessWidget {
const CompletionReviewInfo({required this.invoice, super.key});
/// Creates a [CompletionReviewInfo].
const CompletionReviewInfo({
required this.dateLabel,
this.vendorName,
super.key,
});
final BillingInvoice invoice;
/// Formatted date string.
final String dateLabel;
/// Vendor name, if available.
final String? vendorName;
@override
Widget build(BuildContext context) {
@@ -14,12 +22,9 @@ class CompletionReviewInfo extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space1,
children: <Widget>[
_buildInfoRow(UiIcons.calendar, invoice.date),
_buildInfoRow(
UiIcons.clock,
'${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}',
),
_buildInfoRow(UiIcons.mapPin, invoice.locationAddress),
_buildInfoRow(UiIcons.calendar, dateLabel),
if (vendorName != null)
_buildInfoRow(UiIcons.building, vendorName!),
],
);
}

View File

@@ -1,126 +1,18 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../models/billing_invoice_model.dart';
/// Card showing a single worker's details in the completion review.
///
/// Currently unused -- the V2 Invoice entity does not include per-worker
/// breakdown data. This widget is retained as a placeholder for when the
/// backend adds worker-level invoice detail endpoints.
class CompletionReviewWorkerCard extends StatelessWidget {
const CompletionReviewWorkerCard({required this.worker, super.key});
final BillingWorkerRecord worker;
/// Creates a [CompletionReviewWorkerCard].
const CompletionReviewWorkerCard({super.key});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border.withValues(alpha: 0.5)),
),
child: Column(
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
CircleAvatar(
radius: 20,
backgroundColor: UiColors.bgSecondary,
backgroundImage: worker.workerAvatarUrl != null
? NetworkImage(worker.workerAvatarUrl!)
: null,
child: worker.workerAvatarUrl == null
? const Icon(
UiIcons.user,
size: 20,
color: UiColors.iconSecondary,
)
: null,
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
worker.workerName,
style: UiTypography.body1b.textPrimary,
),
Text(
worker.roleName,
style: UiTypography.footnote2r.textSecondary,
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
'\$${worker.totalAmount.toStringAsFixed(2)}',
style: UiTypography.body1b.textPrimary,
),
Text(
'${worker.hours}h x \$${worker.rate.toStringAsFixed(2)}/hr',
style: UiTypography.footnote2r.textSecondary,
),
],
),
],
),
const SizedBox(height: UiConstants.space4),
Row(
children: <Widget>[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: Text(
'${worker.startTime} - ${worker.endTime}',
style: UiTypography.footnote2b.textPrimary,
),
),
const SizedBox(width: UiConstants.space2),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: Row(
children: <Widget>[
const Icon(
UiIcons.coffee,
size: 12,
color: UiColors.iconSecondary,
),
const SizedBox(width: 4),
Text(
'${worker.breakMinutes} ${t.client_billing.workers_tab.min_break}',
style: UiTypography.footnote2r.textSecondary,
),
],
),
),
const Spacer(),
UiIconButton.secondary(icon: UiIcons.edit, onTap: () {}),
const SizedBox(width: UiConstants.space2),
UiIconButton.secondary(icon: UiIcons.warning, onTap: () {}),
],
),
],
),
);
// Placeholder until V2 API provides worker-level invoice data.
return const SizedBox.shrink();
}
}

View File

@@ -1,7 +1,8 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../models/billing_invoice_model.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
/// Section showing the history of paid invoices.
class InvoiceHistorySection extends StatelessWidget {
@@ -9,7 +10,7 @@ class InvoiceHistorySection extends StatelessWidget {
const InvoiceHistorySection({required this.invoices, super.key});
/// The list of historical invoices.
final List<BillingInvoice> invoices;
final List<Invoice> invoices;
@override
Widget build(BuildContext context) {
@@ -36,10 +37,10 @@ class InvoiceHistorySection extends StatelessWidget {
),
child: Column(
children: invoices.asMap().entries.map((
MapEntry<int, BillingInvoice> entry,
MapEntry<int, Invoice> entry,
) {
final int index = entry.key;
final BillingInvoice invoice = entry.value;
final Invoice invoice = entry.value;
return Column(
children: <Widget>[
if (index > 0)
@@ -58,10 +59,18 @@ class InvoiceHistorySection extends StatelessWidget {
class _InvoiceItem extends StatelessWidget {
const _InvoiceItem({required this.invoice});
final BillingInvoice invoice;
final Invoice invoice;
@override
Widget build(BuildContext context) {
final DateFormat formatter = DateFormat('MMM d, yyyy');
final String dateLabel = invoice.paymentDate != null
? formatter.format(invoice.paymentDate!)
: invoice.dueDate != null
? formatter.format(invoice.dueDate!)
: 'N/A';
final double amountDollars = invoice.amountCents / 100.0;
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
@@ -86,11 +95,11 @@ class _InvoiceItem extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(invoice.title, style: UiTypography.body1r.textPrimary),
Text(
invoice.date,
style: UiTypography.footnote2r.textSecondary,
invoice.invoiceNumber,
style: UiTypography.body1r.textPrimary,
),
Text(dateLabel, style: UiTypography.footnote2r.textSecondary),
],
),
),
@@ -98,7 +107,7 @@ class _InvoiceItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
'\$${invoice.totalAmount.toStringAsFixed(2)}',
'\$${amountDollars.toStringAsFixed(2)}',
style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15),
),
_StatusBadge(status: invoice.status),
@@ -113,11 +122,11 @@ class _InvoiceItem extends StatelessWidget {
class _StatusBadge extends StatelessWidget {
const _StatusBadge({required this.status});
final String status;
final InvoiceStatus status;
@override
Widget build(BuildContext context) {
final bool isPaid = status.toUpperCase() == 'PAID';
final bool isPaid = status == InvoiceStatus.paid;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space1 + 2,

View File

@@ -3,8 +3,9 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_state.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
/// Card showing the current payment method.
class PaymentMethodCard extends StatelessWidget {
@@ -15,8 +16,8 @@ class PaymentMethodCard extends StatelessWidget {
Widget build(BuildContext context) {
return BlocBuilder<BillingBloc, BillingState>(
builder: (BuildContext context, BillingState state) {
final List<BusinessBankAccount> accounts = state.bankAccounts;
final BusinessBankAccount? account =
final List<BillingAccount> accounts = state.bankAccounts;
final BillingAccount? account =
accounts.isNotEmpty ? accounts.first : null;
if (account == null) {
@@ -24,11 +25,10 @@ class PaymentMethodCard extends StatelessWidget {
}
final String bankLabel =
account.bankName.isNotEmpty == true ? account.bankName : '----';
account.bankName.isNotEmpty ? account.bankName : '----';
final String last4 =
account.last4.isNotEmpty == true ? account.last4 : '----';
account.last4?.isNotEmpty == true ? account.last4! : '----';
final bool isPrimary = account.isPrimary;
final String expiryLabel = _formatExpiry(account.expiryTime);
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
@@ -87,11 +87,11 @@ class PaymentMethodCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'•••• $last4',
'\u2022\u2022\u2022\u2022 $last4',
style: UiTypography.body2b.textPrimary,
),
Text(
t.client_billing.expires(date: expiryLabel),
account.accountType.name.toUpperCase(),
style: UiTypography.footnote2r.textSecondary,
),
],
@@ -121,13 +121,4 @@ class PaymentMethodCard extends StatelessWidget {
},
);
}
String _formatExpiry(DateTime? expiryTime) {
if (expiryTime == null) {
return 'N/A';
}
final String month = expiryTime.month.toString().padLeft(2, '0');
final String year = (expiryTime.year % 100).toString().padLeft(2, '0');
return '$month/$year';
}
}

View File

@@ -2,9 +2,9 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import '../models/billing_invoice_model.dart';
import 'package:krow_domain/krow_domain.dart';
/// Section showing a banner for invoices awaiting approval.
class PendingInvoicesSection extends StatelessWidget {
@@ -12,7 +12,7 @@ class PendingInvoicesSection extends StatelessWidget {
const PendingInvoicesSection({required this.invoices, super.key});
/// The list of pending invoices.
final List<BillingInvoice> invoices;
final List<Invoice> invoices;
@override
Widget build(BuildContext context) {
@@ -93,10 +93,17 @@ class PendingInvoiceCard extends StatelessWidget {
/// Creates a [PendingInvoiceCard].
const PendingInvoiceCard({required this.invoice, super.key});
final BillingInvoice invoice;
/// The invoice to display.
final Invoice invoice;
@override
Widget build(BuildContext context) {
final DateFormat formatter = DateFormat('EEEE, MMMM d');
final String dateLabel = invoice.dueDate != null
? formatter.format(invoice.dueDate!)
: 'N/A';
final double amountDollars = invoice.amountCents / 100.0;
return Container(
decoration: BoxDecoration(
color: UiColors.white,
@@ -108,42 +115,33 @@ class PendingInvoiceCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(invoice.title, style: UiTypography.headline4b.textPrimary),
Text(
invoice.invoiceNumber,
style: UiTypography.headline4b.textPrimary,
),
const SizedBox(height: UiConstants.space3),
Row(
children: <Widget>[
const Icon(
UiIcons.mapPin,
size: 16,
color: UiColors.iconSecondary,
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Text(
invoice.locationAddress,
style: UiTypography.footnote2r.textSecondary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
if (invoice.vendorName != null) ...<Widget>[
Row(
children: <Widget>[
const Icon(
UiIcons.building,
size: 16,
color: UiColors.iconSecondary,
),
),
],
),
const SizedBox(height: UiConstants.space2),
Row(
children: <Widget>[
Text(
invoice.clientName,
style: UiTypography.footnote2r.textSecondary,
),
const SizedBox(width: UiConstants.space2),
Text('', style: UiTypography.footnote2r.textInactive),
const SizedBox(width: UiConstants.space2),
Text(
invoice.date,
style: UiTypography.footnote2r.textSecondary,
),
],
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Text(
invoice.vendorName!,
style: UiTypography.footnote2r.textSecondary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: UiConstants.space2),
],
Text(dateLabel, style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: UiConstants.space3),
Row(
children: <Widget>[
@@ -157,7 +155,7 @@ class PendingInvoiceCard extends StatelessWidget {
),
const SizedBox(width: UiConstants.space2),
Text(
t.client_billing.pending_badge.toUpperCase(),
invoice.status.value.toUpperCase(),
style: UiTypography.titleUppercase4b.copyWith(
color: UiColors.textWarning,
),
@@ -168,40 +166,10 @@ class PendingInvoiceCard extends StatelessWidget {
const Divider(height: 1, color: UiColors.border),
Padding(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
child: Row(
children: <Widget>[
Expanded(
child: _buildStatItem(
UiIcons.dollar,
'\$${invoice.totalAmount.toStringAsFixed(2)}',
t.client_billing.stats.total,
),
),
Container(
width: 1,
height: 32,
color: UiColors.border.withValues(alpha: 0.3),
),
Expanded(
child: _buildStatItem(
UiIcons.users,
'${invoice.workersCount}',
t.client_billing.stats.workers,
),
),
Container(
width: 1,
height: 32,
color: UiColors.border.withValues(alpha: 0.3),
),
Expanded(
child: _buildStatItem(
UiIcons.clock,
invoice.totalHours.toStringAsFixed(1),
t.client_billing.stats.hrs,
),
),
],
child: _buildStatItem(
UiIcons.dollar,
'\$${amountDollars.toStringAsFixed(2)}',
t.client_billing.stats.total,
),
),
const Divider(height: 1, color: UiColors.border),

View File

@@ -3,10 +3,10 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_state.dart';
import '../blocs/billing_event.dart';
import '../models/spending_breakdown_model.dart';
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
import 'package:billing/src/presentation/blocs/billing_event.dart';
import 'package:billing/src/presentation/blocs/billing_state.dart';
/// Card showing the spending breakdown for the current period.
class SpendingBreakdownCard extends StatefulWidget {
@@ -37,10 +37,7 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
Widget build(BuildContext context) {
return BlocBuilder<BillingBloc, BillingState>(
builder: (BuildContext context, BillingState state) {
final double total = state.spendingBreakdown.fold(
0.0,
(double sum, SpendingBreakdownItem item) => sum + item.amount,
);
final double totalDollars = state.spendTotalCents / 100.0;
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
@@ -97,11 +94,12 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
),
dividerColor: UiColors.transparent,
onTap: (int index) {
final BillingPeriod period =
index == 0 ? BillingPeriod.week : BillingPeriod.month;
ReadContext(context).read<BillingBloc>().add(
BillingPeriodChanged(period),
);
final BillingPeriodTab tab = index == 0
? BillingPeriodTab.week
: BillingPeriodTab.month;
ReadContext(context)
.read<BillingBloc>()
.add(BillingPeriodChanged(tab));
},
tabs: <Widget>[
Tab(text: t.client_billing.week),
@@ -112,8 +110,8 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
],
),
const SizedBox(height: UiConstants.space4),
...state.spendingBreakdown.map(
(SpendingBreakdownItem item) => _buildBreakdownRow(item),
...state.spendBreakdown.map(
(SpendItem item) => _buildBreakdownRow(item),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: UiConstants.space2),
@@ -127,7 +125,7 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
style: UiTypography.body2b.textPrimary,
),
Text(
'\$${total.toStringAsFixed(2)}',
'\$${totalDollars.toStringAsFixed(2)}',
style: UiTypography.body2b.textPrimary,
),
],
@@ -139,7 +137,8 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
);
}
Widget _buildBreakdownRow(SpendingBreakdownItem item) {
Widget _buildBreakdownRow(SpendItem item) {
final double amountDollars = item.amountCents / 100.0;
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space2),
child: Row(
@@ -151,14 +150,14 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
children: <Widget>[
Text(item.category, style: UiTypography.body2r.textPrimary),
Text(
t.client_billing.hours(count: item.hours),
'${item.percentage.toStringAsFixed(1)}%',
style: UiTypography.footnote2r.textSecondary,
),
],
),
),
Text(
'\$${item.amount.toStringAsFixed(2)}',
'\$${amountDollars.toStringAsFixed(2)}',
style: UiTypography.body2m.textPrimary,
),
],

View File

@@ -10,12 +10,12 @@ environment:
dependencies:
flutter:
sdk: flutter
# Architecture
flutter_modular: ^6.3.2
flutter_bloc: ^8.1.3
equatable: ^2.0.5
# Shared packages
design_system:
path: ../../../design_system
@@ -25,12 +25,10 @@ dependencies:
path: ../../../domain
krow_core:
path: ../../../core
krow_data_connect:
path: ../../../data_connect
# UI
intl: ^0.20.0
firebase_data_connect: ^0.2.2+1
dev_dependencies:
flutter_test:
sdk: flutter

View File

@@ -1,26 +1,35 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'data/repositories_impl/coverage_repository_impl.dart';
import 'domain/repositories/coverage_repository.dart';
import 'domain/usecases/get_coverage_stats_usecase.dart';
import 'domain/usecases/get_shifts_for_date_usecase.dart';
import 'presentation/blocs/coverage_bloc.dart';
import 'presentation/pages/coverage_page.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:client_coverage/src/data/repositories_impl/coverage_repository_impl.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
import 'package:client_coverage/src/domain/usecases/cancel_late_worker_usecase.dart';
import 'package:client_coverage/src/domain/usecases/get_coverage_stats_usecase.dart';
import 'package:client_coverage/src/domain/usecases/get_shifts_for_date_usecase.dart';
import 'package:client_coverage/src/domain/usecases/submit_worker_review_usecase.dart';
import 'package:client_coverage/src/presentation/blocs/coverage_bloc.dart';
import 'package:client_coverage/src/presentation/pages/coverage_page.dart';
/// Modular module for the coverage feature.
///
/// Uses the V2 REST API via [BaseApiService] for all backend access.
class CoverageModule extends Module {
@override
List<Module> get imports => <Module>[DataConnectModule()];
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<CoverageRepository>(CoverageRepositoryImpl.new);
i.addLazySingleton<CoverageRepository>(
() => CoverageRepositoryImpl(apiService: i.get<BaseApiService>()),
);
// Use Cases
i.addLazySingleton(GetShiftsForDateUseCase.new);
i.addLazySingleton(GetCoverageStatsUseCase.new);
i.addLazySingleton(SubmitWorkerReviewUseCase.new);
i.addLazySingleton(CancelLateWorkerUseCase.new);
// BLoCs
i.addLazySingleton<CoverageBloc>(CoverageBloc.new);
@@ -28,7 +37,9 @@ class CoverageModule extends Module {
@override
void routes(RouteManager r) {
r.child(ClientPaths.childRoute(ClientPaths.coverage, ClientPaths.coverage),
child: (_) => const CoveragePage());
r.child(
ClientPaths.childRoute(ClientPaths.coverage, ClientPaths.coverage),
child: (_) => const CoveragePage(),
);
}
}

View File

@@ -1,62 +1,89 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/coverage_repository.dart';
/// Implementation of [CoverageRepository] that delegates to [dc.CoverageConnectorRepository].
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
/// V2 API implementation of [CoverageRepository].
///
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
/// connector repository from the data_connect package.
/// Uses [BaseApiService] with [V2ApiEndpoints] for all backend access.
class CoverageRepositoryImpl implements CoverageRepository {
/// Creates a [CoverageRepositoryImpl].
CoverageRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
CoverageRepositoryImpl({
dc.CoverageConnectorRepository? connectorRepository,
dc.DataConnectService? service,
}) : _connectorRepository = connectorRepository ??
dc.DataConnectService.instance.getCoverageRepository(),
_service = service ?? dc.DataConnectService.instance;
final dc.CoverageConnectorRepository _connectorRepository;
final dc.DataConnectService _service;
final BaseApiService _apiService;
@override
Future<List<CoverageShift>> getShiftsForDate({required DateTime date}) async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.getShiftsForDate(
businessId: businessId,
date: date,
Future<List<ShiftWithWorkers>> getShiftsForDate({
required DateTime date,
}) async {
final String dateStr =
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.clientCoverage,
params: <String, dynamic>{'date': dateStr},
);
final List<dynamic> items = response.data['items'] as List<dynamic>;
return items
.map((dynamic e) =>
ShiftWithWorkers.fromJson(e as Map<String, dynamic>))
.toList();
}
@override
Future<CoverageStats> getCoverageStats({required DateTime date}) async {
final List<CoverageShift> shifts = await getShiftsForDate(date: date);
final int totalNeeded = shifts.fold<int>(
0,
(int sum, CoverageShift shift) => sum + shift.workersNeeded,
final String dateStr =
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.clientCoverageStats,
params: <String, dynamic>{'date': dateStr},
);
return CoverageStats.fromJson(response.data as Map<String, dynamic>);
}
final List<CoverageWorker> allWorkers =
shifts.expand((CoverageShift shift) => shift.workers).toList();
final int totalConfirmed = allWorkers.length;
final int checkedIn = allWorkers
.where((CoverageWorker w) => w.status == CoverageWorkerStatus.checkedIn)
.length;
final int enRoute = allWorkers
.where((CoverageWorker w) =>
w.status == CoverageWorkerStatus.confirmed && w.checkInTime == null)
.length;
final int late = allWorkers
.where((CoverageWorker w) => w.status == CoverageWorkerStatus.late)
.length;
@override
Future<void> submitWorkerReview({
required String staffId,
required int rating,
String? assignmentId,
String? feedback,
List<String>? issueFlags,
bool? markAsFavorite,
}) async {
final Map<String, dynamic> body = <String, dynamic>{
'staffId': staffId,
'rating': rating,
};
if (assignmentId != null) {
body['assignmentId'] = assignmentId;
}
if (feedback != null) {
body['feedback'] = feedback;
}
if (issueFlags != null && issueFlags.isNotEmpty) {
body['issueFlags'] = issueFlags;
}
if (markAsFavorite != null) {
body['markAsFavorite'] = markAsFavorite;
}
await _apiService.post(
V2ApiEndpoints.clientCoverageReviews,
data: body,
);
}
return CoverageStats(
totalNeeded: totalNeeded,
totalConfirmed: totalConfirmed,
checkedIn: checkedIn,
enRoute: enRoute,
late: late,
@override
Future<void> cancelLateWorker({
required String assignmentId,
String? reason,
}) async {
final Map<String, dynamic> body = <String, dynamic>{};
if (reason != null) {
body['reason'] = reason;
}
await _apiService.post(
V2ApiEndpoints.clientCoverageCancelLateWorker(assignmentId),
data: body,
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:krow_core/core.dart';
/// Arguments for cancelling a late worker's assignment.
class CancelLateWorkerArguments extends UseCaseArgument {
/// Creates [CancelLateWorkerArguments].
const CancelLateWorkerArguments({
required this.assignmentId,
this.reason,
});
/// The assignment ID to cancel.
final String assignmentId;
/// Optional cancellation reason.
final String? reason;
@override
List<Object?> get props => <Object?>[assignmentId, reason];
}

View File

@@ -1,9 +1,6 @@
import 'package:krow_core/core.dart';
/// Arguments for fetching coverage statistics for a specific date.
///
/// This argument class encapsulates the date parameter required by
/// the [GetCoverageStatsUseCase].
class GetCoverageStatsArguments extends UseCaseArgument {
/// Creates [GetCoverageStatsArguments].
const GetCoverageStatsArguments({required this.date});

View File

@@ -1,9 +1,6 @@
import 'package:krow_core/core.dart';
/// Arguments for fetching shifts for a specific date.
///
/// This argument class encapsulates the date parameter required by
/// the [GetShiftsForDateUseCase].
class GetShiftsForDateArguments extends UseCaseArgument {
/// Creates [GetShiftsForDateArguments].
const GetShiftsForDateArguments({required this.date});

View File

@@ -0,0 +1,42 @@
import 'package:krow_core/core.dart';
/// Arguments for submitting a worker review from the coverage page.
class SubmitWorkerReviewArguments extends UseCaseArgument {
/// Creates [SubmitWorkerReviewArguments].
const SubmitWorkerReviewArguments({
required this.staffId,
required this.rating,
this.assignmentId,
this.feedback,
this.issueFlags,
this.markAsFavorite,
});
/// The ID of the worker being reviewed.
final String staffId;
/// The rating value (1-5).
final int rating;
/// The assignment ID, if reviewing for a specific assignment.
final String? assignmentId;
/// Optional text feedback.
final String? feedback;
/// Optional list of issue flag labels.
final List<String>? issueFlags;
/// Whether to mark/unmark the worker as a favorite.
final bool? markAsFavorite;
@override
List<Object?> get props => <Object?>[
staffId,
rating,
assignmentId,
feedback,
issueFlags,
markAsFavorite,
];
}

View File

@@ -2,22 +2,35 @@ import 'package:krow_domain/krow_domain.dart';
/// Repository interface for coverage-related operations.
///
/// This interface defines the contract for accessing coverage data,
/// Defines the contract for accessing coverage data via the V2 REST API,
/// acting as a boundary between the Domain and Data layers.
/// It allows the Domain layer to remain independent of specific data sources.
///
/// Implementation of this interface must delegate all data access through
/// the `packages/data_connect` layer, ensuring compliance with Clean Architecture.
abstract interface class CoverageRepository {
/// Fetches shifts for a specific date.
///
/// Returns a list of [CoverageShift] entities representing all shifts
/// scheduled for the given [date].
Future<List<CoverageShift>> getShiftsForDate({required DateTime date});
/// Fetches shifts with assigned workers for a specific [date].
Future<List<ShiftWithWorkers>> getShiftsForDate({required DateTime date});
/// Fetches coverage statistics for a specific date.
///
/// Returns [CoverageStats] containing aggregated metrics including
/// total workers needed, confirmed, checked in, en route, and late.
/// Fetches aggregated coverage statistics for a specific [date].
Future<CoverageStats> getCoverageStats({required DateTime date});
/// Submits a worker review from the coverage page.
///
/// [staffId] identifies the worker being reviewed.
/// [rating] is an integer from 1 to 5.
/// Optional fields: [assignmentId], [feedback], [issueFlags], [markAsFavorite].
Future<void> submitWorkerReview({
required String staffId,
required int rating,
String? assignmentId,
String? feedback,
List<String>? issueFlags,
bool? markAsFavorite,
});
/// Cancels a late worker's assignment.
///
/// [assignmentId] identifies the assignment to cancel.
/// [reason] is an optional cancellation reason.
Future<void> cancelLateWorker({
required String assignmentId,
String? reason,
});
}

View File

@@ -0,0 +1,23 @@
import 'package:krow_core/core.dart';
import 'package:client_coverage/src/domain/arguments/cancel_late_worker_arguments.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
/// Use case for cancelling a late worker's assignment.
///
/// Delegates to [CoverageRepository] to cancel the assignment via V2 API.
class CancelLateWorkerUseCase
implements UseCase<CancelLateWorkerArguments, void> {
/// Creates a [CancelLateWorkerUseCase].
CancelLateWorkerUseCase(this._repository);
final CoverageRepository _repository;
@override
Future<void> call(CancelLateWorkerArguments arguments) {
return _repository.cancelLateWorker(
assignmentId: arguments.assignmentId,
reason: arguments.reason,
);
}
}

View File

@@ -1,20 +1,12 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../arguments/get_coverage_stats_arguments.dart';
import '../repositories/coverage_repository.dart';
import 'package:client_coverage/src/domain/arguments/get_coverage_stats_arguments.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
/// Use case for fetching coverage statistics for a specific date.
/// Use case for fetching aggregated coverage statistics for a specific date.
///
/// This use case encapsulates the logic for retrieving coverage metrics including
/// total workers needed, confirmed, checked in, en route, and late.
/// It delegates the data retrieval to the [CoverageRepository].
///
/// Follows the KROW Clean Architecture pattern by:
/// - Extending from [UseCase] base class
/// - Using [GetCoverageStatsArguments] for input
/// - Returning domain entities ([CoverageStats])
/// - Delegating to repository abstraction
/// Delegates to [CoverageRepository] and returns a [CoverageStats] entity.
class GetCoverageStatsUseCase
implements UseCase<GetCoverageStatsArguments, CoverageStats> {
/// Creates a [GetCoverageStatsUseCase].

View File

@@ -1,27 +1,21 @@
import 'package:krow_core/core.dart';
import '../arguments/get_shifts_for_date_arguments.dart';
import '../repositories/coverage_repository.dart';
import 'package:krow_domain/krow_domain.dart';
/// Use case for fetching shifts for a specific date.
import 'package:client_coverage/src/domain/arguments/get_shifts_for_date_arguments.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
/// Use case for fetching shifts with workers for a specific date.
///
/// This use case encapsulates the logic for retrieving all shifts scheduled for a given date.
/// It delegates the data retrieval to the [CoverageRepository].
///
/// Follows the KROW Clean Architecture pattern by:
/// - Extending from [UseCase] base class
/// - Using [GetShiftsForDateArguments] for input
/// - Returning domain entities ([CoverageShift])
/// - Delegating to repository abstraction
/// Delegates to [CoverageRepository] and returns V2 [ShiftWithWorkers] entities.
class GetShiftsForDateUseCase
implements UseCase<GetShiftsForDateArguments, List<CoverageShift>> {
implements UseCase<GetShiftsForDateArguments, List<ShiftWithWorkers>> {
/// Creates a [GetShiftsForDateUseCase].
GetShiftsForDateUseCase(this._repository);
final CoverageRepository _repository;
@override
Future<List<CoverageShift>> call(GetShiftsForDateArguments arguments) {
Future<List<ShiftWithWorkers>> call(GetShiftsForDateArguments arguments) {
return _repository.getShiftsForDate(date: arguments.date);
}
}

View File

@@ -0,0 +1,30 @@
import 'package:krow_core/core.dart';
import 'package:client_coverage/src/domain/arguments/submit_worker_review_arguments.dart';
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
/// Use case for submitting a worker review from the coverage page.
///
/// Validates the rating range and delegates to [CoverageRepository].
class SubmitWorkerReviewUseCase
implements UseCase<SubmitWorkerReviewArguments, void> {
/// Creates a [SubmitWorkerReviewUseCase].
SubmitWorkerReviewUseCase(this._repository);
final CoverageRepository _repository;
@override
Future<void> call(SubmitWorkerReviewArguments arguments) async {
if (arguments.rating < 1 || arguments.rating > 5) {
throw ArgumentError('Rating must be between 1 and 5');
}
return _repository.submitWorkerReview(
staffId: arguments.staffId,
rating: arguments.rating,
assignmentId: arguments.assignmentId,
feedback: arguments.feedback,
issueFlags: arguments.issueFlags,
markAsFavorite: arguments.markAsFavorite,
);
}
}

View File

@@ -1,35 +1,46 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/arguments/get_coverage_stats_arguments.dart';
import '../../domain/arguments/get_shifts_for_date_arguments.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/usecases/get_coverage_stats_usecase.dart';
import '../../domain/usecases/get_shifts_for_date_usecase.dart';
import 'coverage_event.dart';
import 'coverage_state.dart';
import 'package:client_coverage/src/domain/arguments/cancel_late_worker_arguments.dart';
import 'package:client_coverage/src/domain/arguments/get_coverage_stats_arguments.dart';
import 'package:client_coverage/src/domain/arguments/get_shifts_for_date_arguments.dart';
import 'package:client_coverage/src/domain/arguments/submit_worker_review_arguments.dart';
import 'package:client_coverage/src/domain/usecases/cancel_late_worker_usecase.dart';
import 'package:client_coverage/src/domain/usecases/get_coverage_stats_usecase.dart';
import 'package:client_coverage/src/domain/usecases/get_shifts_for_date_usecase.dart';
import 'package:client_coverage/src/domain/usecases/submit_worker_review_usecase.dart';
import 'package:client_coverage/src/presentation/blocs/coverage_event.dart';
import 'package:client_coverage/src/presentation/blocs/coverage_state.dart';
/// BLoC for managing coverage feature state.
///
/// This BLoC handles:
/// - Loading shifts for a specific date
/// - Loading coverage statistics
/// - Refreshing coverage data
/// Handles loading shifts, coverage statistics, worker reviews,
/// and late-worker cancellation for a selected date.
class CoverageBloc extends Bloc<CoverageEvent, CoverageState>
with BlocErrorHandler<CoverageState> {
/// Creates a [CoverageBloc].
CoverageBloc({
required GetShiftsForDateUseCase getShiftsForDate,
required GetCoverageStatsUseCase getCoverageStats,
}) : _getShiftsForDate = getShiftsForDate,
required SubmitWorkerReviewUseCase submitWorkerReview,
required CancelLateWorkerUseCase cancelLateWorker,
}) : _getShiftsForDate = getShiftsForDate,
_getCoverageStats = getCoverageStats,
_submitWorkerReview = submitWorkerReview,
_cancelLateWorker = cancelLateWorker,
super(const CoverageState()) {
on<CoverageLoadRequested>(_onLoadRequested);
on<CoverageRefreshRequested>(_onRefreshRequested);
on<CoverageRepostShiftRequested>(_onRepostShiftRequested);
on<CoverageSubmitReviewRequested>(_onSubmitReviewRequested);
on<CoverageCancelLateWorkerRequested>(_onCancelLateWorkerRequested);
}
final GetShiftsForDateUseCase _getShiftsForDate;
final GetCoverageStatsUseCase _getCoverageStats;
final SubmitWorkerReviewUseCase _submitWorkerReview;
final CancelLateWorkerUseCase _cancelLateWorker;
/// Handles the load requested event.
Future<void> _onLoadRequested(
@@ -47,12 +58,15 @@ class CoverageBloc extends Bloc<CoverageEvent, CoverageState>
emit: emit.call,
action: () async {
// Fetch shifts and stats concurrently
final List<Object> results = await Future.wait<Object>(<Future<Object>>[
_getShiftsForDate(GetShiftsForDateArguments(date: event.date)),
_getCoverageStats(GetCoverageStatsArguments(date: event.date)),
]);
final List<Object> results = await Future.wait<Object>(
<Future<Object>>[
_getShiftsForDate(GetShiftsForDateArguments(date: event.date)),
_getCoverageStats(GetCoverageStatsArguments(date: event.date)),
],
);
final List<CoverageShift> shifts = results[0] as List<CoverageShift>;
final List<ShiftWithWorkers> shifts =
results[0] as List<ShiftWithWorkers>;
final CoverageStats stats = results[1] as CoverageStats;
emit(
@@ -86,17 +100,14 @@ class CoverageBloc extends Bloc<CoverageEvent, CoverageState>
CoverageRepostShiftRequested event,
Emitter<CoverageState> emit,
) async {
// In a real implementation, this would call a repository method.
// For this audit completion, we simulate the action and refresh the state.
emit(state.copyWith(status: CoverageStatus.loading));
await handleError(
emit: emit.call,
action: () async {
// Simulating API call delay
// TODO: Implement re-post shift via V2 API when endpoint is available.
await Future<void>.delayed(const Duration(seconds: 1));
// Since we don't have a real re-post mutation yet, we just refresh
if (state.selectedDate != null) {
add(CoverageLoadRequested(date: state.selectedDate!));
}
@@ -107,5 +118,70 @@ class CoverageBloc extends Bloc<CoverageEvent, CoverageState>
),
);
}
}
/// Handles the submit review requested event.
Future<void> _onSubmitReviewRequested(
CoverageSubmitReviewRequested event,
Emitter<CoverageState> emit,
) async {
emit(state.copyWith(writeStatus: CoverageWriteStatus.submitting));
await handleError(
emit: emit.call,
action: () async {
await _submitWorkerReview(
SubmitWorkerReviewArguments(
staffId: event.staffId,
rating: event.rating,
assignmentId: event.assignmentId,
feedback: event.feedback,
issueFlags: event.issueFlags,
markAsFavorite: event.markAsFavorite,
),
);
emit(state.copyWith(writeStatus: CoverageWriteStatus.submitted));
// Refresh coverage data after successful review.
if (state.selectedDate != null) {
add(CoverageLoadRequested(date: state.selectedDate!));
}
},
onError: (String errorKey) => state.copyWith(
writeStatus: CoverageWriteStatus.submitFailure,
writeErrorMessage: errorKey,
),
);
}
/// Handles the cancel late worker requested event.
Future<void> _onCancelLateWorkerRequested(
CoverageCancelLateWorkerRequested event,
Emitter<CoverageState> emit,
) async {
emit(state.copyWith(writeStatus: CoverageWriteStatus.submitting));
await handleError(
emit: emit.call,
action: () async {
await _cancelLateWorker(
CancelLateWorkerArguments(
assignmentId: event.assignmentId,
reason: event.reason,
),
);
emit(state.copyWith(writeStatus: CoverageWriteStatus.submitted));
// Refresh coverage data after cancellation.
if (state.selectedDate != null) {
add(CoverageLoadRequested(date: state.selectedDate!));
}
},
onError: (String errorKey) => state.copyWith(
writeStatus: CoverageWriteStatus.submitFailure,
writeErrorMessage: errorKey,
),
);
}
}

View File

@@ -38,3 +38,62 @@ final class CoverageRepostShiftRequested extends CoverageEvent {
@override
List<Object?> get props => <Object?>[shiftId];
}
/// Event to submit a worker review.
final class CoverageSubmitReviewRequested extends CoverageEvent {
/// Creates a [CoverageSubmitReviewRequested] event.
const CoverageSubmitReviewRequested({
required this.staffId,
required this.rating,
this.assignmentId,
this.feedback,
this.issueFlags,
this.markAsFavorite,
});
/// The worker ID to review.
final String staffId;
/// Rating from 1 to 5.
final int rating;
/// Optional assignment ID for context.
final String? assignmentId;
/// Optional text feedback.
final String? feedback;
/// Optional issue flag labels.
final List<String>? issueFlags;
/// Whether to mark/unmark as favorite.
final bool? markAsFavorite;
@override
List<Object?> get props => <Object?>[
staffId,
rating,
assignmentId,
feedback,
issueFlags,
markAsFavorite,
];
}
/// Event to cancel a late worker's assignment.
final class CoverageCancelLateWorkerRequested extends CoverageEvent {
/// Creates a [CoverageCancelLateWorkerRequested] event.
const CoverageCancelLateWorkerRequested({
required this.assignmentId,
this.reason,
});
/// The assignment ID to cancel.
final String assignmentId;
/// Optional reason for cancellation.
final String? reason;
@override
List<Object?> get props => <Object?>[assignmentId, reason];
}

View File

@@ -16,15 +16,32 @@ enum CoverageStatus {
failure,
}
/// Status of a write (review / cancel) operation.
enum CoverageWriteStatus {
/// No write operation in progress.
idle,
/// A write operation is in progress.
submitting,
/// The write operation succeeded.
submitted,
/// The write operation failed.
submitFailure,
}
/// State for the coverage feature.
final class CoverageState extends Equatable {
/// Creates a [CoverageState].
const CoverageState({
this.status = CoverageStatus.initial,
this.selectedDate,
this.shifts = const <CoverageShift>[],
this.shifts = const <ShiftWithWorkers>[],
this.stats,
this.errorMessage,
this.writeStatus = CoverageWriteStatus.idle,
this.writeErrorMessage,
});
/// The current status of data loading.
@@ -33,8 +50,8 @@ final class CoverageState extends Equatable {
/// The currently selected date.
final DateTime? selectedDate;
/// The list of shifts for the selected date.
final List<CoverageShift> shifts;
/// The list of shifts with assigned workers for the selected date.
final List<ShiftWithWorkers> shifts;
/// Coverage statistics for the selected date.
final CoverageStats? stats;
@@ -42,13 +59,21 @@ final class CoverageState extends Equatable {
/// Error message if status is failure.
final String? errorMessage;
/// Status of the current write operation (review or cancel).
final CoverageWriteStatus writeStatus;
/// Error message from a failed write operation.
final String? writeErrorMessage;
/// Creates a copy of this state with the given fields replaced.
CoverageState copyWith({
CoverageStatus? status,
DateTime? selectedDate,
List<CoverageShift>? shifts,
List<ShiftWithWorkers>? shifts,
CoverageStats? stats,
String? errorMessage,
CoverageWriteStatus? writeStatus,
String? writeErrorMessage,
}) {
return CoverageState(
status: status ?? this.status,
@@ -56,6 +81,8 @@ final class CoverageState extends Equatable {
shifts: shifts ?? this.shifts,
stats: stats ?? this.stats,
errorMessage: errorMessage ?? this.errorMessage,
writeStatus: writeStatus ?? this.writeStatus,
writeErrorMessage: writeErrorMessage ?? this.writeErrorMessage,
);
}
@@ -66,5 +93,7 @@ final class CoverageState extends Equatable {
shifts,
stats,
errorMessage,
writeStatus,
writeErrorMessage,
];
}

View File

@@ -5,15 +5,15 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import '../blocs/coverage_bloc.dart';
import '../blocs/coverage_event.dart';
import '../blocs/coverage_state.dart';
import '../widgets/coverage_calendar_selector.dart';
import '../widgets/coverage_page_skeleton.dart';
import '../widgets/coverage_quick_stats.dart';
import '../widgets/coverage_shift_list.dart';
import '../widgets/coverage_stats_header.dart';
import '../widgets/late_workers_alert.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';
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.
///
@@ -102,7 +102,8 @@ class _CoveragePageState extends State<CoveragePage> {
icon: Container(
padding: const EdgeInsets.all(UiConstants.space2),
decoration: BoxDecoration(
color: UiColors.primaryForeground.withValues(alpha: 0.2),
color: UiColors.primaryForeground
.withValues(alpha: 0.2),
borderRadius: UiConstants.radiusMd,
),
child: const Icon(
@@ -147,11 +148,12 @@ class _CoveragePageState extends State<CoveragePage> {
const SizedBox(height: UiConstants.space4),
CoverageStatsHeader(
coveragePercent:
(state.stats?.coveragePercent ?? 0)
(state.stats?.totalCoveragePercentage ?? 0)
.toDouble(),
totalConfirmed:
state.stats?.totalConfirmed ?? 0,
totalNeeded: state.stats?.totalNeeded ?? 0,
state.stats?.totalPositionsConfirmed ?? 0,
totalNeeded:
state.stats?.totalPositionsNeeded ?? 0,
),
],
),
@@ -207,7 +209,8 @@ class _CoveragePageState extends State<CoveragePage> {
const SizedBox(height: UiConstants.space4),
UiButton.secondary(
text: context.t.client_coverage.page.retry,
onPressed: () => BlocProvider.of<CoverageBloc>(context).add(
onPressed: () =>
BlocProvider.of<CoverageBloc>(context).add(
const CoverageRefreshRequested(),
),
),
@@ -227,8 +230,11 @@ class _CoveragePageState extends State<CoveragePage> {
Column(
spacing: UiConstants.space2,
children: <Widget>[
if (state.stats != null && state.stats!.late > 0) ...<Widget>[
LateWorkersAlert(lateCount: state.stats!.late),
if (state.stats != null &&
state.stats!.totalWorkersLate > 0) ...<Widget>[
LateWorkersAlert(
lateCount: state.stats!.totalWorkersLate,
),
],
if (state.stats != null) ...<Widget>[
CoverageQuickStats(stats: state.stats!),

View File

@@ -3,7 +3,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'calendar_nav_button.dart';
import 'package:client_coverage/src/presentation/widgets/calendar_nav_button.dart';
/// Calendar selector widget for choosing dates.
///

View File

@@ -1,7 +1,7 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'shift_card_skeleton.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_page_skeleton/shift_card_skeleton.dart';
/// Shimmer loading skeleton that mimics the coverage page loaded layout.
///

View File

@@ -3,7 +3,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
import 'coverage_stat_card.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_stat_card.dart';
/// Quick statistics cards showing coverage metrics.
///
@@ -27,7 +27,7 @@ class CoverageQuickStats extends StatelessWidget {
child: CoverageStatCard(
icon: UiIcons.success,
label: context.t.client_coverage.stats.checked_in,
value: stats.checkedIn.toString(),
value: stats.totalWorkersCheckedIn.toString(),
color: UiColors.iconSuccess,
),
),
@@ -35,7 +35,7 @@ class CoverageQuickStats extends StatelessWidget {
child: CoverageStatCard(
icon: UiIcons.clock,
label: context.t.client_coverage.stats.en_route,
value: stats.enRoute.toString(),
value: stats.totalWorkersEnRoute.toString(),
color: UiColors.textWarning,
),
),

View File

@@ -4,8 +4,8 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import 'shift_header.dart';
import 'worker_row.dart';
import 'package:client_coverage/src/presentation/widgets/shift_header.dart';
import 'package:client_coverage/src/presentation/widgets/worker_row.dart';
/// List of shifts with their workers.
///
@@ -18,20 +18,12 @@ class CoverageShiftList extends StatelessWidget {
});
/// The list of shifts to display.
final List<CoverageShift> shifts;
final List<ShiftWithWorkers> shifts;
/// Formats a time string (HH:mm) to a readable format (h:mm a).
String _formatTime(String? time) {
/// Formats a [DateTime] to a readable time string (h:mm a).
String _formatTime(DateTime? time) {
if (time == null) return '';
final List<String> parts = time.split(':');
final DateTime dt = DateTime(
2022,
1,
1,
int.parse(parts[0]),
int.parse(parts[1]),
);
return DateFormat('h:mm a').format(dt);
return DateFormat('h:mm a').format(time);
}
@override
@@ -65,7 +57,12 @@ class CoverageShiftList extends StatelessWidget {
}
return Column(
children: shifts.map((CoverageShift shift) {
children: shifts.map((ShiftWithWorkers shift) {
final int coveragePercent = shift.requiredWorkerCount > 0
? ((shift.assignedWorkerCount / shift.requiredWorkerCount) * 100)
.round()
: 0;
return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
decoration: BoxDecoration(
@@ -77,29 +74,30 @@ class CoverageShiftList extends StatelessWidget {
child: Column(
children: <Widget>[
ShiftHeader(
title: shift.title,
location: shift.location,
startTime: _formatTime(shift.startTime),
current: shift.workers.length,
total: shift.workersNeeded,
coveragePercent: shift.coveragePercent,
shiftId: shift.id,
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,
),
if (shift.workers.isNotEmpty)
if (shift.assignedWorkers.isNotEmpty)
Padding(
padding: const EdgeInsets.all(UiConstants.space3),
child: Column(
children:
shift.workers.map<Widget>((CoverageWorker worker) {
final bool isLast = worker == shift.workers.last;
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.startTime),
formatTime: _formatTime,
shiftStartTime:
_formatTime(shift.timeRange.startsAt),
),
);
}).toList(),

View File

@@ -1,7 +1,7 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'coverage_badge.dart';
import 'package:client_coverage/src/presentation/widgets/coverage_badge.dart';
/// Header section for a shift card showing title, location, time, and coverage.
class ShiftHeader extends StatelessWidget {

View File

@@ -1,6 +1,7 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
/// Row displaying a single worker's avatar, name, status, and badge.
@@ -9,18 +10,20 @@ class WorkerRow extends StatelessWidget {
const WorkerRow({
required this.worker,
required this.shiftStartTime,
required this.formatTime,
super.key,
});
/// The worker data to display.
final CoverageWorker worker;
/// The assigned worker data to display.
final AssignedWorker worker;
/// The formatted shift start time.
final String shiftStartTime;
/// Callback to format a raw time string into a readable format.
final String Function(String?) formatTime;
/// Formats a [DateTime] to a readable time string (h:mm a).
String _formatCheckInTime(DateTime? time) {
if (time == null) return '';
return DateFormat('h:mm a').format(time);
}
@override
Widget build(BuildContext context) {
@@ -38,21 +41,21 @@ class WorkerRow extends StatelessWidget {
String badgeLabel;
switch (worker.status) {
case CoverageWorkerStatus.checkedIn:
case AssignmentStatus.checkedIn:
bg = UiColors.textSuccess.withAlpha(26);
border = UiColors.textSuccess;
textBg = UiColors.textSuccess.withAlpha(51);
textColor = UiColors.textSuccess;
icon = UiIcons.success;
statusText = l10n.status_checked_in_at(
time: formatTime(worker.checkInTime),
time: _formatCheckInTime(worker.checkInAt),
);
badgeBg = UiColors.textSuccess.withAlpha(40);
badgeText = UiColors.textSuccess;
badgeBorder = badgeText;
badgeLabel = l10n.status_on_site;
case CoverageWorkerStatus.confirmed:
if (worker.checkInTime == null) {
case AssignmentStatus.accepted:
if (worker.checkInAt == null) {
bg = UiColors.textWarning.withAlpha(26);
border = UiColors.textWarning;
textBg = UiColors.textWarning.withAlpha(51);
@@ -75,29 +78,7 @@ class WorkerRow extends StatelessWidget {
badgeBorder = badgeText;
badgeLabel = l10n.status_confirmed;
}
case CoverageWorkerStatus.late:
bg = UiColors.destructive.withAlpha(26);
border = UiColors.destructive;
textBg = UiColors.destructive.withAlpha(51);
textColor = UiColors.destructive;
icon = UiIcons.warning;
statusText = l10n.status_running_late;
badgeBg = UiColors.destructive.withAlpha(40);
badgeText = UiColors.destructive;
badgeBorder = badgeText;
badgeLabel = l10n.status_late;
case CoverageWorkerStatus.checkedOut:
bg = UiColors.muted.withAlpha(26);
border = UiColors.border;
textBg = UiColors.muted.withAlpha(51);
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 CoverageWorkerStatus.noShow:
case AssignmentStatus.noShow:
bg = UiColors.destructive.withAlpha(26);
border = UiColors.destructive;
textBg = UiColors.destructive.withAlpha(51);
@@ -108,7 +89,18 @@ class WorkerRow extends StatelessWidget {
badgeText = UiColors.destructive;
badgeBorder = badgeText;
badgeLabel = l10n.status_no_show;
case CoverageWorkerStatus.completed:
case AssignmentStatus.checkedOut:
bg = UiColors.muted.withAlpha(26);
border = UiColors.border;
textBg = UiColors.muted.withAlpha(51);
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;
textBg = UiColors.iconSuccess.withAlpha(51);
@@ -119,20 +111,20 @@ class WorkerRow extends StatelessWidget {
badgeText = UiColors.textSuccess;
badgeBorder = badgeText;
badgeLabel = l10n.status_completed;
case CoverageWorkerStatus.pending:
case CoverageWorkerStatus.accepted:
case CoverageWorkerStatus.rejected:
case AssignmentStatus.assigned:
case AssignmentStatus.swapRequested:
case AssignmentStatus.cancelled:
case AssignmentStatus.unknown:
bg = UiColors.muted.withAlpha(26);
border = UiColors.border;
textBg = UiColors.muted.withAlpha(51);
textColor = UiColors.textSecondary;
icon = UiIcons.clock;
statusText = worker.status.name.toUpperCase();
statusText = worker.status.value;
badgeBg = UiColors.textSecondary.withAlpha(40);
badgeText = UiColors.textSecondary;
badgeBorder = badgeText;
badgeLabel = worker.status.name[0].toUpperCase() +
worker.status.name.substring(1);
badgeLabel = worker.status.value;
}
return Container(
@@ -156,7 +148,7 @@ class WorkerRow extends StatelessWidget {
child: CircleAvatar(
backgroundColor: textBg,
child: Text(
worker.name.isNotEmpty ? worker.name[0] : 'W',
worker.fullName.isNotEmpty ? worker.fullName[0] : 'W',
style: UiTypography.body1b.copyWith(
color: textColor,
),
@@ -188,7 +180,7 @@ class WorkerRow extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
worker.name,
worker.fullName,
style: UiTypography.body2b.copyWith(
color: UiColors.textPrimary,
),

View File

@@ -10,7 +10,7 @@ environment:
dependencies:
flutter:
sdk: flutter
# Internal packages
design_system:
path: ../../../design_system
@@ -18,17 +18,14 @@ dependencies:
path: ../../../domain
krow_core:
path: ../../../core
krow_data_connect:
path: ../../../data_connect
core_localization:
path: ../../../core_localization
# External packages
flutter_modular: ^6.3.4
flutter_bloc: ^8.1.6
equatable: ^2.0.7
intl: ^0.20.0
firebase_data_connect: ^0.2.2+1
dev_dependencies:
flutter_test:

View File

@@ -1,12 +1,11 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart';
import 'src/data/repositories_impl/home_repository_impl.dart';
import 'src/domain/repositories/home_repository_interface.dart';
import 'src/domain/usecases/get_dashboard_data_usecase.dart';
import 'src/domain/usecases/get_recent_reorders_usecase.dart';
import 'src/domain/usecases/get_user_session_data_usecase.dart';
import 'src/presentation/blocs/client_home_bloc.dart';
import 'src/presentation/pages/client_home_page.dart';
@@ -14,24 +13,34 @@ export 'src/presentation/pages/client_home_page.dart';
/// A [Module] for the client home feature.
///
/// This module configures the dependencies for the client home feature,
/// including repositories, use cases, and BLoCs.
/// Imports [CoreModule] for [BaseApiService] and registers repositories,
/// use cases, and BLoCs for the client dashboard.
class ClientHomeModule extends Module {
@override
List<Module> get imports => <Module>[DataConnectModule()];
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<HomeRepositoryInterface>(HomeRepositoryImpl.new);
i.addLazySingleton<HomeRepositoryInterface>(
() => HomeRepositoryImpl(apiService: i.get<BaseApiService>()),
);
// UseCases
i.addLazySingleton(GetDashboardDataUseCase.new);
i.addLazySingleton(GetRecentReordersUseCase.new);
i.addLazySingleton(GetUserSessionDataUseCase.new);
i.addLazySingleton(
() => GetDashboardDataUseCase(i.get<HomeRepositoryInterface>()),
);
i.addLazySingleton(
() => GetRecentReordersUseCase(i.get<HomeRepositoryInterface>()),
);
// BLoCs
i.add<ClientHomeBloc>(ClientHomeBloc.new);
i.add<ClientHomeBloc>(
() => ClientHomeBloc(
getDashboardDataUseCase: i.get<GetDashboardDataUseCase>(),
getRecentReordersUseCase: i.get<GetRecentReordersUseCase>(),
),
);
}
@override

View File

@@ -1,198 +1,37 @@
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/home_repository_interface.dart';
/// Implementation of [HomeRepositoryInterface] that directly interacts with the Data Connect SDK.
import 'package:client_home/src/domain/repositories/home_repository_interface.dart';
/// V2 API implementation of [HomeRepositoryInterface].
///
/// Fetches client dashboard data from `GET /client/dashboard` and recent
/// reorders from `GET /client/reorders`.
class HomeRepositoryImpl implements HomeRepositoryInterface {
HomeRepositoryImpl({dc.DataConnectService? service})
: _service = service ?? dc.DataConnectService.instance;
/// Creates a [HomeRepositoryImpl].
HomeRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
final dc.DataConnectService _service;
/// The API service used for network requests.
final BaseApiService _apiService;
@override
Future<HomeDashboardData> getDashboardData() async {
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final DateTime now = DateTime.now();
final int daysFromMonday = now.weekday - DateTime.monday;
final DateTime monday = DateTime(
now.year,
now.month,
now.day,
).subtract(Duration(days: daysFromMonday));
final DateTime weekRangeStart = monday;
final DateTime weekRangeEnd = monday.add(
const Duration(days: 13, hours: 23, minutes: 59, seconds: 59),
);
final QueryResult<
dc.GetCompletedShiftsByBusinessIdData,
dc.GetCompletedShiftsByBusinessIdVariables
>
completedResult = await _service.connector
.getCompletedShiftsByBusinessId(
businessId: businessId,
dateFrom: _service.toTimestamp(weekRangeStart),
dateTo: _service.toTimestamp(weekRangeEnd),
)
.execute();
double weeklySpending = 0.0;
double next7DaysSpending = 0.0;
int weeklyShifts = 0;
int next7DaysScheduled = 0;
for (final dc.GetCompletedShiftsByBusinessIdShifts shift
in completedResult.data.shifts) {
final DateTime? shiftDate = _service.toDateTime(shift.date);
if (shiftDate == null) continue;
final int offset = shiftDate.difference(weekRangeStart).inDays;
if (offset < 0 || offset > 13) continue;
final double cost = shift.cost ?? 0.0;
if (offset <= 6) {
weeklySpending += cost;
weeklyShifts += 1;
} else {
next7DaysSpending += cost;
next7DaysScheduled += 1;
}
}
final DateTime start = DateTime(now.year, now.month, now.day);
final DateTime end = start.add(
const Duration(hours: 23, minutes: 59, seconds: 59),
);
final QueryResult<
dc.ListShiftRolesByBusinessAndDateRangeData,
dc.ListShiftRolesByBusinessAndDateRangeVariables
>
result = await _service.connector
.listShiftRolesByBusinessAndDateRange(
businessId: businessId,
start: _service.toTimestamp(start),
end: _service.toTimestamp(end),
)
.execute();
int totalNeeded = 0;
int totalFilled = 0;
for (final dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole
in result.data.shiftRoles) {
totalNeeded += shiftRole.count;
totalFilled += shiftRole.assigned ?? 0;
}
return HomeDashboardData(
weeklySpending: weeklySpending,
next7DaysSpending: next7DaysSpending,
weeklyShifts: weeklyShifts,
next7DaysScheduled: next7DaysScheduled,
totalNeeded: totalNeeded,
totalFilled: totalFilled,
);
});
Future<ClientDashboard> getDashboard() async {
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientDashboard);
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
return ClientDashboard.fromJson(data);
}
@override
Future<UserSessionData> getUserSessionData() async {
return await _service.run(() async {
final String businessId = await _service.getBusinessId();
final QueryResult<dc.GetBusinessByIdData, dc.GetBusinessByIdVariables>
businessResult = await _service.connector
.getBusinessById(id: businessId)
.execute();
final dc.GetBusinessByIdBusiness? b = businessResult.data.business;
if (b == null) {
throw Exception('Business data not found for ID: $businessId');
}
final dc.ClientSession updatedSession = dc.ClientSession(
business: dc.ClientBusinessSession(
id: b.id,
businessName: b.businessName,
email: b.email ?? '',
city: b.city ?? '',
contactName: b.contactName ?? '',
companyLogoUrl: b.companyLogoUrl,
),
);
dc.ClientSessionStore.instance.setSession(updatedSession);
return UserSessionData(
businessName: b.businessName,
photoUrl: b.companyLogoUrl,
);
});
}
@override
Future<List<ReorderItem>> getRecentReorders() async {
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final DateTime now = DateTime.now();
final DateTime start = now.subtract(const Duration(days: 30));
final QueryResult<
dc.ListCompletedOrdersByBusinessAndDateRangeData,
dc.ListCompletedOrdersByBusinessAndDateRangeVariables
>
result = await _service.connector
.listCompletedOrdersByBusinessAndDateRange(
businessId: businessId,
start: _service.toTimestamp(start),
end: _service.toTimestamp(now),
)
.execute();
return result.data.orders.map((
dc.ListCompletedOrdersByBusinessAndDateRangeOrders order,
) {
final String title =
order.eventName ??
(order.shifts_on_order.isNotEmpty
? order.shifts_on_order[0].title
: 'Order');
final String location = order.shifts_on_order.isNotEmpty
? (order.shifts_on_order[0].location ??
order.shifts_on_order[0].locationAddress ??
'')
: '';
int totalWorkers = 0;
double totalHours = 0;
double totalRate = 0;
int roleCount = 0;
for (final dc.ListCompletedOrdersByBusinessAndDateRangeOrdersShiftsOnOrder
shift
in order.shifts_on_order) {
for (final dc.ListCompletedOrdersByBusinessAndDateRangeOrdersShiftsOnOrderShiftRolesOnShift
role
in shift.shiftRoles_on_shift) {
totalWorkers += role.count;
totalHours += role.hours ?? 0;
totalRate += role.role.costPerHour;
roleCount++;
}
}
return ReorderItem(
orderId: order.id,
title: title,
location: location,
totalCost: order.total ?? 0.0,
workers: totalWorkers,
type: order.orderType.stringValue,
hourlyRate: roleCount > 0 ? totalRate / roleCount : 0.0,
hours: totalHours,
);
}).toList();
});
Future<List<RecentOrder>> getRecentReorders() async {
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientReorders);
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
final List<dynamic> items = body['items'] as List<dynamic>;
return items
.map((dynamic json) =>
RecentOrder.fromJson(json as Map<String, dynamic>))
.toList();
}
}

View File

@@ -1,31 +1,15 @@
import 'package:krow_domain/krow_domain.dart';
/// User session data for the home page.
class UserSessionData {
/// Creates a [UserSessionData].
const UserSessionData({
required this.businessName,
this.photoUrl,
});
/// The business name of the logged-in user.
final String businessName;
/// The photo URL of the logged-in user (optional).
final String? photoUrl;
}
/// Interface for the Client Home repository.
///
/// This repository is responsible for providing data required for the
/// client home screen dashboard.
/// Provides data required for the client home screen dashboard
/// via the V2 REST API.
abstract interface class HomeRepositoryInterface {
/// Fetches the [HomeDashboardData] containing aggregated dashboard metrics.
Future<HomeDashboardData> getDashboardData();
/// Fetches the [ClientDashboard] containing aggregated dashboard metrics,
/// user name, and business info from `GET /client/dashboard`.
Future<ClientDashboard> getDashboard();
/// Fetches the user's session data (business name and photo).
Future<UserSessionData> getUserSessionData();
/// Fetches recently completed shift roles for reorder suggestions.
Future<List<ReorderItem>> getRecentReorders();
/// Fetches recent completed orders for reorder suggestions
/// from `GET /client/reorders`.
Future<List<RecentOrder>> getRecentReorders();
}

View File

@@ -1,19 +1,21 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/home_repository_interface.dart';
/// Use case to fetch dashboard data for the client home screen.
import 'package:client_home/src/domain/repositories/home_repository_interface.dart';
/// Use case to fetch the client dashboard from the V2 API.
///
/// This use case coordinates with the [HomeRepositoryInterface] to retrieve
/// the [HomeDashboardData] required for the dashboard display.
class GetDashboardDataUseCase implements NoInputUseCase<HomeDashboardData> {
/// Returns a [ClientDashboard] containing spending, coverage,
/// live-activity metrics and user/business info.
class GetDashboardDataUseCase implements NoInputUseCase<ClientDashboard> {
/// Creates a [GetDashboardDataUseCase].
GetDashboardDataUseCase(this._repository);
/// The repository providing dashboard data.
final HomeRepositoryInterface _repository;
@override
Future<HomeDashboardData> call() {
return _repository.getDashboardData();
Future<ClientDashboard> call() {
return _repository.getDashboard();
}
}

View File

@@ -1,16 +1,20 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/home_repository_interface.dart';
/// Use case to fetch recent completed shift roles for reorder suggestions.
class GetRecentReordersUseCase implements NoInputUseCase<List<ReorderItem>> {
import 'package:client_home/src/domain/repositories/home_repository_interface.dart';
/// Use case to fetch recent completed orders for reorder suggestions.
///
/// Returns a list of [RecentOrder] from the V2 API.
class GetRecentReordersUseCase implements NoInputUseCase<List<RecentOrder>> {
/// Creates a [GetRecentReordersUseCase].
GetRecentReordersUseCase(this._repository);
/// The repository providing reorder data.
final HomeRepositoryInterface _repository;
@override
Future<List<ReorderItem>> call() {
Future<List<RecentOrder>> call() {
return _repository.getRecentReorders();
}
}

View File

@@ -1,16 +0,0 @@
import '../repositories/home_repository_interface.dart';
/// Use case for retrieving user session data.
///
/// Returns the user's business name and photo URL for display in the header.
class GetUserSessionDataUseCase {
/// Creates a [GetUserSessionDataUseCase].
GetUserSessionDataUseCase(this._repository);
final HomeRepositoryInterface _repository;
/// Executes the use case to get session data.
Future<UserSessionData> call() {
return _repository.getUserSessionData();
}
}

View File

@@ -1,24 +1,27 @@
import 'package:client_home/src/domain/repositories/home_repository_interface.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/usecases/get_dashboard_data_usecase.dart';
import '../../domain/usecases/get_recent_reorders_usecase.dart';
import '../../domain/usecases/get_user_session_data_usecase.dart';
import 'client_home_event.dart';
import 'client_home_state.dart';
/// BLoC responsible for managing the state and business logic of the client home dashboard.
import 'package:client_home/src/domain/usecases/get_dashboard_data_usecase.dart';
import 'package:client_home/src/domain/usecases/get_recent_reorders_usecase.dart';
import 'package:client_home/src/presentation/blocs/client_home_event.dart';
import 'package:client_home/src/presentation/blocs/client_home_state.dart';
/// BLoC responsible for managing the client home dashboard state.
///
/// Fetches the [ClientDashboard] and recent reorders from the V2 API
/// and exposes layout-editing capabilities (reorder, toggle visibility).
class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState>
with BlocErrorHandler<ClientHomeState>, SafeBloc<ClientHomeEvent, ClientHomeState> {
with
BlocErrorHandler<ClientHomeState>,
SafeBloc<ClientHomeEvent, ClientHomeState> {
/// Creates a [ClientHomeBloc].
ClientHomeBloc({
required GetDashboardDataUseCase getDashboardDataUseCase,
required GetRecentReordersUseCase getRecentReordersUseCase,
required GetUserSessionDataUseCase getUserSessionDataUseCase,
}) : _getDashboardDataUseCase = getDashboardDataUseCase,
_getRecentReordersUseCase = getRecentReordersUseCase,
_getUserSessionDataUseCase = getUserSessionDataUseCase,
super(const ClientHomeState()) {
}) : _getDashboardDataUseCase = getDashboardDataUseCase,
_getRecentReordersUseCase = getRecentReordersUseCase,
super(const ClientHomeState()) {
on<ClientHomeStarted>(_onStarted);
on<ClientHomeEditModeToggled>(_onEditModeToggled);
on<ClientHomeWidgetVisibilityToggled>(_onWidgetVisibilityToggled);
@@ -27,9 +30,12 @@ class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState>
add(ClientHomeStarted());
}
/// Use case that fetches the client dashboard.
final GetDashboardDataUseCase _getDashboardDataUseCase;
/// Use case that fetches recent reorders.
final GetRecentReordersUseCase _getRecentReordersUseCase;
final GetUserSessionDataUseCase _getUserSessionDataUseCase;
Future<void> _onStarted(
ClientHomeStarted event,
@@ -39,20 +45,15 @@ class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState>
await handleError(
emit: emit.call,
action: () async {
// Get session data
final UserSessionData sessionData = await _getUserSessionDataUseCase();
// Get dashboard data
final HomeDashboardData data = await _getDashboardDataUseCase();
final List<ReorderItem> reorderItems = await _getRecentReordersUseCase();
final ClientDashboard dashboard = await _getDashboardDataUseCase();
final List<RecentOrder> reorderItems =
await _getRecentReordersUseCase();
emit(
state.copyWith(
status: ClientHomeStatus.success,
dashboardData: data,
dashboard: dashboard,
reorderItems: reorderItems,
businessName: sessionData.businessName,
photoUrl: sessionData.photoUrl,
),
);
},
@@ -121,4 +122,3 @@ class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState>
);
}
}

View File

@@ -2,11 +2,23 @@ import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Status of the client home dashboard.
enum ClientHomeStatus { initial, loading, success, error }
enum ClientHomeStatus {
/// Initial state before any data is loaded.
initial,
/// Data is being fetched.
loading,
/// Data was fetched successfully.
success,
/// An error occurred.
error,
}
/// Represents the state of the client home dashboard.
class ClientHomeState extends Equatable {
/// Creates a [ClientHomeState].
const ClientHomeState({
this.status = ClientHomeStatus.initial,
this.widgetOrder = const <String>[
@@ -25,38 +37,46 @@ class ClientHomeState extends Equatable {
},
this.isEditMode = false,
this.errorMessage,
this.dashboardData = const HomeDashboardData(
weeklySpending: 0.0,
next7DaysSpending: 0.0,
weeklyShifts: 0,
next7DaysScheduled: 0,
totalNeeded: 0,
totalFilled: 0,
),
this.reorderItems = const <ReorderItem>[],
this.businessName = 'Your Company',
this.photoUrl,
this.dashboard,
this.reorderItems = const <RecentOrder>[],
});
final ClientHomeStatus status;
final List<String> widgetOrder;
final Map<String, bool> widgetVisibility;
final bool isEditMode;
final String? errorMessage;
final HomeDashboardData dashboardData;
final List<ReorderItem> reorderItems;
final String businessName;
final String? photoUrl;
/// The current loading status.
final ClientHomeStatus status;
/// Ordered list of widget identifiers for the dashboard layout.
final List<String> widgetOrder;
/// Visibility map keyed by widget identifier.
final Map<String, bool> widgetVisibility;
/// Whether the dashboard is in edit/customise mode.
final bool isEditMode;
/// Error key for translation when [status] is [ClientHomeStatus.error].
final String? errorMessage;
/// The V2 client dashboard data (null until loaded).
final ClientDashboard? dashboard;
/// Recent orders available for quick reorder.
final List<RecentOrder> reorderItems;
/// The business name from the dashboard, with a safe fallback.
String get businessName => dashboard?.businessName ?? 'Your Company';
/// The user display name from the dashboard.
String get userName => dashboard?.userName ?? '';
/// Creates a copy of this state with the given fields replaced.
ClientHomeState copyWith({
ClientHomeStatus? status,
List<String>? widgetOrder,
Map<String, bool>? widgetVisibility,
bool? isEditMode,
String? errorMessage,
HomeDashboardData? dashboardData,
List<ReorderItem>? reorderItems,
String? businessName,
String? photoUrl,
ClientDashboard? dashboard,
List<RecentOrder>? reorderItems,
}) {
return ClientHomeState(
status: status ?? this.status,
@@ -64,23 +84,19 @@ class ClientHomeState extends Equatable {
widgetVisibility: widgetVisibility ?? this.widgetVisibility,
isEditMode: isEditMode ?? this.isEditMode,
errorMessage: errorMessage ?? this.errorMessage,
dashboardData: dashboardData ?? this.dashboardData,
dashboard: dashboard ?? this.dashboard,
reorderItems: reorderItems ?? this.reorderItems,
businessName: businessName ?? this.businessName,
photoUrl: photoUrl ?? this.photoUrl,
);
}
@override
List<Object?> get props => <Object?>[
status,
widgetOrder,
widgetVisibility,
isEditMode,
errorMessage,
dashboardData,
reorderItems,
businessName,
photoUrl,
];
status,
widgetOrder,
widgetVisibility,
isEditMode,
errorMessage,
dashboard,
reorderItems,
];
}

View File

@@ -3,10 +3,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import '../blocs/client_home_bloc.dart';
import '../widgets/client_home_body.dart';
import '../widgets/client_home_edit_banner.dart';
import '../widgets/client_home_header.dart';
import 'package:client_home/src/presentation/blocs/client_home_bloc.dart';
import 'package:client_home/src/presentation/widgets/client_home_body.dart';
import 'package:client_home/src/presentation/widgets/client_home_edit_banner.dart';
import 'package:client_home/src/presentation/widgets/client_home_header.dart';
/// The main Home page for client users.
///

View File

@@ -3,12 +3,12 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/client_home_bloc.dart';
import '../blocs/client_home_state.dart';
import 'client_home_edit_mode_body.dart';
import 'client_home_error_state.dart';
import 'client_home_normal_mode_body.dart';
import 'client_home_page_skeleton.dart';
import 'package:client_home/src/presentation/blocs/client_home_bloc.dart';
import 'package:client_home/src/presentation/blocs/client_home_state.dart';
import 'package:client_home/src/presentation/widgets/client_home_edit_mode_body.dart';
import 'package:client_home/src/presentation/widgets/client_home_error_state.dart';
import 'package:client_home/src/presentation/widgets/client_home_normal_mode_body.dart';
import 'package:client_home/src/presentation/widgets/client_home_page_skeleton.dart';
/// Main body widget for the client home page.
///

View File

@@ -1,9 +1,9 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/client_home_bloc.dart';
import '../blocs/client_home_event.dart';
import '../blocs/client_home_state.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';
/// A banner displayed when edit mode is active.
///

View File

@@ -2,10 +2,10 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/client_home_bloc.dart';
import '../blocs/client_home_event.dart';
import '../blocs/client_home_state.dart';
import 'dashboard_widget_builder.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';
/// Widget that displays the home dashboard in edit mode with drag-and-drop support.
///

View File

@@ -3,9 +3,9 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/client_home_bloc.dart';
import '../blocs/client_home_event.dart';
import '../blocs/client_home_state.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';
/// Widget that displays an error state for the client home page.
///

View File

@@ -3,23 +3,24 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../blocs/client_home_bloc.dart';
import '../blocs/client_home_event.dart';
import '../blocs/client_home_state.dart';
import 'header_icon_button.dart';
import 'client_home_header_skeleton.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/header_icon_button.dart';
import 'package:client_home/src/presentation/widgets/client_home_header_skeleton.dart';
/// The header section of the client home page.
///
/// Displays the user's business name, avatar, and action buttons
/// (edit mode, notifications, settings).
/// (edit mode, settings).
class ClientHomeHeader extends StatelessWidget {
/// Creates a [ClientHomeHeader].
const ClientHomeHeader({
required this.i18n,
super.key,
});
/// The internationalization object for localized strings.
final dynamic i18n;
@@ -33,7 +34,6 @@ class ClientHomeHeader extends StatelessWidget {
}
final String businessName = state.businessName;
final String? photoUrl = state.photoUrl;
final String avatarLetter = businessName.trim().isNotEmpty
? businessName.trim()[0].toUpperCase()
: 'C';
@@ -62,18 +62,12 @@ class ClientHomeHeader extends StatelessWidget {
),
child: CircleAvatar(
backgroundColor: UiColors.primary.withValues(alpha: 0.1),
backgroundImage:
photoUrl != null && photoUrl.isNotEmpty
? NetworkImage(photoUrl)
: null,
child: photoUrl != null && photoUrl.isNotEmpty
? null
: Text(
avatarLetter,
style: UiTypography.body2b.copyWith(
color: UiColors.primary,
),
),
child: Text(
avatarLetter,
style: UiTypography.body2b.copyWith(
color: UiColors.primary,
),
),
),
),
const SizedBox(width: UiConstants.space3),

View File

@@ -1,8 +1,8 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../blocs/client_home_state.dart';
import 'dashboard_widget_builder.dart';
import 'package:client_home/src/presentation/blocs/client_home_state.dart';
import 'package:client_home/src/presentation/widgets/dashboard_widget_builder.dart';
/// Widget that displays the home dashboard in normal mode.
///

View File

@@ -1,27 +0,0 @@
import 'package:flutter/material.dart';
import 'shift_order_form_sheet.dart';
/// Helper class for showing modal sheets in the client home feature.
class ClientHomeSheets {
/// Shows the shift order form bottom sheet.
///
/// Optionally accepts [initialData] to pre-populate the form for reordering.
/// Calls [onSubmit] when the user submits the form successfully.
static void showOrderFormSheet(
BuildContext context,
Map<String, dynamic>? initialData, {
required void Function(Map<String, dynamic>) onSubmit,
}) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (BuildContext context) {
return ShiftOrderFormSheet(
initialData: initialData,
onSubmit: onSubmit,
);
},
);
}
}

View File

@@ -1,217 +0,0 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A dashboard widget that displays today's coverage status.
class CoverageDashboard extends StatelessWidget {
/// Creates a [CoverageDashboard].
const CoverageDashboard({
super.key,
required this.shifts,
required this.applications,
});
/// The list of shifts for today.
final List<dynamic> shifts;
/// The list of applications for today's shifts.
final List<dynamic> applications;
@override
Widget build(BuildContext context) {
int totalNeeded = 0;
int totalConfirmed = 0;
double todayCost = 0;
for (final dynamic s in shifts) {
final int needed =
(s as Map<String, dynamic>)['workersNeeded'] as int? ?? 0;
final int confirmed = s['filled'] as int? ?? 0;
final double rate = s['hourlyRate'] as double? ?? 0.0;
final double hours = s['hours'] as double? ?? 0.0;
totalNeeded += needed;
totalConfirmed += confirmed;
todayCost += rate * hours;
}
final int coveragePercent = totalNeeded > 0
? ((totalConfirmed / totalNeeded) * 100).round()
: 100;
final int unfilledPositions = totalNeeded - totalConfirmed;
final int checkedInCount = applications
.where(
(dynamic a) => (a as Map<String, dynamic>)['checkInTime'] != null,
)
.length;
final int lateWorkersCount = applications
.where((dynamic a) => (a as Map<String, dynamic>)['status'] == 'LATE')
.length;
final bool isCoverageGood = coveragePercent >= 90;
final Color coverageBadgeColor = isCoverageGood
? UiColors.tagSuccess
: UiColors.tagPending;
final Color coverageTextColor = isCoverageGood
? UiColors.textSuccess
: UiColors.textWarning;
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border, width: 0.5),
),
child: Column(
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text("Today's Status", style: UiTypography.body1m.textSecondary),
if (totalNeeded > 0 || totalConfirmed > 0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: 2.0,
),
decoration: BoxDecoration(
color: coverageBadgeColor,
borderRadius: UiConstants.radiusMd,
),
child: Text(
'$coveragePercent% Covered',
style: UiTypography.footnote1b.copyWith(
color: coverageTextColor,
),
),
),
],
),
const SizedBox(height: UiConstants.space4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: Column(
children: <Widget>[
_StatusCard(
label: 'Unfilled Today',
value: '$unfilledPositions',
icon: UiIcons.warning,
isWarning: unfilledPositions > 0,
),
const SizedBox(height: UiConstants.space2),
_StatusCard(
label: 'Running Late',
value: '$lateWorkersCount',
icon: UiIcons.error,
isError: true,
),
],
),
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Column(
children: <Widget>[
_StatusCard(
label: 'Checked In',
value: '$checkedInCount/$totalNeeded',
icon: UiIcons.success,
isInfo: true,
),
const SizedBox(height: UiConstants.space2),
_StatusCard(
label: "Today's Cost",
value: '\$${todayCost.round()}',
icon: UiIcons.dollar,
isInfo: true,
),
],
),
),
],
),
],
),
);
}
}
class _StatusCard extends StatelessWidget {
const _StatusCard({
required this.label,
required this.value,
required this.icon,
this.isWarning = false,
this.isError = false,
this.isInfo = false,
});
final String label;
final String value;
final IconData icon;
final bool isWarning;
final bool isError;
final bool isInfo;
@override
Widget build(BuildContext context) {
Color bg = UiColors.bgSecondary;
Color border = UiColors.border;
Color iconColor = UiColors.iconSecondary;
Color textColor = UiColors.textPrimary;
if (isWarning) {
bg = UiColors.tagPending.withAlpha(80);
border = UiColors.textWarning.withAlpha(80);
iconColor = UiColors.textWarning;
textColor = UiColors.textWarning;
} else if (isError) {
bg = UiColors.tagError.withAlpha(80);
border = UiColors.borderError.withAlpha(80);
iconColor = UiColors.textError;
textColor = UiColors.textError;
} else if (isInfo) {
bg = UiColors.tagInProgress.withAlpha(80);
border = UiColors.primary.withValues(alpha: 0.2);
iconColor = UiColors.primary;
textColor = UiColors.primary;
}
return Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: bg,
border: Border.all(color: border),
borderRadius: UiConstants.radiusMd,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Icon(icon, size: 16, color: iconColor),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Text(
label,
style: UiTypography.footnote1m.copyWith(
color: textColor.withValues(alpha: 0.8),
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: UiConstants.space1),
Text(
value,
style: UiTypography.headline3m.copyWith(color: textColor),
),
],
),
);
}
}

View File

@@ -2,18 +2,20 @@ import 'package:core_localization/core_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../blocs/client_home_state.dart';
import '../widgets/actions_widget.dart';
import '../widgets/coverage_widget.dart';
import '../widgets/draggable_widget_wrapper.dart';
import '../widgets/live_activity_widget.dart';
import '../widgets/reorder_widget.dart';
import '../widgets/spending_widget.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:client_home/src/presentation/blocs/client_home_state.dart';
import 'package:client_home/src/presentation/widgets/actions_widget.dart';
import 'package:client_home/src/presentation/widgets/coverage_widget.dart';
import 'package:client_home/src/presentation/widgets/draggable_widget_wrapper.dart';
import 'package:client_home/src/presentation/widgets/live_activity_widget.dart';
import 'package:client_home/src/presentation/widgets/reorder_widget.dart';
import 'package:client_home/src/presentation/widgets/spending_widget.dart';
/// A widget that builds dashboard content based on widget ID.
///
/// This widget encapsulates the logic for rendering different dashboard
/// widgets based on their unique identifiers and current state.
/// Renders different dashboard sections depending on their unique identifier
/// and the current [ClientHomeState].
class DashboardWidgetBuilder extends StatelessWidget {
/// Creates a [DashboardWidgetBuilder].
const DashboardWidgetBuilder({
@@ -55,11 +57,16 @@ class DashboardWidgetBuilder extends StatelessWidget {
}
/// Builds the actual widget content based on the widget ID.
Widget _buildWidgetContent(BuildContext context, TranslationsClientHomeWidgetsEn i18n) {
Widget _buildWidgetContent(
BuildContext context,
TranslationsClientHomeWidgetsEn i18n,
) {
final String title = _getWidgetTitle(i18n);
// Only show subtitle in normal mode
final String? subtitle = !isEditMode ? _getWidgetSubtitle(id) : null;
final ClientDashboard? dashboard = state.dashboard;
switch (id) {
case 'actions':
return ActionsWidget(title: title, subtitle: subtitle);
@@ -71,28 +78,32 @@ class DashboardWidgetBuilder extends StatelessWidget {
);
case 'spending':
return SpendingWidget(
weeklySpending: state.dashboardData.weeklySpending,
next7DaysSpending: state.dashboardData.next7DaysSpending,
weeklyShifts: state.dashboardData.weeklyShifts,
next7DaysScheduled: state.dashboardData.next7DaysScheduled,
weeklySpendCents: dashboard?.spending.weeklySpendCents ?? 0,
projectedNext7DaysCents:
dashboard?.spending.projectedNext7DaysCents ?? 0,
title: title,
subtitle: subtitle,
);
case 'coverage':
final CoverageMetrics? coverage = dashboard?.coverage;
final int needed = coverage?.neededWorkersToday ?? 0;
final int filled = coverage?.filledWorkersToday ?? 0;
return CoverageWidget(
totalNeeded: state.dashboardData.totalNeeded,
totalConfirmed: state.dashboardData.totalFilled,
coveragePercent: state.dashboardData.totalNeeded > 0
? ((state.dashboardData.totalFilled /
state.dashboardData.totalNeeded) *
100)
.toInt()
: 0,
totalNeeded: needed,
totalConfirmed: filled,
coveragePercent: needed > 0 ? ((filled / needed) * 100).toInt() : 0,
title: title,
subtitle: subtitle,
);
case 'liveActivity':
return LiveActivityWidget(
metrics: dashboard?.liveActivity ??
const LiveActivityMetrics(
lateWorkersToday: 0,
checkedInWorkersToday: 0,
averageShiftCostCents: 0,
),
coverageNeeded: dashboard?.coverage.neededWorkersToday ?? 0,
onViewAllPressed: () => Modular.to.toClientCoverage(),
title: title,
subtitle: subtitle,
@@ -106,20 +117,21 @@ class DashboardWidgetBuilder extends StatelessWidget {
String _getWidgetTitle(dynamic i18n) {
switch (id) {
case 'actions':
return i18n.actions;
return i18n.actions as String;
case 'reorder':
return i18n.reorder;
return i18n.reorder as String;
case 'coverage':
return i18n.coverage;
return i18n.coverage as String;
case 'spending':
return i18n.spending;
return i18n.spending as String;
case 'liveActivity':
return i18n.live_activity;
return i18n.live_activity as String;
default:
return '';
}
}
/// Returns the subtitle for the widget based on its ID.
String _getWidgetSubtitle(String id) {
switch (id) {
case 'actions':

View File

@@ -1,8 +1,8 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/client_home_bloc.dart';
import '../blocs/client_home_event.dart';
import 'package:client_home/src/presentation/blocs/client_home_bloc.dart';
import 'package:client_home/src/presentation/blocs/client_home_event.dart';
/// A wrapper for dashboard widgets in edit mode.
///

View File

@@ -1,21 +1,31 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart';
import 'coverage_dashboard.dart';
import 'section_layout.dart';
/// A widget that displays live activity information.
class LiveActivityWidget extends StatefulWidget {
import 'package:client_home/src/presentation/widgets/section_layout.dart';
/// A widget that displays live activity metrics for today.
///
/// Renders checked-in count, late workers, and average shift cost
/// from the [LiveActivityMetrics] provided by the V2 dashboard endpoint.
class LiveActivityWidget extends StatelessWidget {
/// Creates a [LiveActivityWidget].
const LiveActivityWidget({
super.key,
required this.metrics,
required this.coverageNeeded,
required this.onViewAllPressed,
this.title,
this.subtitle
this.subtitle,
});
/// Live activity metrics from the V2 dashboard.
final LiveActivityMetrics metrics;
/// Workers needed today (from coverage metrics) for the checked-in ratio.
final int coverageNeeded;
/// Callback when "View all" is pressed.
final VoidCallback onViewAllPressed;
@@ -25,159 +35,180 @@ class LiveActivityWidget extends StatefulWidget {
/// Optional subtitle for the section.
final String? subtitle;
@override
State<LiveActivityWidget> createState() => _LiveActivityWidgetState();
}
class _LiveActivityWidgetState extends State<LiveActivityWidget> {
late final Future<_LiveActivityData> _liveActivityFuture =
_loadLiveActivity();
Future<_LiveActivityData> _loadLiveActivity() async {
final String? businessId =
dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return _LiveActivityData.empty();
}
final DateTime now = DateTime.now();
final DateTime start = DateTime(now.year, now.month, now.day);
final DateTime end = DateTime(now.year, now.month, now.day, 23, 59, 59, 999);
final fdc.QueryResult<dc.ListShiftRolesByBusinessAndDateRangeData,
dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult =
await dc.ExampleConnector.instance
.listShiftRolesByBusinessAndDateRange(
businessId: businessId,
start: _toTimestamp(start),
end: _toTimestamp(end),
)
.execute();
final fdc.QueryResult<dc.ListStaffsApplicationsByBusinessForDayData,
dc.ListStaffsApplicationsByBusinessForDayVariables> result =
await dc.ExampleConnector.instance
.listStaffsApplicationsByBusinessForDay(
businessId: businessId,
dayStart: _toTimestamp(start),
dayEnd: _toTimestamp(end),
)
.execute();
if (shiftRolesResult.data.shiftRoles.isEmpty &&
result.data.applications.isEmpty) {
return _LiveActivityData.empty();
}
int totalNeeded = 0;
double totalCost = 0;
for (final dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole
in shiftRolesResult.data.shiftRoles) {
totalNeeded += shiftRole.count;
totalCost += shiftRole.totalValue ?? 0;
}
final int totalAssigned = result.data.applications.length;
int lateCount = 0;
int checkedInCount = 0;
for (final dc.ListStaffsApplicationsByBusinessForDayApplications app
in result.data.applications) {
if (app.checkInTime != null) {
checkedInCount += 1;
}
if (app.status is dc.Known<dc.ApplicationStatus> &&
(app.status as dc.Known<dc.ApplicationStatus>).value ==
dc.ApplicationStatus.LATE) {
lateCount += 1;
}
}
return _LiveActivityData(
totalNeeded: totalNeeded,
totalAssigned: totalAssigned,
totalCost: totalCost,
checkedInCount: checkedInCount,
lateCount: lateCount,
);
}
fdc.Timestamp _toTimestamp(DateTime dateTime) {
final DateTime utc = dateTime.toUtc();
final int seconds = utc.millisecondsSinceEpoch ~/ 1000;
final int nanoseconds =
(utc.millisecondsSinceEpoch % 1000) * 1000000;
return fdc.Timestamp(nanoseconds, seconds);
}
@override
Widget build(BuildContext context) {
final TranslationsClientHomeEn i18n = t.client_home;
final int checkedIn = metrics.checkedInWorkersToday;
final int late_ = metrics.lateWorkersToday;
final String avgCostDisplay =
'\$${(metrics.averageShiftCostCents / 100).toStringAsFixed(0)}';
final int coveragePercent =
coverageNeeded > 0 ? ((checkedIn / coverageNeeded) * 100).round() : 100;
final bool isCoverageGood = coveragePercent >= 90;
final Color coverageBadgeColor =
isCoverageGood ? UiColors.tagSuccess : UiColors.tagPending;
final Color coverageTextColor =
isCoverageGood ? UiColors.textSuccess : UiColors.textWarning;
return SectionLayout(
title: widget.title,
subtitle: widget.subtitle,
title: title,
subtitle: subtitle,
action: i18n.dashboard.view_all,
onAction: widget.onViewAllPressed,
child: FutureBuilder<_LiveActivityData>(
future: _liveActivityFuture,
builder: (BuildContext context,
AsyncSnapshot<_LiveActivityData> snapshot) {
final _LiveActivityData data =
snapshot.data ?? _LiveActivityData.empty();
final List<Map<String, Object>> shifts =
<Map<String, Object>>[
<String, Object>{
'workersNeeded': data.totalNeeded,
'filled': data.totalAssigned,
'hourlyRate': 1.0,
'hours': data.totalCost,
'status': 'OPEN',
'date': DateTime.now().toIso8601String().split('T')[0],
},
];
final List<Map<String, Object?>> applications =
<Map<String, Object?>>[];
for (int i = 0; i < data.checkedInCount; i += 1) {
applications.add(
<String, Object?>{
'status': 'CONFIRMED',
'checkInTime': '09:00',
},
);
}
for (int i = 0; i < data.lateCount; i += 1) {
applications.add(<String, Object?>{'status': 'LATE'});
}
return CoverageDashboard(
shifts: shifts,
applications: applications,
);
},
onAction: onViewAllPressed,
child: Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border, width: 0.5),
),
child: Column(
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
// ASSUMPTION: Reusing hardcoded string from previous
// CoverageDashboard widget — a future localization pass should
// add a dedicated i18n key.
Text(
"Today's Status",
style: UiTypography.body1m.textSecondary,
),
if (coverageNeeded > 0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: 2.0,
),
decoration: BoxDecoration(
color: coverageBadgeColor,
borderRadius: UiConstants.radiusMd,
),
child: Text(
i18n.dashboard.percent_covered(percent: coveragePercent),
style: UiTypography.footnote1b.copyWith(
color: coverageTextColor,
),
),
),
],
),
const SizedBox(height: UiConstants.space4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: Column(
children: <Widget>[
// ASSUMPTION: Reusing hardcoded strings from previous
// CoverageDashboard widget.
_StatusCard(
label: 'Running Late',
value: '$late_',
icon: UiIcons.error,
isError: true,
),
const SizedBox(height: UiConstants.space2),
_StatusCard(
label: "Today's Cost",
value: avgCostDisplay,
icon: UiIcons.dollar,
isInfo: true,
),
],
),
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Column(
children: <Widget>[
_StatusCard(
label: 'Checked In',
value: '$checkedIn/$coverageNeeded',
icon: UiIcons.success,
isInfo: true,
),
],
),
),
],
),
],
),
),
);
}
}
class _LiveActivityData {
factory _LiveActivityData.empty() {
return const _LiveActivityData(
totalNeeded: 0,
totalAssigned: 0,
totalCost: 0,
checkedInCount: 0,
lateCount: 0,
);
}
const _LiveActivityData({
required this.totalNeeded,
required this.totalAssigned,
required this.totalCost,
required this.checkedInCount,
required this.lateCount,
class _StatusCard extends StatelessWidget {
const _StatusCard({
required this.label,
required this.value,
required this.icon,
this.isError = false,
this.isInfo = false,
});
final int totalNeeded;
final int totalAssigned;
final double totalCost;
final int checkedInCount;
final int lateCount;
final String label;
final String value;
final IconData icon;
final bool isError;
final bool isInfo;
@override
Widget build(BuildContext context) {
Color bg = UiColors.bgSecondary;
Color border = UiColors.border;
Color iconColor = UiColors.iconSecondary;
Color textColor = UiColors.textPrimary;
if (isError) {
bg = UiColors.tagError.withAlpha(80);
border = UiColors.borderError.withAlpha(80);
iconColor = UiColors.textError;
textColor = UiColors.textError;
} else if (isInfo) {
bg = UiColors.tagInProgress.withAlpha(80);
border = UiColors.primary.withValues(alpha: 0.2);
iconColor = UiColors.primary;
textColor = UiColors.primary;
}
return Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: bg,
border: Border.all(color: border),
borderRadius: UiConstants.radiusMd,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Icon(icon, size: 16, color: iconColor),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Text(
label,
style: UiTypography.footnote1m.copyWith(
color: textColor.withValues(alpha: 0.8),
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: UiConstants.space1),
Text(
value,
style: UiTypography.headline3m.copyWith(color: textColor),
),
],
),
);
}
}

View File

@@ -5,9 +5,11 @@ import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'section_layout.dart';
import 'package:client_home/src/presentation/widgets/section_layout.dart';
/// A widget that allows clients to reorder recent shifts.
/// A widget that allows clients to reorder recent orders.
///
/// Displays a horizontal list of [RecentOrder] cards with a reorder button.
class ReorderWidget extends StatelessWidget {
/// Creates a [ReorderWidget].
const ReorderWidget({
@@ -18,7 +20,7 @@ class ReorderWidget extends StatelessWidget {
});
/// Recent completed orders for reorder.
final List<ReorderItem> orders;
final List<RecentOrder> orders;
/// Optional title for the section.
final String? title;
@@ -34,21 +36,18 @@ class ReorderWidget extends StatelessWidget {
final TranslationsClientHomeReorderEn i18n = t.client_home.reorder;
final List<ReorderItem> recentOrders = orders;
return SectionLayout(
title: title,
subtitle: subtitle,
child: SizedBox(
height: 164,
height: 140,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: recentOrders.length,
itemCount: orders.length,
separatorBuilder: (BuildContext context, int index) =>
const SizedBox(width: UiConstants.space3),
itemBuilder: (BuildContext context, int index) {
final ReorderItem order = recentOrders[index];
final double totalCost = order.totalCost;
final RecentOrder order = orders[index];
return Container(
width: 260,
@@ -71,9 +70,7 @@ class ReorderWidget extends StatelessWidget {
width: 36,
height: 36,
decoration: BoxDecoration(
color: UiColors.primary.withValues(
alpha: 0.1,
),
color: UiColors.primary.withValues(alpha: 0.1),
borderRadius: UiConstants.radiusLg,
),
child: const Icon(
@@ -92,12 +89,14 @@ class ReorderWidget extends StatelessWidget {
style: UiTypography.body2b,
overflow: TextOverflow.ellipsis,
),
Text(
order.location,
style:
UiTypography.footnote1r.textSecondary,
overflow: TextOverflow.ellipsis,
),
if (order.hubName != null &&
order.hubName!.isNotEmpty)
Text(
order.hubName!,
style:
UiTypography.footnote1r.textSecondary,
overflow: TextOverflow.ellipsis,
),
],
),
),
@@ -107,12 +106,11 @@ class ReorderWidget extends StatelessWidget {
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
// ASSUMPTION: No i18n key for 'positions' under
// reorder section — carrying forward existing
// hardcoded string pattern for this migration.
Text(
'\$${totalCost.toStringAsFixed(0)}',
style: UiTypography.body1b,
),
Text(
'${i18n.per_hr(amount: order.hourlyRate.toString())} · ${order.hours}h',
'${order.positionCount} positions',
style: UiTypography.footnote2r.textSecondary,
),
],
@@ -124,7 +122,7 @@ class ReorderWidget extends StatelessWidget {
children: <Widget>[
_Badge(
icon: UiIcons.success,
text: order.type,
text: order.orderType.value,
color: UiColors.primary,
bg: UiColors.buttonSecondaryStill,
textColor: UiColors.primary,
@@ -132,7 +130,7 @@ class ReorderWidget extends StatelessWidget {
const SizedBox(width: UiConstants.space2),
_Badge(
icon: UiIcons.building,
text: '${order.workers}',
text: '${order.positionCount}',
color: UiColors.textSecondary,
bg: UiColors.buttonSecondaryStill,
textColor: UiColors.textSecondary,
@@ -140,24 +138,13 @@ class ReorderWidget extends StatelessWidget {
],
),
const Spacer(),
UiButton.secondary(
size: UiButtonSize.small,
text: i18n.reorder_button,
leadingIcon: UiIcons.zap,
iconSize: 12,
fullWidth: true,
onPressed: () =>
_handleReorderPressed(context, <String, dynamic>{
'orderId': order.orderId,
'title': order.title,
'location': order.location,
'hourlyRate': order.hourlyRate,
'hours': order.hours,
'workers': order.workers,
'type': order.type,
'totalCost': order.totalCost,
}),
onPressed: () => _handleReorderPressed(order),
),
],
),
@@ -168,28 +155,27 @@ class ReorderWidget extends StatelessWidget {
);
}
void _handleReorderPressed(BuildContext context, Map<String, dynamic> data) {
// Override start date with today's date as requested
final Map<String, dynamic> populatedData = Map<String, dynamic>.from(data)
..['startDate'] = DateTime.now();
/// Navigates to the appropriate create-order form pre-populated
/// with data from the selected [order].
void _handleReorderPressed(RecentOrder order) {
final Map<String, dynamic> populatedData = <String, dynamic>{
'orderId': order.id,
'title': order.title,
'location': order.hubName ?? '',
'workers': order.positionCount,
'type': order.orderType.value,
'startDate': DateTime.now(),
};
final String? typeStr = populatedData['type']?.toString();
if (typeStr == null || typeStr.isEmpty) {
return;
}
final OrderType orderType = OrderType.fromString(typeStr);
switch (orderType) {
switch (order.orderType) {
case OrderType.recurring:
Modular.to.toCreateOrderRecurring(arguments: populatedData);
break;
case OrderType.permanent:
Modular.to.toCreateOrderPermanent(arguments: populatedData);
break;
case OrderType.oneTime:
default:
case OrderType.rapid:
case OrderType.unknown:
Modular.to.toCreateOrderOneTime(arguments: populatedData);
break;
}
}
}
@@ -202,6 +188,7 @@ class _Badge extends StatelessWidget {
required this.bg,
required this.textColor,
});
final IconData icon;
final String text;
final Color color;

View File

@@ -2,32 +2,26 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'section_layout.dart';
import 'package:client_home/src/presentation/widgets/section_layout.dart';
/// A widget that displays spending insights for the client.
///
/// All monetary values are in **cents** and converted to dollars for display.
class SpendingWidget extends StatelessWidget {
/// Creates a [SpendingWidget].
const SpendingWidget({
super.key,
required this.weeklySpending,
required this.next7DaysSpending,
required this.weeklyShifts,
required this.next7DaysScheduled,
required this.weeklySpendCents,
required this.projectedNext7DaysCents,
this.title,
this.subtitle,
});
/// The spending this week.
final double weeklySpending;
/// The spending for the next 7 days.
final double next7DaysSpending;
/// Total spend this week in cents.
final int weeklySpendCents;
/// The number of shifts this week.
final int weeklyShifts;
/// The number of scheduled shifts for next 7 days.
final int next7DaysScheduled;
/// Projected spend for the next 7 days in cents.
final int projectedNext7DaysCents;
/// Optional title for the section.
final String? title;
@@ -37,6 +31,11 @@ class SpendingWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final String weeklyDisplay =
'\$${(weeklySpendCents / 100).toStringAsFixed(0)}';
final String projectedDisplay =
'\$${(projectedNext7DaysCents / 100).toStringAsFixed(0)}';
return SectionLayout(
title: title,
subtitle: subtitle,
@@ -77,19 +76,12 @@ class SpendingWidget extends StatelessWidget {
),
const SizedBox(height: UiConstants.space1),
Text(
'\$${weeklySpending.toStringAsFixed(0)}',
weeklyDisplay,
style: UiTypography.headline3m.copyWith(
color: UiColors.white,
fontWeight: FontWeight.bold,
),
),
Text(
t.client_home.dashboard.spending.shifts_count(count: weeklyShifts),
style: UiTypography.footnote2r.white.copyWith(
color: UiColors.white.withValues(alpha: 0.6),
fontSize: 9,
),
),
],
),
),
@@ -106,19 +98,12 @@ class SpendingWidget extends StatelessWidget {
),
const SizedBox(height: UiConstants.space1),
Text(
'\$${next7DaysSpending.toStringAsFixed(0)}',
projectedDisplay,
style: UiTypography.headline4m.copyWith(
color: UiColors.white,
fontWeight: FontWeight.bold,
),
),
Text(
t.client_home.dashboard.spending.scheduled_count(count: next7DaysScheduled),
style: UiTypography.footnote2r.white.copyWith(
color: UiColors.white.withValues(alpha: 0.6),
fontSize: 9,
),
),
],
),
),

View File

@@ -14,19 +14,16 @@ dependencies:
flutter_bloc: ^8.1.0
flutter_modular: ^6.3.0
equatable: ^2.0.5
# Architecture Packages
design_system:
path: ../../../design_system
core_localization:
path: ../../../core_localization
krow_domain: ^0.0.1
krow_data_connect: ^0.0.1
krow_core:
path: ../../../core
firebase_data_connect: any
intl: any
dev_dependencies:
flutter_test:
sdk: flutter

View File

@@ -3,34 +3,38 @@ library;
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'src/data/repositories_impl/hub_repository_impl.dart';
import 'src/domain/repositories/hub_repository_interface.dart';
import 'src/domain/usecases/assign_nfc_tag_usecase.dart';
import 'src/domain/usecases/create_hub_usecase.dart';
import 'src/domain/usecases/delete_hub_usecase.dart';
import 'src/domain/usecases/get_cost_centers_usecase.dart';
import 'src/domain/usecases/get_hubs_usecase.dart';
import 'src/domain/usecases/update_hub_usecase.dart';
import 'src/presentation/blocs/client_hubs_bloc.dart';
import 'src/presentation/blocs/edit_hub/edit_hub_bloc.dart';
import 'src/presentation/blocs/hub_details/hub_details_bloc.dart';
import 'src/presentation/pages/client_hubs_page.dart';
import 'src/presentation/pages/edit_hub_page.dart';
import 'src/presentation/pages/hub_details_page.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:client_hubs/src/data/repositories_impl/hub_repository_impl.dart';
import 'package:client_hubs/src/domain/repositories/hub_repository_interface.dart';
import 'package:client_hubs/src/domain/usecases/assign_nfc_tag_usecase.dart';
import 'package:client_hubs/src/domain/usecases/create_hub_usecase.dart';
import 'package:client_hubs/src/domain/usecases/delete_hub_usecase.dart';
import 'package:client_hubs/src/domain/usecases/get_cost_centers_usecase.dart';
import 'package:client_hubs/src/domain/usecases/get_hubs_usecase.dart';
import 'package:client_hubs/src/domain/usecases/update_hub_usecase.dart';
import 'package:client_hubs/src/presentation/blocs/client_hubs_bloc.dart';
import 'package:client_hubs/src/presentation/blocs/edit_hub/edit_hub_bloc.dart';
import 'package:client_hubs/src/presentation/blocs/hub_details/hub_details_bloc.dart';
import 'package:client_hubs/src/presentation/pages/client_hubs_page.dart';
import 'package:client_hubs/src/presentation/pages/edit_hub_page.dart';
import 'package:client_hubs/src/presentation/pages/hub_details_page.dart';
export 'src/presentation/pages/client_hubs_page.dart';
/// A [Module] for the client hubs feature.
///
/// Uses [BaseApiService] for all backend access via V2 REST API.
class ClientHubsModule extends Module {
@override
List<Module> get imports => <Module>[DataConnectModule()];
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<HubRepositoryInterface>(HubRepositoryImpl.new);
i.addLazySingleton<HubRepositoryInterface>(
() => HubRepositoryImpl(apiService: i.get<BaseApiService>()),
);
// UseCases
i.addLazySingleton(GetHubsUseCase.new);
@@ -55,7 +59,8 @@ class ClientHubsModule extends Module {
r.child(
ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.hubDetails),
child: (_) {
final Map<String, dynamic> data = r.args.data as Map<String, dynamic>;
final Map<String, dynamic> data =
r.args.data as Map<String, dynamic>;
final Hub hub = data['hub'] as Hub;
return HubDetailsPage(hub: hub);
},
@@ -65,18 +70,18 @@ class ClientHubsModule extends Module {
transition: TransitionType.custom,
customTransition: CustomTransition(
opaque: false,
transitionBuilder:
(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return FadeTransition(opacity: animation, child: child);
},
transitionBuilder: (
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return FadeTransition(opacity: animation, child: child);
},
),
child: (_) {
final Map<String, dynamic> data = r.args.data as Map<String, dynamic>;
final Map<String, dynamic> data =
r.args.data as Map<String, dynamic>;
return EditHubPage(hub: data['hub'] as Hub?);
},
);

View File

@@ -1,51 +1,46 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/hub_repository_interface.dart';
/// Implementation of [HubRepositoryInterface] that delegates to [dc.HubsConnectorRepository].
import 'package:client_hubs/src/domain/repositories/hub_repository_interface.dart';
/// Implementation of [HubRepositoryInterface] using the V2 REST API.
///
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
/// connector repository from the data_connect package.
/// All backend calls go through [BaseApiService] with [V2ApiEndpoints].
class HubRepositoryImpl implements HubRepositoryInterface {
/// Creates a [HubRepositoryImpl].
HubRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
HubRepositoryImpl({
dc.HubsConnectorRepository? connectorRepository,
dc.DataConnectService? service,
}) : _connectorRepository = connectorRepository ??
dc.DataConnectService.instance.getHubsRepository(),
_service = service ?? dc.DataConnectService.instance;
final dc.HubsConnectorRepository _connectorRepository;
final dc.DataConnectService _service;
/// The API service for HTTP requests.
final BaseApiService _apiService;
@override
Future<List<Hub>> getHubs() async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.getHubs(businessId: businessId);
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientHubs);
final List<dynamic> items =
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
return items
.map((dynamic json) => Hub.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<List<CostCenter>> getCostCenters() async {
return _service.run(() async {
final result = await _service.connector.listTeamHudDepartments().execute();
final Set<String> seen = <String>{};
final List<CostCenter> costCenters = <CostCenter>[];
for (final dc.ListTeamHudDepartmentsTeamHudDepartments dep
in result.data.teamHudDepartments) {
final String? cc = dep.costCenter;
if (cc != null && cc.isNotEmpty && !seen.contains(cc)) {
seen.add(cc);
costCenters.add(CostCenter(id: cc, name: dep.name, code: cc));
}
}
return costCenters;
});
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientCostCenters);
final List<dynamic> items =
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
return items
.map((dynamic json) =>
CostCenter.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<Hub> createHub({
Future<String> createHub({
required String name,
required String address,
required String fullAddress,
String? placeId,
double? latitude,
double? longitude,
@@ -56,41 +51,32 @@ class HubRepositoryImpl implements HubRepositoryInterface {
String? zipCode,
String? costCenterId,
}) async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.createHub(
businessId: businessId,
name: name,
address: address,
placeId: placeId,
latitude: latitude,
longitude: longitude,
city: city,
state: state,
street: street,
country: country,
zipCode: zipCode,
costCenterId: costCenterId,
final ApiResponse response = await _apiService.post(
V2ApiEndpoints.clientHubCreate,
data: <String, dynamic>{
'name': name,
'fullAddress': fullAddress,
if (placeId != null) 'placeId': placeId,
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
if (city != null) 'city': city,
if (state != null) 'state': state,
if (street != null) 'street': street,
if (country != null) 'country': country,
if (zipCode != null) 'zipCode': zipCode,
if (costCenterId != null) 'costCenterId': costCenterId,
},
);
final Map<String, dynamic> data =
response.data as Map<String, dynamic>;
return data['hubId'] as String;
}
@override
Future<void> deleteHub(String id) async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.deleteHub(businessId: businessId, id: id);
}
@override
Future<void> assignNfcTag({required String hubId, required String nfcTagId}) {
throw UnimplementedError(
'NFC tag assignment is not supported for team hubs.',
);
}
@override
Future<Hub> updateHub({
required String id,
Future<String> updateHub({
required String hubId,
String? name,
String? address,
String? fullAddress,
String? placeId,
double? latitude,
double? longitude,
@@ -101,22 +87,66 @@ class HubRepositoryImpl implements HubRepositoryInterface {
String? zipCode,
String? costCenterId,
}) async {
final String businessId = await _service.getBusinessId();
return _connectorRepository.updateHub(
businessId: businessId,
id: id,
name: name,
address: address,
placeId: placeId,
latitude: latitude,
longitude: longitude,
city: city,
state: state,
street: street,
country: country,
zipCode: zipCode,
costCenterId: costCenterId,
final ApiResponse response = await _apiService.put(
V2ApiEndpoints.clientHubUpdate(hubId),
data: <String, dynamic>{
'hubId': hubId,
if (name != null) 'name': name,
if (fullAddress != null) 'fullAddress': fullAddress,
if (placeId != null) 'placeId': placeId,
if (latitude != null) 'latitude': latitude,
if (longitude != null) 'longitude': longitude,
if (city != null) 'city': city,
if (state != null) 'state': state,
if (street != null) 'street': street,
if (country != null) 'country': country,
if (zipCode != null) 'zipCode': zipCode,
if (costCenterId != null) 'costCenterId': costCenterId,
},
);
final Map<String, dynamic> data =
response.data as Map<String, dynamic>;
return data['hubId'] as String;
}
@override
Future<void> deleteHub(String hubId) async {
await _apiService.delete(V2ApiEndpoints.clientHubDelete(hubId));
}
@override
Future<void> assignNfcTag({
required String hubId,
required String nfcTagId,
}) async {
await _apiService.post(
V2ApiEndpoints.clientHubAssignNfc(hubId),
data: <String, dynamic>{'nfcTagId': nfcTagId},
);
}
@override
Future<List<HubManager>> getManagers(String hubId) async {
final ApiResponse response =
await _apiService.get(V2ApiEndpoints.clientHubManagers(hubId));
final List<dynamic> items =
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
return items
.map((dynamic json) =>
HubManager.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<void> assignManagers({
required String hubId,
required List<String> businessMembershipIds,
}) async {
await _apiService.post(
V2ApiEndpoints.clientHubAssignManagers(hubId),
data: <String, dynamic>{
'businessMembershipIds': businessMembershipIds,
},
);
}
}

View File

@@ -1,14 +1,12 @@
import 'package:krow_core/core.dart';
/// Represents the arguments required for the AssignNfcTagUseCase.
/// Arguments for the [AssignNfcTagUseCase].
///
/// Encapsulates the hub ID and the NFC tag ID to be assigned.
class AssignNfcTagArguments extends UseCaseArgument {
/// Creates an [AssignNfcTagArguments] instance.
///
/// Both [hubId] and [nfcTagId] are required.
const AssignNfcTagArguments({required this.hubId, required this.nfcTagId});
/// The unique identifier of the hub.
final String hubId;

View File

@@ -1,16 +1,13 @@
import 'package:krow_core/core.dart';
/// Represents the arguments required for the CreateHubUseCase.
/// Arguments for the [CreateHubUseCase].
///
/// Encapsulates the name and address of the hub to be created.
class CreateHubArguments extends UseCaseArgument {
/// Creates a [CreateHubArguments] instance.
///
/// Both [name] and [address] are required.
const CreateHubArguments({
required this.name,
required this.address,
required this.fullAddress,
this.placeId,
this.latitude,
this.longitude,
@@ -21,36 +18,52 @@ class CreateHubArguments extends UseCaseArgument {
this.zipCode,
this.costCenterId,
});
/// The name of the hub.
/// The display name of the hub.
final String name;
/// The physical address of the hub.
final String address;
/// The full street address.
final String fullAddress;
/// Google Place ID.
final String? placeId;
/// GPS latitude.
final double? latitude;
/// GPS longitude.
final double? longitude;
/// City.
final String? city;
/// State.
final String? state;
/// Street.
final String? street;
/// Country.
final String? country;
/// Zip code.
final String? zipCode;
/// The cost center of the hub.
/// Associated cost center ID.
final String? costCenterId;
@override
List<Object?> get props => <Object?>[
name,
address,
placeId,
latitude,
longitude,
city,
state,
street,
country,
zipCode,
costCenterId,
];
name,
fullAddress,
placeId,
latitude,
longitude,
city,
state,
street,
country,
zipCode,
costCenterId,
];
}

View File

@@ -2,13 +2,10 @@ import 'package:krow_domain/krow_domain.dart';
/// Interface for the Hub repository.
///
/// This repository defines the contract for hub-related operations in the
/// domain layer. It handles fetching, creating, deleting hubs and assigning
/// NFC tags. The implementation will be provided in the data layer.
/// Defines the contract for hub-related operations. The implementation
/// uses the V2 REST API via [BaseApiService].
abstract interface class HubRepositoryInterface {
/// Fetches the list of hubs for the current client.
///
/// Returns a list of [Hub] entities.
Future<List<Hub>> getHubs();
/// Fetches the list of available cost centers for the current business.
@@ -16,11 +13,10 @@ abstract interface class HubRepositoryInterface {
/// Creates a new hub.
///
/// Takes the [name] and [address] of the new hub.
/// Returns the created [Hub] entity.
Future<Hub> createHub({
/// Returns the created hub ID.
Future<String> createHub({
required String name,
required String address,
required String fullAddress,
String? placeId,
double? latitude,
double? longitude,
@@ -32,21 +28,19 @@ abstract interface class HubRepositoryInterface {
String? costCenterId,
});
/// Deletes a hub by its [id].
Future<void> deleteHub(String id);
/// Deletes a hub by its [hubId].
Future<void> deleteHub(String hubId);
/// Assigns an NFC tag to a hub.
///
/// Takes the [hubId] and the [nfcTagId] to be associated.
Future<void> assignNfcTag({required String hubId, required String nfcTagId});
/// Updates an existing hub by its [id].
/// Updates an existing hub by its [hubId].
///
/// All fields other than [id] are optional — only supplied values are updated.
Future<Hub> updateHub({
required String id,
/// Only supplied values are updated.
Future<String> updateHub({
required String hubId,
String? name,
String? address,
String? fullAddress,
String? placeId,
double? latitude,
double? longitude,
@@ -57,4 +51,13 @@ abstract interface class HubRepositoryInterface {
String? zipCode,
String? costCenterId,
});
/// Fetches managers assigned to a hub.
Future<List<HubManager>> getManagers(String hubId);
/// Assigns managers to a hub.
Future<void> assignManagers({
required String hubId,
required List<String> businessMembershipIds,
});
}

View File

@@ -1,17 +1,16 @@
import 'package:krow_core/core.dart';
import '../arguments/assign_nfc_tag_arguments.dart';
import '../repositories/hub_repository_interface.dart';
import 'package:client_hubs/src/domain/arguments/assign_nfc_tag_arguments.dart';
import 'package:client_hubs/src/domain/repositories/hub_repository_interface.dart';
/// Use case for assigning an NFC tag to a hub.
///
/// This use case handles the association of a physical NFC tag with a specific
/// hub by calling the [HubRepositoryInterface].
/// Handles the association of a physical NFC tag with a specific hub.
class AssignNfcTagUseCase implements UseCase<AssignNfcTagArguments, void> {
/// Creates an [AssignNfcTagUseCase].
///
/// Requires a [HubRepositoryInterface] to interact with the backend.
AssignNfcTagUseCase(this._repository);
/// The repository for hub operations.
final HubRepositoryInterface _repository;
@override

View File

@@ -1,26 +1,24 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../arguments/create_hub_arguments.dart';
import '../repositories/hub_repository_interface.dart';
import 'package:client_hubs/src/domain/arguments/create_hub_arguments.dart';
import 'package:client_hubs/src/domain/repositories/hub_repository_interface.dart';
/// Use case for creating a new hub.
///
/// This use case orchestrates the creation of a hub by interacting with the
/// [HubRepositoryInterface]. It requires [CreateHubArguments] which includes
/// the name and address of the hub.
class CreateHubUseCase implements UseCase<CreateHubArguments, Hub> {
/// Orchestrates hub creation by delegating to [HubRepositoryInterface].
/// Returns the created hub ID.
class CreateHubUseCase implements UseCase<CreateHubArguments, String> {
/// Creates a [CreateHubUseCase].
///
/// Requires a [HubRepositoryInterface] to perform the actual creation.
CreateHubUseCase(this._repository);
/// The repository for hub operations.
final HubRepositoryInterface _repository;
@override
Future<Hub> call(CreateHubArguments arguments) {
Future<String> call(CreateHubArguments arguments) {
return _repository.createHub(
name: arguments.name,
address: arguments.address,
fullAddress: arguments.fullAddress,
placeId: arguments.placeId,
latitude: arguments.latitude,
longitude: arguments.longitude,

View File

@@ -1,16 +1,16 @@
import 'package:krow_core/core.dart';
import '../arguments/delete_hub_arguments.dart';
import '../repositories/hub_repository_interface.dart';
import 'package:client_hubs/src/domain/arguments/delete_hub_arguments.dart';
import 'package:client_hubs/src/domain/repositories/hub_repository_interface.dart';
/// Use case for deleting a hub.
///
/// This use case removes a hub from the system via the [HubRepositoryInterface].
/// Removes a hub from the system via [HubRepositoryInterface].
class DeleteHubUseCase implements UseCase<DeleteHubArguments, void> {
/// Creates a [DeleteHubUseCase].
///
/// Requires a [HubRepositoryInterface] to perform the deletion.
DeleteHubUseCase(this._repository);
/// The repository for hub operations.
final HubRepositoryInterface _repository;
@override

View File

@@ -1,13 +1,17 @@
import 'package:krow_domain/krow_domain.dart';
import '../repositories/hub_repository_interface.dart';
/// Usecase to fetch all available cost centers.
import 'package:client_hubs/src/domain/repositories/hub_repository_interface.dart';
/// Use case to fetch all available cost centers.
class GetCostCentersUseCase {
/// Creates a [GetCostCentersUseCase].
GetCostCentersUseCase({required HubRepositoryInterface repository})
: _repository = repository;
/// The repository for hub operations.
final HubRepositoryInterface _repository;
/// Executes the use case.
Future<List<CostCenter>> call() async {
return _repository.getCostCenters();
}

View File

@@ -1,17 +1,16 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/hub_repository_interface.dart';
import 'package:client_hubs/src/domain/repositories/hub_repository_interface.dart';
/// Use case for fetching the list of hubs.
///
/// This use case retrieves all hubs associated with the current client
/// by interacting with the [HubRepositoryInterface].
/// Retrieves all hubs associated with the current client.
class GetHubsUseCase implements NoInputUseCase<List<Hub>> {
/// Creates a [GetHubsUseCase].
///
/// Requires a [HubRepositoryInterface] to fetch the data.
GetHubsUseCase(this._repository);
/// The repository for hub operations.
final HubRepositoryInterface _repository;
@override

View File

@@ -1,14 +1,14 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/hub_repository_interface.dart';
import 'package:client_hubs/src/domain/repositories/hub_repository_interface.dart';
/// Arguments for the UpdateHubUseCase.
/// Arguments for the [UpdateHubUseCase].
class UpdateHubArguments extends UseCaseArgument {
/// Creates an [UpdateHubArguments] instance.
const UpdateHubArguments({
required this.id,
required this.hubId,
this.name,
this.address,
this.fullAddress,
this.placeId,
this.latitude,
this.longitude,
@@ -20,48 +20,75 @@ class UpdateHubArguments extends UseCaseArgument {
this.costCenterId,
});
final String id;
/// The hub ID to update.
final String hubId;
/// Updated name.
final String? name;
final String? address;
/// Updated full address.
final String? fullAddress;
/// Updated Google Place ID.
final String? placeId;
/// Updated latitude.
final double? latitude;
/// Updated longitude.
final double? longitude;
/// Updated city.
final String? city;
/// Updated state.
final String? state;
/// Updated street.
final String? street;
/// Updated country.
final String? country;
/// Updated zip code.
final String? zipCode;
/// Updated cost center ID.
final String? costCenterId;
@override
List<Object?> get props => <Object?>[
id,
name,
address,
placeId,
latitude,
longitude,
city,
state,
street,
country,
zipCode,
costCenterId,
];
hubId,
name,
fullAddress,
placeId,
latitude,
longitude,
city,
state,
street,
country,
zipCode,
costCenterId,
];
}
/// Use case for updating an existing hub.
class UpdateHubUseCase implements UseCase<UpdateHubArguments, Hub> {
UpdateHubUseCase(this.repository);
///
/// Returns the updated hub ID.
class UpdateHubUseCase implements UseCase<UpdateHubArguments, String> {
/// Creates an [UpdateHubUseCase].
UpdateHubUseCase(this._repository);
final HubRepositoryInterface repository;
/// The repository for hub operations.
final HubRepositoryInterface _repository;
@override
Future<Hub> call(UpdateHubArguments params) {
return repository.updateHub(
id: params.id,
Future<String> call(UpdateHubArguments params) {
return _repository.updateHub(
hubId: params.hubId,
name: params.name,
address: params.address,
fullAddress: params.fullAddress,
placeId: params.placeId,
latitude: params.latitude,
longitude: params.longitude,

View File

@@ -2,20 +2,21 @@ 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 '../../domain/usecases/get_hubs_usecase.dart';
import 'client_hubs_event.dart';
import 'client_hubs_state.dart';
/// BLoC responsible for managing the state of the Client Hubs feature.
import 'package:client_hubs/src/domain/usecases/get_hubs_usecase.dart';
import 'package:client_hubs/src/presentation/blocs/client_hubs_event.dart';
import 'package:client_hubs/src/presentation/blocs/client_hubs_state.dart';
/// BLoC responsible for managing the state of the Client Hubs list.
///
/// It orchestrates the flow between the UI and the domain layer by invoking
/// specific use cases for fetching hubs.
/// Invokes [GetHubsUseCase] to fetch hubs from the V2 API.
class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
with BlocErrorHandler<ClientHubsState>
implements Disposable {
/// Creates a [ClientHubsBloc].
ClientHubsBloc({required GetHubsUseCase getHubsUseCase})
: _getHubsUseCase = getHubsUseCase,
super(const ClientHubsState()) {
: _getHubsUseCase = getHubsUseCase,
super(const ClientHubsState()) {
on<ClientHubsFetched>(_onFetched);
on<ClientHubsMessageCleared>(_onMessageCleared);
}
@@ -49,8 +50,7 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
state.copyWith(
clearErrorMessage: true,
clearSuccessMessage: true,
status:
state.status == ClientHubsStatus.success ||
status: state.status == ClientHubsStatus.success ||
state.status == ClientHubsStatus.failure
? ClientHubsStatus.success
: state.status,

View File

@@ -1,24 +1,27 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../../domain/arguments/create_hub_arguments.dart';
import '../../../domain/usecases/create_hub_usecase.dart';
import '../../../domain/usecases/update_hub_usecase.dart';
import '../../../domain/usecases/get_cost_centers_usecase.dart';
import 'edit_hub_event.dart';
import 'edit_hub_state.dart';
import 'package:client_hubs/src/domain/arguments/create_hub_arguments.dart';
import 'package:client_hubs/src/domain/usecases/create_hub_usecase.dart';
import 'package:client_hubs/src/domain/usecases/get_cost_centers_usecase.dart';
import 'package:client_hubs/src/domain/usecases/update_hub_usecase.dart';
import 'package:client_hubs/src/presentation/blocs/edit_hub/edit_hub_event.dart';
import 'package:client_hubs/src/presentation/blocs/edit_hub/edit_hub_state.dart';
/// Bloc for creating and updating hubs.
class EditHubBloc extends Bloc<EditHubEvent, EditHubState>
with BlocErrorHandler<EditHubState> {
/// Creates an [EditHubBloc].
EditHubBloc({
required CreateHubUseCase createHubUseCase,
required UpdateHubUseCase updateHubUseCase,
required GetCostCentersUseCase getCostCentersUseCase,
}) : _createHubUseCase = createHubUseCase,
_updateHubUseCase = updateHubUseCase,
_getCostCentersUseCase = getCostCentersUseCase,
super(const EditHubState()) {
}) : _createHubUseCase = createHubUseCase,
_updateHubUseCase = updateHubUseCase,
_getCostCentersUseCase = getCostCentersUseCase,
super(const EditHubState()) {
on<EditHubCostCentersLoadRequested>(_onCostCentersLoadRequested);
on<EditHubAddRequested>(_onAddRequested);
on<EditHubUpdateRequested>(_onUpdateRequested);
@@ -35,7 +38,8 @@ class EditHubBloc extends Bloc<EditHubEvent, EditHubState>
await handleError(
emit: emit.call,
action: () async {
final List<CostCenter> costCenters = await _getCostCentersUseCase.call();
final List<CostCenter> costCenters =
await _getCostCentersUseCase.call();
emit(state.copyWith(costCenters: costCenters));
},
onError: (String errorKey) => state.copyWith(
@@ -57,7 +61,7 @@ class EditHubBloc extends Bloc<EditHubEvent, EditHubState>
await _createHubUseCase.call(
CreateHubArguments(
name: event.name,
address: event.address,
fullAddress: event.fullAddress,
placeId: event.placeId,
latitude: event.latitude,
longitude: event.longitude,
@@ -92,9 +96,9 @@ class EditHubBloc extends Bloc<EditHubEvent, EditHubState>
action: () async {
await _updateHubUseCase.call(
UpdateHubArguments(
id: event.id,
hubId: event.hubId,
name: event.name,
address: event.address,
fullAddress: event.fullAddress,
placeId: event.placeId,
latitude: event.latitude,
longitude: event.longitude,

View File

@@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart';
/// Base class for all edit hub events.
abstract class EditHubEvent extends Equatable {
/// Creates an [EditHubEvent].
const EditHubEvent();
@override
@@ -10,14 +11,16 @@ abstract class EditHubEvent extends Equatable {
/// Event triggered to load all available cost centers.
class EditHubCostCentersLoadRequested extends EditHubEvent {
/// Creates an [EditHubCostCentersLoadRequested].
const EditHubCostCentersLoadRequested();
}
/// Event triggered to add a new hub.
class EditHubAddRequested extends EditHubEvent {
/// Creates an [EditHubAddRequested].
const EditHubAddRequested({
required this.name,
required this.address,
required this.fullAddress,
this.placeId,
this.latitude,
this.longitude,
@@ -29,40 +32,62 @@ class EditHubAddRequested extends EditHubEvent {
this.costCenterId,
});
/// Hub name.
final String name;
final String address;
/// Full street address.
final String fullAddress;
/// Google Place ID.
final String? placeId;
/// GPS latitude.
final double? latitude;
/// GPS longitude.
final double? longitude;
/// City.
final String? city;
/// State.
final String? state;
/// Street.
final String? street;
/// Country.
final String? country;
/// Zip code.
final String? zipCode;
/// Cost center ID.
final String? costCenterId;
@override
List<Object?> get props => <Object?>[
name,
address,
placeId,
latitude,
longitude,
city,
state,
street,
country,
zipCode,
costCenterId,
];
name,
fullAddress,
placeId,
latitude,
longitude,
city,
state,
street,
country,
zipCode,
costCenterId,
];
}
/// Event triggered to update an existing hub.
class EditHubUpdateRequested extends EditHubEvent {
/// Creates an [EditHubUpdateRequested].
const EditHubUpdateRequested({
required this.id,
required this.hubId,
required this.name,
required this.address,
required this.fullAddress,
this.placeId,
this.latitude,
this.longitude,
@@ -74,32 +99,55 @@ class EditHubUpdateRequested extends EditHubEvent {
this.costCenterId,
});
final String id;
/// Hub ID to update.
final String hubId;
/// Updated name.
final String name;
final String address;
/// Updated full address.
final String fullAddress;
/// Updated Google Place ID.
final String? placeId;
/// Updated latitude.
final double? latitude;
/// Updated longitude.
final double? longitude;
/// Updated city.
final String? city;
/// Updated state.
final String? state;
/// Updated street.
final String? street;
/// Updated country.
final String? country;
/// Updated zip code.
final String? zipCode;
/// Updated cost center ID.
final String? costCenterId;
@override
List<Object?> get props => <Object?>[
id,
name,
address,
placeId,
latitude,
longitude,
city,
state,
street,
country,
zipCode,
costCenterId,
];
hubId,
name,
fullAddress,
placeId,
latitude,
longitude,
city,
state,
street,
country,
zipCode,
costCenterId,
];
}

View File

@@ -1,21 +1,23 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import '../../../domain/arguments/assign_nfc_tag_arguments.dart';
import '../../../domain/arguments/delete_hub_arguments.dart';
import '../../../domain/usecases/assign_nfc_tag_usecase.dart';
import '../../../domain/usecases/delete_hub_usecase.dart';
import 'hub_details_event.dart';
import 'hub_details_state.dart';
import 'package:client_hubs/src/domain/arguments/assign_nfc_tag_arguments.dart';
import 'package:client_hubs/src/domain/arguments/delete_hub_arguments.dart';
import 'package:client_hubs/src/domain/usecases/assign_nfc_tag_usecase.dart';
import 'package:client_hubs/src/domain/usecases/delete_hub_usecase.dart';
import 'package:client_hubs/src/presentation/blocs/hub_details/hub_details_event.dart';
import 'package:client_hubs/src/presentation/blocs/hub_details/hub_details_state.dart';
/// Bloc for managing hub details and operations like delete and NFC assignment.
class HubDetailsBloc extends Bloc<HubDetailsEvent, HubDetailsState>
with BlocErrorHandler<HubDetailsState> {
/// Creates a [HubDetailsBloc].
HubDetailsBloc({
required DeleteHubUseCase deleteHubUseCase,
required AssignNfcTagUseCase assignNfcTagUseCase,
}) : _deleteHubUseCase = deleteHubUseCase,
_assignNfcTagUseCase = assignNfcTagUseCase,
super(const HubDetailsState()) {
}) : _deleteHubUseCase = deleteHubUseCase,
_assignNfcTagUseCase = assignNfcTagUseCase,
super(const HubDetailsState()) {
on<HubDetailsDeleteRequested>(_onDeleteRequested);
on<HubDetailsNfcTagAssignRequested>(_onNfcTagAssignRequested);
}
@@ -32,7 +34,7 @@ class HubDetailsBloc extends Bloc<HubDetailsEvent, HubDetailsState>
await handleError(
emit: emit.call,
action: () async {
await _deleteHubUseCase.call(DeleteHubArguments(hubId: event.id));
await _deleteHubUseCase.call(DeleteHubArguments(hubId: event.hubId));
emit(
state.copyWith(
status: HubDetailsStatus.deleted,

View File

@@ -2,6 +2,7 @@ import 'package:equatable/equatable.dart';
/// Base class for all hub details events.
abstract class HubDetailsEvent extends Equatable {
/// Creates a [HubDetailsEvent].
const HubDetailsEvent();
@override
@@ -10,21 +11,28 @@ abstract class HubDetailsEvent extends Equatable {
/// Event triggered to delete a hub.
class HubDetailsDeleteRequested extends HubDetailsEvent {
const HubDetailsDeleteRequested(this.id);
final String id;
/// Creates a [HubDetailsDeleteRequested].
const HubDetailsDeleteRequested(this.hubId);
/// The ID of the hub to delete.
final String hubId;
@override
List<Object?> get props => <Object?>[id];
List<Object?> get props => <Object?>[hubId];
}
/// Event triggered to assign an NFC tag to a hub.
class HubDetailsNfcTagAssignRequested extends HubDetailsEvent {
/// Creates a [HubDetailsNfcTagAssignRequested].
const HubDetailsNfcTagAssignRequested({
required this.hubId,
required this.nfcTagId,
});
/// The hub ID.
final String hubId;
/// The NFC tag ID.
final String nfcTagId;
@override

View File

@@ -5,19 +5,18 @@ import 'package:flutter_modular/flutter_modular.dart';
import 'package:core_localization/core_localization.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/client_hubs_bloc.dart';
import '../blocs/client_hubs_event.dart';
import '../blocs/client_hubs_state.dart';
import '../widgets/hub_card.dart';
import '../widgets/hub_empty_state.dart';
import '../widgets/hub_info_card.dart';
import '../widgets/hubs_page_skeleton.dart';
import 'package:client_hubs/src/presentation/blocs/client_hubs_bloc.dart';
import 'package:client_hubs/src/presentation/blocs/client_hubs_event.dart';
import 'package:client_hubs/src/presentation/blocs/client_hubs_state.dart';
import 'package:client_hubs/src/presentation/widgets/hub_card.dart';
import 'package:client_hubs/src/presentation/widgets/hub_empty_state.dart';
import 'package:client_hubs/src/presentation/widgets/hub_info_card.dart';
import 'package:client_hubs/src/presentation/widgets/hubs_page_skeleton.dart';
/// The main page for the client hubs feature.
///
/// This page follows the KROW Clean Architecture by being a [StatelessWidget]
/// and delegating all state management to the [ClientHubsBloc].
/// Delegates all state management to [ClientHubsBloc].
class ClientHubsPage extends StatelessWidget {
/// Creates a [ClientHubsPage].
const ClientHubsPage({super.key});
@@ -99,7 +98,8 @@ class ClientHubsPage extends StatelessWidget {
else if (state.hubs.isEmpty)
HubEmptyState(
onAddPressed: () async {
final bool? success = await Modular.to.toEditHub();
final bool? success =
await Modular.to.toEditHub();
if (success == true && context.mounted) {
BlocProvider.of<ClientHubsBloc>(
context,
@@ -112,8 +112,8 @@ class ClientHubsPage extends StatelessWidget {
(Hub hub) => HubCard(
hub: hub,
onTap: () async {
final bool? success = await Modular.to
.toHubDetails(hub);
final bool? success =
await Modular.to.toHubDetails(hub);
if (success == true && context.mounted) {
BlocProvider.of<ClientHubsBloc>(
context,

View File

@@ -5,15 +5,17 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/edit_hub/edit_hub_bloc.dart';
import '../blocs/edit_hub/edit_hub_event.dart';
import '../blocs/edit_hub/edit_hub_state.dart';
import '../widgets/hub_form.dart';
import 'package:client_hubs/src/presentation/blocs/edit_hub/edit_hub_bloc.dart';
import 'package:client_hubs/src/presentation/blocs/edit_hub/edit_hub_event.dart';
import 'package:client_hubs/src/presentation/blocs/edit_hub/edit_hub_state.dart';
import 'package:client_hubs/src/presentation/widgets/hub_form.dart';
/// A wrapper page that shows the hub form in a modal-style layout.
class EditHubPage extends StatelessWidget {
/// Creates an [EditHubPage].
const EditHubPage({this.hub, super.key});
/// The hub to edit, or null for creating a new hub.
final Hub? hub;
@override
@@ -64,40 +66,39 @@ class EditHubPage extends StatelessWidget {
hub: hub,
costCenters: state.costCenters,
onCancel: () => Modular.to.pop(),
onSave:
({
required String name,
required String address,
String? costCenterId,
String? placeId,
double? latitude,
double? longitude,
}) {
if (hub == null) {
BlocProvider.of<EditHubBloc>(context).add(
EditHubAddRequested(
name: name,
address: address,
costCenterId: costCenterId,
placeId: placeId,
latitude: latitude,
longitude: longitude,
),
);
} else {
BlocProvider.of<EditHubBloc>(context).add(
EditHubUpdateRequested(
id: hub!.id,
name: name,
address: address,
costCenterId: costCenterId,
placeId: placeId,
latitude: latitude,
longitude: longitude,
),
);
}
},
onSave: ({
required String name,
required String fullAddress,
String? costCenterId,
String? placeId,
double? latitude,
double? longitude,
}) {
if (hub == null) {
BlocProvider.of<EditHubBloc>(context).add(
EditHubAddRequested(
name: name,
fullAddress: fullAddress,
costCenterId: costCenterId,
placeId: placeId,
latitude: latitude,
longitude: longitude,
),
);
} else {
BlocProvider.of<EditHubBloc>(context).add(
EditHubUpdateRequested(
hubId: hub!.hubId,
name: name,
fullAddress: fullAddress,
costCenterId: costCenterId,
placeId: placeId,
latitude: latitude,
longitude: longitude,
),
);
}
},
),
),

View File

@@ -6,18 +6,20 @@ import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/hub_details/hub_details_bloc.dart';
import '../blocs/hub_details/hub_details_event.dart';
import '../blocs/hub_details/hub_details_state.dart';
import '../widgets/hub_details/hub_details_bottom_actions.dart';
import '../widgets/hub_details/hub_details_item.dart';
import 'package:client_hubs/src/presentation/blocs/hub_details/hub_details_bloc.dart';
import 'package:client_hubs/src/presentation/blocs/hub_details/hub_details_event.dart';
import 'package:client_hubs/src/presentation/blocs/hub_details/hub_details_state.dart';
import 'package:client_hubs/src/presentation/widgets/hub_details/hub_details_bottom_actions.dart';
import 'package:client_hubs/src/presentation/widgets/hub_details/hub_details_item.dart';
/// A read-only details page for a single [Hub].
///
/// Shows hub name, address, and NFC tag assignment.
/// Shows hub name, address, NFC tag, and cost center.
class HubDetailsPage extends StatelessWidget {
/// Creates a [HubDetailsPage].
const HubDetailsPage({required this.hub, super.key});
/// The hub to display.
final Hub hub;
@override
@@ -30,7 +32,7 @@ class HubDetailsPage extends StatelessWidget {
final String message = state.successKey == 'deleted'
? t.client_hubs.hub_details.deleted_success
: (state.successMessage ??
t.client_hubs.hub_details.deleted_success);
t.client_hubs.hub_details.deleted_success);
UiSnackbar.show(
context,
message: message,
@@ -50,11 +52,12 @@ class HubDetailsPage extends StatelessWidget {
child: BlocBuilder<HubDetailsBloc, HubDetailsState>(
builder: (BuildContext context, HubDetailsState state) {
final bool isLoading = state.status == HubDetailsStatus.loading;
final String displayAddress = hub.fullAddress ?? '';
return Scaffold(
appBar: UiAppBar(
title: hub.name,
subtitle: hub.address,
subtitle: displayAddress,
showBackButton: true,
),
bottomNavigationBar: HubDetailsBottomActions(
@@ -75,25 +78,21 @@ class HubDetailsPage extends StatelessWidget {
children: <Widget>[
HubDetailsItem(
label: t.client_hubs.hub_details.nfc_label,
value:
hub.nfcTagId ??
value: hub.nfcTagId ??
t.client_hubs.hub_details.nfc_not_assigned,
icon: UiIcons.nfc,
isHighlight: hub.nfcTagId != null,
),
const SizedBox(height: UiConstants.space4),
HubDetailsItem(
label:
t.client_hubs.hub_details.cost_center_label,
value: hub.costCenter != null
? '${hub.costCenter!.name} (${hub.costCenter!.code})'
: t
.client_hubs
.hub_details
.cost_center_none,
icon: UiIcons
.bank, // Using bank icon for cost center
isHighlight: hub.costCenter != null,
label: t
.client_hubs.hub_details.cost_center_label,
value: hub.costCenterName != null
? hub.costCenterName!
: t.client_hubs.hub_details
.cost_center_none,
icon: UiIcons.bank,
isHighlight: hub.costCenterId != null,
),
],
),
@@ -143,7 +142,8 @@ class HubDetailsPage extends StatelessWidget {
);
if (confirm == true) {
Modular.get<HubDetailsBloc>().add(HubDetailsDeleteRequested(hub.id));
Modular.get<HubDetailsBloc>()
.add(HubDetailsDeleteRequested(hub.hubId));
}
}
}

View File

@@ -4,11 +4,12 @@ import 'package:flutter/material.dart';
import 'package:google_places_flutter/model/prediction.dart';
import 'package:krow_domain/krow_domain.dart';
import '../hub_address_autocomplete.dart';
import 'edit_hub_field_label.dart';
import 'package:client_hubs/src/presentation/widgets/hub_address_autocomplete.dart';
import 'package:client_hubs/src/presentation/widgets/edit_hub/edit_hub_field_label.dart';
/// The form section for adding or editing a hub.
class EditHubFormSection extends StatelessWidget {
/// Creates an [EditHubFormSection].
const EditHubFormSection({
required this.formKey,
required this.nameController,
@@ -16,24 +17,45 @@ class EditHubFormSection extends StatelessWidget {
required this.addressFocusNode,
required this.onAddressSelected,
required this.onSave,
required this.onCostCenterChanged,
this.costCenters = const <CostCenter>[],
this.selectedCostCenterId,
required this.onCostCenterChanged,
this.isSaving = false,
this.isEdit = false,
super.key,
});
/// Form key for validation.
final GlobalKey<FormState> formKey;
/// Controller for the name field.
final TextEditingController nameController;
/// Controller for the address field.
final TextEditingController addressController;
/// Focus node for the address field.
final FocusNode addressFocusNode;
/// Callback when an address prediction is selected.
final ValueChanged<Prediction> onAddressSelected;
/// Callback when the save button is pressed.
final VoidCallback onSave;
/// Available cost centers.
final List<CostCenter> costCenters;
/// Currently selected cost center ID.
final String? selectedCostCenterId;
/// Callback when the cost center selection changes.
final ValueChanged<String?> onCostCenterChanged;
/// Whether a save operation is in progress.
final bool isSaving;
/// Whether this is an edit (vs. create) operation.
final bool isEdit;
@override
@@ -43,7 +65,7 @@ class EditHubFormSection extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
// ── Name field ──────────────────────────────────
// -- Name field --
EditHubFieldLabel(t.client_hubs.edit_hub.name_label),
TextFormField(
controller: nameController,
@@ -60,7 +82,7 @@ class EditHubFormSection extends StatelessWidget {
const SizedBox(height: UiConstants.space4),
// ── Address field ────────────────────────────────
// -- Address field --
EditHubFieldLabel(t.client_hubs.edit_hub.address_label),
HubAddressAutocomplete(
controller: addressController,
@@ -71,6 +93,7 @@ class EditHubFormSection extends StatelessWidget {
const SizedBox(height: UiConstants.space4),
// -- Cost Center --
EditHubFieldLabel(t.client_hubs.edit_hub.cost_center_label),
InkWell(
onTap: () => _showCostCenterSelector(context),
@@ -116,7 +139,7 @@ class EditHubFormSection extends StatelessWidget {
const SizedBox(height: UiConstants.space8),
// ── Save button ──────────────────────────────────
// -- Save button --
UiButton.primary(
onPressed: isSaving ? null : onSave,
text: isEdit
@@ -157,8 +180,9 @@ class EditHubFormSection extends StatelessWidget {
String _getCostCenterName(String id) {
try {
final CostCenter cc = costCenters.firstWhere((CostCenter item) => item.id == id);
return cc.code != null ? '${cc.name} (${cc.code})' : cc.name;
final CostCenter cc =
costCenters.firstWhere((CostCenter item) => item.costCenterId == id);
return cc.name;
} catch (_) {
return id;
}
@@ -181,24 +205,27 @@ class EditHubFormSection extends StatelessWidget {
width: double.maxFinite,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child : costCenters.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(t.client_hubs.edit_hub.cost_centers_empty),
)
child: costCenters.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(t.client_hubs.edit_hub.cost_centers_empty),
)
: ListView.builder(
shrinkWrap: true,
itemCount: costCenters.length,
itemBuilder: (BuildContext context, int index) {
final CostCenter cc = costCenters[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(cc.name, style: UiTypography.body1m.textPrimary),
subtitle: cc.code != null ? Text(cc.code!, style: UiTypography.body2r.textSecondary) : null,
onTap: () => Navigator.of(context).pop(cc),
);
},
),
shrinkWrap: true,
itemCount: costCenters.length,
itemBuilder: (BuildContext context, int index) {
final CostCenter cc = costCenters[index];
return ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 24),
title: Text(
cc.name,
style: UiTypography.body1m.textPrimary,
),
onTap: () => Navigator.of(context).pop(cc),
);
},
),
),
),
);
@@ -206,7 +233,7 @@ class EditHubFormSection extends StatelessWidget {
);
if (selected != null) {
onCostCenterChanged(selected.id);
onCostCenterChanged(selected.costCenterId);
}
}
}

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