Merge 592-migrate-frontend-applications-to-v2-backend-and-database into feature/session-persistence-new
This commit is contained in:
@@ -2,7 +2,8 @@ library;
|
||||
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'src/data/repositories_impl/auth_repository_impl.dart';
|
||||
import 'src/domain/repositories/auth_repository_interface.dart';
|
||||
import 'src/domain/usecases/sign_in_with_email_use_case.dart';
|
||||
@@ -21,14 +22,19 @@ export 'src/presentation/pages/client_sign_up_page.dart';
|
||||
export 'package:core_localization/core_localization.dart';
|
||||
|
||||
/// A [Module] for the client authentication feature.
|
||||
///
|
||||
/// Imports [CoreModule] for [BaseApiService] and registers repositories,
|
||||
/// use cases, and BLoCs for the client authentication flow.
|
||||
class ClientAuthenticationModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[DataConnectModule()];
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<AuthRepositoryInterface>(AuthRepositoryImpl.new);
|
||||
i.addLazySingleton<AuthRepositoryInterface>(
|
||||
() => AuthRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||
);
|
||||
|
||||
// UseCases
|
||||
i.addLazySingleton(
|
||||
|
||||
@@ -1,68 +1,80 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:client_authentication/src/domain/repositories/auth_repository_interface.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
||||
import 'package: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';
|
||||
|
||||
/// 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(
|
||||
AuthEndpoints.clientSignIn,
|
||||
data: <String, dynamic>{
|
||||
'email': email,
|
||||
'password': password,
|
||||
},
|
||||
);
|
||||
|
||||
final Map<String, dynamic> body =
|
||||
response.data as Map<String, dynamic>;
|
||||
|
||||
// Step 2: Sign in locally so AuthInterceptor can attach Bearer tokens
|
||||
// to subsequent requests. The V2 API already validated credentials, so
|
||||
// email/password sign-in establishes the local Firebase Auth state.
|
||||
final firebase.UserCredential credential =
|
||||
await _auth.signInWithEmailAndPassword(
|
||||
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 +82,52 @@ 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(
|
||||
AuthEndpoints.clientSignUp,
|
||||
data: <String, dynamic>{
|
||||
'companyName': companyName,
|
||||
'email': email,
|
||||
'password': password,
|
||||
},
|
||||
);
|
||||
|
||||
firebaseUser = credential.user;
|
||||
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
|
||||
|
||||
// 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 +135,116 @@ 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(AuthEndpoints.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) {
|
||||
// The auth envelope from buildAuthEnvelope uses `user.id` but
|
||||
// ClientSession.fromJson expects `user.userId` (matching the shape
|
||||
// returned by loadActorContext / GET /client/session). Normalise the
|
||||
// key so the session is populated correctly.
|
||||
final Map<String, dynamic> normalisedEnvelope = <String, dynamic>{
|
||||
...envelope,
|
||||
if (userJson != null)
|
||||
'user': <String, dynamic>{
|
||||
...userJson,
|
||||
'userId': userJson['id'] ?? userJson['userId'],
|
||||
},
|
||||
};
|
||||
final ClientSession clientSession =
|
||||
ClientSession.fromJson(normalisedEnvelope);
|
||||
ClientSessionStore.instance.setSession(clientSession);
|
||||
}
|
||||
|
||||
final String userId =
|
||||
userJson?['id'] as String? ?? firebaseUser.uid;
|
||||
final String? email = userJson?['email'] as String? ?? fallbackEmail;
|
||||
|
||||
return User(
|
||||
id: userId,
|
||||
email: email,
|
||||
role: user.role.stringValue,
|
||||
displayName: userJson?['displayName'] as String?,
|
||||
phone: userJson?['phone'] as String?,
|
||||
status: _parseUserStatus(userJson?['status'] as String?),
|
||||
);
|
||||
}
|
||||
|
||||
final QueryResult<
|
||||
dc.GetBusinessesByUserIdData,
|
||||
dc.GetBusinessesByUserIdVariables
|
||||
>
|
||||
businessResponse = await _service.run(
|
||||
() => _service.connector
|
||||
.getBusinessesByUserId(userId: firebaseUserId)
|
||||
.execute(),
|
||||
);
|
||||
final dc.GetBusinessesByUserIdBusinesses? business =
|
||||
businessResponse.data.businesses.isNotEmpty
|
||||
? businessResponse.data.businesses.first
|
||||
: null;
|
||||
/// Maps a V2 error code to the appropriate domain exception for sign-up.
|
||||
Never _throwSignUpError(String errorCode, String message) {
|
||||
switch (errorCode) {
|
||||
case 'AUTH_PROVIDER_ERROR' when message.contains('EMAIL_EXISTS'):
|
||||
throw AccountExistsException(technicalMessage: message);
|
||||
case 'AUTH_PROVIDER_ERROR' when message.contains('WEAK_PASSWORD'):
|
||||
throw WeakPasswordException(technicalMessage: message);
|
||||
case 'FORBIDDEN':
|
||||
throw PasswordMismatchException(technicalMessage: message);
|
||||
default:
|
||||
throw SignUpFailedException(technicalMessage: '$errorCode: $message');
|
||||
}
|
||||
}
|
||||
|
||||
dc.ClientSessionStore.instance.setSession(
|
||||
dc.ClientSession(
|
||||
business: business == null
|
||||
? null
|
||||
: dc.ClientBusinessSession(
|
||||
id: business.id,
|
||||
businessName: business.businessName,
|
||||
email: business.email,
|
||||
city: business.city,
|
||||
contactName: business.contactName,
|
||||
companyLogoUrl: business.companyLogoUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return domainUser;
|
||||
/// Parses a status string from the API into a [UserStatus].
|
||||
static UserStatus _parseUserStatus(String? value) {
|
||||
switch (value?.toUpperCase()) {
|
||||
case 'ACTIVE':
|
||||
return UserStatus.active;
|
||||
case 'INVITED':
|
||||
return UserStatus.invited;
|
||||
case 'DISABLED':
|
||||
return UserStatus.disabled;
|
||||
default:
|
||||
return UserStatus.active;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,17 +14,13 @@ dependencies:
|
||||
flutter_bloc: ^8.1.0
|
||||
flutter_modular: ^6.3.0
|
||||
equatable: ^2.0.5
|
||||
firebase_core: ^4.2.1
|
||||
firebase_auth: ^6.1.2 # Updated for compatibility
|
||||
firebase_data_connect: ^0.2.2+1
|
||||
|
||||
firebase_auth: ^6.1.2
|
||||
|
||||
# Architecture Packages
|
||||
design_system:
|
||||
path: ../../../design_system
|
||||
core_localization:
|
||||
path: ../../../core_localization
|
||||
krow_data_connect:
|
||||
path: ../../../data_connect
|
||||
krow_domain:
|
||||
path: ../../../domain
|
||||
krow_core:
|
||||
@@ -35,7 +31,6 @@ dev_dependencies:
|
||||
sdk: flutter
|
||||
bloc_test: ^9.1.0
|
||||
mocktail: ^1.0.0
|
||||
build_runner: ^2.4.15
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
@@ -1,30 +1,37 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'data/repositories_impl/billing_repository_impl.dart';
|
||||
import 'domain/repositories/billing_repository.dart';
|
||||
import 'domain/usecases/get_bank_accounts.dart';
|
||||
import 'domain/usecases/get_current_bill_amount.dart';
|
||||
import 'domain/usecases/get_invoice_history.dart';
|
||||
import 'domain/usecases/get_pending_invoices.dart';
|
||||
import 'domain/usecases/get_savings_amount.dart';
|
||||
import 'domain/usecases/get_spending_breakdown.dart';
|
||||
import 'domain/usecases/approve_invoice.dart';
|
||||
import 'domain/usecases/dispute_invoice.dart';
|
||||
import 'presentation/blocs/billing_bloc.dart';
|
||||
import 'presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
|
||||
import 'presentation/models/billing_invoice_model.dart';
|
||||
import 'presentation/pages/billing_page.dart';
|
||||
import 'presentation/pages/completion_review_page.dart';
|
||||
import 'presentation/pages/invoice_ready_page.dart';
|
||||
import 'presentation/pages/pending_invoices_page.dart';
|
||||
import 'package:billing/src/data/repositories_impl/billing_repository_impl.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/usecases/approve_invoice.dart';
|
||||
import 'package:billing/src/domain/usecases/dispute_invoice.dart';
|
||||
import 'package:billing/src/domain/usecases/get_bank_accounts.dart';
|
||||
import 'package:billing/src/domain/usecases/get_current_bill_amount.dart';
|
||||
import 'package:billing/src/domain/usecases/get_invoice_history.dart';
|
||||
import 'package:billing/src/domain/usecases/get_pending_invoices.dart';
|
||||
import 'package:billing/src/domain/usecases/get_savings_amount.dart';
|
||||
import 'package:billing/src/domain/usecases/get_spending_breakdown.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
|
||||
import 'package:billing/src/presentation/pages/billing_page.dart';
|
||||
import 'package:billing/src/presentation/pages/completion_review_page.dart';
|
||||
import 'package:billing/src/presentation/pages/invoice_ready_page.dart';
|
||||
import 'package:billing/src/presentation/pages/pending_invoices_page.dart';
|
||||
|
||||
/// Modular module for the billing feature.
|
||||
///
|
||||
/// Uses [BaseApiService] for all backend access via V2 REST API.
|
||||
class BillingModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<BillingRepository>(BillingRepositoryImpl.new);
|
||||
i.addLazySingleton<BillingRepository>(
|
||||
() => BillingRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||
);
|
||||
|
||||
// Use Cases
|
||||
i.addLazySingleton(GetBankAccountsUseCase.new);
|
||||
@@ -32,7 +39,7 @@ class BillingModule extends Module {
|
||||
i.addLazySingleton(GetSavingsAmountUseCase.new);
|
||||
i.addLazySingleton(GetPendingInvoicesUseCase.new);
|
||||
i.addLazySingleton(GetInvoiceHistoryUseCase.new);
|
||||
i.addLazySingleton(GetSpendingBreakdownUseCase.new);
|
||||
i.addLazySingleton(GetSpendBreakdownUseCase.new);
|
||||
i.addLazySingleton(ApproveInvoiceUseCase.new);
|
||||
i.addLazySingleton(DisputeInvoiceUseCase.new);
|
||||
|
||||
@@ -44,7 +51,7 @@ class BillingModule extends Module {
|
||||
getSavingsAmount: i.get<GetSavingsAmountUseCase>(),
|
||||
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),
|
||||
getInvoiceHistory: i.get<GetInvoiceHistoryUseCase>(),
|
||||
getSpendingBreakdown: i.get<GetSpendingBreakdownUseCase>(),
|
||||
getSpendBreakdown: i.get<GetSpendBreakdownUseCase>(),
|
||||
),
|
||||
);
|
||||
i.add<ShiftCompletionReviewBloc>(
|
||||
@@ -62,16 +69,20 @@ class BillingModule extends Module {
|
||||
child: (_) => const BillingPage(),
|
||||
);
|
||||
r.child(
|
||||
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.completionReview),
|
||||
child: (_) =>
|
||||
ShiftCompletionReviewPage(invoice: r.args.data as BillingInvoice?),
|
||||
ClientPaths.childRoute(
|
||||
ClientPaths.billing, ClientPaths.completionReview),
|
||||
child: (_) => ShiftCompletionReviewPage(
|
||||
invoice:
|
||||
r.args.data is Invoice ? r.args.data as Invoice : null,
|
||||
),
|
||||
);
|
||||
r.child(
|
||||
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.invoiceReady),
|
||||
child: (_) => const InvoiceReadyPage(),
|
||||
);
|
||||
r.child(
|
||||
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.awaitingApproval),
|
||||
ClientPaths.childRoute(
|
||||
ClientPaths.billing, ClientPaths.awaitingApproval),
|
||||
child: (_) => const PendingInvoicesPage(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,70 +1,103 @@
|
||||
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Implementation of [BillingRepository] that delegates to [dc.BillingConnectorRepository].
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Implementation of [BillingRepository] using the V2 REST API.
|
||||
///
|
||||
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
|
||||
/// connector repository from the data_connect package.
|
||||
/// All backend calls go through [BaseApiService] with [ClientEndpoints].
|
||||
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(ClientEndpoints.billingAccounts);
|
||||
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(ClientEndpoints.billingInvoicesPending);
|
||||
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(ClientEndpoints.billingInvoicesHistory);
|
||||
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(ClientEndpoints.billingCurrentBill);
|
||||
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(ClientEndpoints.billingSavings);
|
||||
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(
|
||||
ClientEndpoints.billingSpendBreakdown,
|
||||
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(ClientEndpoints.invoiceApprove(id));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disputeInvoice(String id, String reason) async {
|
||||
return _connectorRepository.disputeInvoice(id: id, reason: reason);
|
||||
await _apiService.post(
|
||||
ClientEndpoints.invoiceDispute(id),
|
||||
data: <String, dynamic>{'reason': reason},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
/// It allows the Domain layer to remain independent of specific data sources.
|
||||
abstract class BillingRepository {
|
||||
/// Fetches bank accounts associated with the business.
|
||||
Future<List<BusinessBankAccount>> getBankAccounts();
|
||||
Future<List<BillingAccount>> getBankAccounts();
|
||||
|
||||
/// Fetches invoices that are pending approval or payment.
|
||||
Future<List<Invoice>> getPendingInvoices();
|
||||
@@ -15,14 +15,17 @@ abstract class BillingRepository {
|
||||
/// Fetches historically paid invoices.
|
||||
Future<List<Invoice>> getInvoiceHistory();
|
||||
|
||||
/// Fetches the current bill amount for the period.
|
||||
Future<double> getCurrentBillAmount();
|
||||
/// Fetches the current bill amount in cents for the period.
|
||||
Future<int> getCurrentBillCents();
|
||||
|
||||
/// Fetches the savings amount.
|
||||
Future<double> getSavingsAmount();
|
||||
/// Fetches the savings amount in cents.
|
||||
Future<int> getSavingsCents();
|
||||
|
||||
/// Fetches invoice items for spending breakdown analysis.
|
||||
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period);
|
||||
/// Fetches spending breakdown by category for a date range.
|
||||
Future<List<SpendItem>> getSpendBreakdown({
|
||||
required String startDate,
|
||||
required String endDate,
|
||||
});
|
||||
|
||||
/// Approves an invoice.
|
||||
Future<void> approveInvoice(String id);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for approving an invoice.
|
||||
class ApproveInvoiceUseCase extends UseCase<String, void> {
|
||||
/// Creates an [ApproveInvoiceUseCase].
|
||||
ApproveInvoiceUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Params for [DisputeInvoiceUseCase].
|
||||
class DisputeInvoiceParams {
|
||||
/// Creates [DisputeInvoiceParams].
|
||||
const DisputeInvoiceParams({required this.id, required this.reason});
|
||||
|
||||
/// The invoice ID to dispute.
|
||||
final String id;
|
||||
|
||||
/// The reason for the dispute.
|
||||
final String reason;
|
||||
}
|
||||
|
||||
@@ -13,6 +19,7 @@ class DisputeInvoiceUseCase extends UseCase<DisputeInvoiceParams, void> {
|
||||
/// Creates a [DisputeInvoiceUseCase].
|
||||
DisputeInvoiceUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the bank accounts associated with the business.
|
||||
class GetBankAccountsUseCase extends NoInputUseCase<List<BusinessBankAccount>> {
|
||||
class GetBankAccountsUseCase extends NoInputUseCase<List<BillingAccount>> {
|
||||
/// Creates a [GetBankAccountsUseCase].
|
||||
GetBankAccountsUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
Future<List<BusinessBankAccount>> call() => _repository.getBankAccounts();
|
||||
Future<List<BillingAccount>> call() => _repository.getBankAccounts();
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the current bill amount.
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the current bill amount in cents.
|
||||
///
|
||||
/// This use case encapsulates the logic for retrieving the total amount due for the current billing period.
|
||||
/// It delegates the data retrieval to the [BillingRepository].
|
||||
class GetCurrentBillAmountUseCase extends NoInputUseCase<double> {
|
||||
/// Delegates data retrieval to the [BillingRepository].
|
||||
class GetCurrentBillAmountUseCase extends NoInputUseCase<int> {
|
||||
/// Creates a [GetCurrentBillAmountUseCase].
|
||||
GetCurrentBillAmountUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
Future<double> call() => _repository.getCurrentBillAmount();
|
||||
Future<int> call() => _repository.getCurrentBillCents();
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the invoice history.
|
||||
///
|
||||
/// This use case encapsulates the logic for retrieving the list of past paid invoices.
|
||||
/// It delegates the data retrieval to the [BillingRepository].
|
||||
/// Retrieves the list of past paid invoices.
|
||||
class GetInvoiceHistoryUseCase extends NoInputUseCase<List<Invoice>> {
|
||||
/// Creates a [GetInvoiceHistoryUseCase].
|
||||
GetInvoiceHistoryUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the pending invoices.
|
||||
///
|
||||
/// This use case encapsulates the logic for retrieving invoices that are currently open or disputed.
|
||||
/// It delegates the data retrieval to the [BillingRepository].
|
||||
/// Retrieves invoices that are currently open or disputed.
|
||||
class GetPendingInvoicesUseCase extends NoInputUseCase<List<Invoice>> {
|
||||
/// Creates a [GetPendingInvoicesUseCase].
|
||||
GetPendingInvoicesUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the savings amount.
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the savings amount in cents.
|
||||
///
|
||||
/// This use case encapsulates the logic for retrieving the estimated savings for the client.
|
||||
/// It delegates the data retrieval to the [BillingRepository].
|
||||
class GetSavingsAmountUseCase extends NoInputUseCase<double> {
|
||||
/// Delegates data retrieval to the [BillingRepository].
|
||||
class GetSavingsAmountUseCase extends NoInputUseCase<int> {
|
||||
/// Creates a [GetSavingsAmountUseCase].
|
||||
GetSavingsAmountUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
Future<double> call() => _repository.getSavingsAmount();
|
||||
Future<int> call() => _repository.getSavingsCents();
|
||||
}
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the spending breakdown items.
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Parameters for [GetSpendBreakdownUseCase].
|
||||
class SpendBreakdownParams {
|
||||
/// Creates [SpendBreakdownParams].
|
||||
const SpendBreakdownParams({
|
||||
required this.startDate,
|
||||
required this.endDate,
|
||||
});
|
||||
|
||||
/// ISO-8601 start date for the range.
|
||||
final String startDate;
|
||||
|
||||
/// ISO-8601 end date for the range.
|
||||
final String endDate;
|
||||
}
|
||||
|
||||
/// Use case for fetching the spending breakdown by category.
|
||||
///
|
||||
/// This use case encapsulates the logic for retrieving the spending breakdown by category or item.
|
||||
/// It delegates the data retrieval to the [BillingRepository].
|
||||
class GetSpendingBreakdownUseCase
|
||||
extends UseCase<BillingPeriod, List<InvoiceItem>> {
|
||||
/// Creates a [GetSpendingBreakdownUseCase].
|
||||
GetSpendingBreakdownUseCase(this._repository);
|
||||
/// Delegates data retrieval to the [BillingRepository].
|
||||
class GetSpendBreakdownUseCase
|
||||
extends UseCase<SpendBreakdownParams, List<SpendItem>> {
|
||||
/// Creates a [GetSpendBreakdownUseCase].
|
||||
GetSpendBreakdownUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
Future<List<InvoiceItem>> call(BillingPeriod period) =>
|
||||
_repository.getSpendingBreakdown(period);
|
||||
Future<List<SpendItem>> call(SpendBreakdownParams input) =>
|
||||
_repository.getSpendBreakdown(
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/usecases/get_bank_accounts.dart';
|
||||
import '../../domain/usecases/get_current_bill_amount.dart';
|
||||
import '../../domain/usecases/get_invoice_history.dart';
|
||||
import '../../domain/usecases/get_pending_invoices.dart';
|
||||
import '../../domain/usecases/get_savings_amount.dart';
|
||||
import '../../domain/usecases/get_spending_breakdown.dart';
|
||||
import '../models/billing_invoice_model.dart';
|
||||
import '../models/spending_breakdown_model.dart';
|
||||
import 'billing_event.dart';
|
||||
import 'billing_state.dart';
|
||||
|
||||
import 'package:billing/src/domain/usecases/get_bank_accounts.dart';
|
||||
import 'package:billing/src/domain/usecases/get_current_bill_amount.dart';
|
||||
import 'package:billing/src/domain/usecases/get_invoice_history.dart';
|
||||
import 'package:billing/src/domain/usecases/get_pending_invoices.dart';
|
||||
import 'package:billing/src/domain/usecases/get_savings_amount.dart';
|
||||
import 'package:billing/src/domain/usecases/get_spending_breakdown.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
|
||||
/// BLoC for managing billing state and data loading.
|
||||
class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||
@@ -23,14 +23,14 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||
required GetSavingsAmountUseCase getSavingsAmount,
|
||||
required GetPendingInvoicesUseCase getPendingInvoices,
|
||||
required GetInvoiceHistoryUseCase getInvoiceHistory,
|
||||
required GetSpendingBreakdownUseCase getSpendingBreakdown,
|
||||
}) : _getBankAccounts = getBankAccounts,
|
||||
_getCurrentBillAmount = getCurrentBillAmount,
|
||||
_getSavingsAmount = getSavingsAmount,
|
||||
_getPendingInvoices = getPendingInvoices,
|
||||
_getInvoiceHistory = getInvoiceHistory,
|
||||
_getSpendingBreakdown = getSpendingBreakdown,
|
||||
super(const BillingState()) {
|
||||
required GetSpendBreakdownUseCase getSpendBreakdown,
|
||||
}) : _getBankAccounts = getBankAccounts,
|
||||
_getCurrentBillAmount = getCurrentBillAmount,
|
||||
_getSavingsAmount = getSavingsAmount,
|
||||
_getPendingInvoices = getPendingInvoices,
|
||||
_getInvoiceHistory = getInvoiceHistory,
|
||||
_getSpendBreakdown = getSpendBreakdown,
|
||||
super(const BillingState()) {
|
||||
on<BillingLoadStarted>(_onLoadStarted);
|
||||
on<BillingPeriodChanged>(_onPeriodChanged);
|
||||
}
|
||||
@@ -40,61 +40,60 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||
final GetSavingsAmountUseCase _getSavingsAmount;
|
||||
final GetPendingInvoicesUseCase _getPendingInvoices;
|
||||
final GetInvoiceHistoryUseCase _getInvoiceHistory;
|
||||
final GetSpendingBreakdownUseCase _getSpendingBreakdown;
|
||||
final GetSpendBreakdownUseCase _getSpendBreakdown;
|
||||
|
||||
/// Executes [loader] and returns null on failure, logging the error.
|
||||
Future<T?> _loadSafe<T>(Future<T> Function() loader) async {
|
||||
try {
|
||||
return await loader();
|
||||
} catch (e, stackTrace) {
|
||||
developer.log(
|
||||
'Partial billing load failed: $e',
|
||||
name: 'BillingBloc',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLoadStarted(
|
||||
BillingLoadStarted event,
|
||||
Emitter<BillingState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: BillingStatus.loading));
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final List<dynamic> results =
|
||||
await Future.wait<dynamic>(<Future<dynamic>>[
|
||||
_getCurrentBillAmount.call(),
|
||||
_getSavingsAmount.call(),
|
||||
_getPendingInvoices.call(),
|
||||
_getInvoiceHistory.call(),
|
||||
_getSpendingBreakdown.call(state.period),
|
||||
_getBankAccounts.call(),
|
||||
]);
|
||||
|
||||
final double savings = results[1] as double;
|
||||
final List<Invoice> pendingInvoices = results[2] as List<Invoice>;
|
||||
final List<Invoice> invoiceHistory = results[3] as List<Invoice>;
|
||||
final List<InvoiceItem> spendingItems = results[4] as List<InvoiceItem>;
|
||||
final List<BusinessBankAccount> bankAccounts =
|
||||
results[5] as List<BusinessBankAccount>;
|
||||
final SpendBreakdownParams spendParams = _dateRangeFor(state.periodTab);
|
||||
|
||||
// Map Domain Entities to Presentation Models
|
||||
final List<BillingInvoice> uiPendingInvoices = pendingInvoices
|
||||
.map(_mapInvoiceToUiModel)
|
||||
.toList();
|
||||
final List<BillingInvoice> uiInvoiceHistory = invoiceHistory
|
||||
.map(_mapInvoiceToUiModel)
|
||||
.toList();
|
||||
final List<SpendingBreakdownItem> uiSpendingBreakdown =
|
||||
_mapSpendingItemsToUiModel(spendingItems);
|
||||
final double periodTotal = uiSpendingBreakdown.fold(
|
||||
0.0,
|
||||
(double sum, SpendingBreakdownItem item) => sum + item.amount,
|
||||
);
|
||||
final List<Object?> results = await Future.wait<Object?>(
|
||||
<Future<Object?>>[
|
||||
_loadSafe<int>(() => _getCurrentBillAmount.call()),
|
||||
_loadSafe<int>(() => _getSavingsAmount.call()),
|
||||
_loadSafe<List<Invoice>>(() => _getPendingInvoices.call()),
|
||||
_loadSafe<List<Invoice>>(() => _getInvoiceHistory.call()),
|
||||
_loadSafe<List<SpendItem>>(() => _getSpendBreakdown.call(spendParams)),
|
||||
_loadSafe<List<BillingAccount>>(() => _getBankAccounts.call()),
|
||||
],
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: BillingStatus.success,
|
||||
currentBill: periodTotal,
|
||||
savings: savings,
|
||||
pendingInvoices: uiPendingInvoices,
|
||||
invoiceHistory: uiInvoiceHistory,
|
||||
spendingBreakdown: uiSpendingBreakdown,
|
||||
bankAccounts: bankAccounts,
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) =>
|
||||
state.copyWith(status: BillingStatus.failure, errorMessage: errorKey),
|
||||
final int? currentBillCents = results[0] as int?;
|
||||
final int? savingsCents = results[1] as int?;
|
||||
final List<Invoice>? pendingInvoices = results[2] as List<Invoice>?;
|
||||
final List<Invoice>? invoiceHistory = results[3] as List<Invoice>?;
|
||||
final List<SpendItem>? spendBreakdown = results[4] as List<SpendItem>?;
|
||||
final List<BillingAccount>? bankAccounts =
|
||||
results[5] as List<BillingAccount>?;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: BillingStatus.success,
|
||||
currentBillCents: currentBillCents ?? state.currentBillCents,
|
||||
savingsCents: savingsCents ?? state.savingsCents,
|
||||
pendingInvoices: pendingInvoices ?? state.pendingInvoices,
|
||||
invoiceHistory: invoiceHistory ?? state.invoiceHistory,
|
||||
spendBreakdown: spendBreakdown ?? state.spendBreakdown,
|
||||
bankAccounts: bankAccounts ?? state.bankAccounts,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,19 +104,15 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final List<InvoiceItem> spendingItems = await _getSpendingBreakdown
|
||||
.call(event.period);
|
||||
final List<SpendingBreakdownItem> uiSpendingBreakdown =
|
||||
_mapSpendingItemsToUiModel(spendingItems);
|
||||
final double periodTotal = uiSpendingBreakdown.fold(
|
||||
0.0,
|
||||
(double sum, SpendingBreakdownItem item) => sum + item.amount,
|
||||
);
|
||||
final SpendBreakdownParams params =
|
||||
_dateRangeFor(event.periodTab);
|
||||
final List<SpendItem> spendBreakdown =
|
||||
await _getSpendBreakdown.call(params);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
period: event.period,
|
||||
spendingBreakdown: uiSpendingBreakdown,
|
||||
currentBill: periodTotal,
|
||||
periodTab: event.periodTab,
|
||||
spendBreakdown: spendBreakdown,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -126,98 +121,14 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||
);
|
||||
}
|
||||
|
||||
BillingInvoice _mapInvoiceToUiModel(Invoice invoice) {
|
||||
final DateFormat formatter = DateFormat('EEEE, MMMM d');
|
||||
final String dateLabel = invoice.issueDate == null
|
||||
? 'N/A'
|
||||
: formatter.format(invoice.issueDate!);
|
||||
|
||||
final List<BillingWorkerRecord> workers = invoice.workers.map((
|
||||
InvoiceWorker w,
|
||||
) {
|
||||
final DateFormat timeFormat = DateFormat('h:mm a');
|
||||
return BillingWorkerRecord(
|
||||
workerName: w.name,
|
||||
roleName: w.role,
|
||||
totalAmount: w.amount,
|
||||
hours: w.hours,
|
||||
rate: w.rate,
|
||||
startTime: w.checkIn != null ? timeFormat.format(w.checkIn!) : '--:--',
|
||||
endTime: w.checkOut != null ? timeFormat.format(w.checkOut!) : '--:--',
|
||||
breakMinutes: w.breakMinutes,
|
||||
workerAvatarUrl: w.avatarUrl,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
String? overallStart;
|
||||
String? overallEnd;
|
||||
|
||||
// Find valid times from actual DateTime checks to ensure chronological sorting
|
||||
final List<DateTime> validCheckIns = invoice.workers
|
||||
.where((InvoiceWorker w) => w.checkIn != null)
|
||||
.map((InvoiceWorker w) => w.checkIn!)
|
||||
.toList();
|
||||
final List<DateTime> validCheckOuts = invoice.workers
|
||||
.where((InvoiceWorker w) => w.checkOut != null)
|
||||
.map((InvoiceWorker w) => w.checkOut!)
|
||||
.toList();
|
||||
|
||||
final DateFormat timeFormat = DateFormat('h:mm a');
|
||||
|
||||
if (validCheckIns.isNotEmpty) {
|
||||
validCheckIns.sort();
|
||||
overallStart = timeFormat.format(validCheckIns.first);
|
||||
} else if (workers.isNotEmpty) {
|
||||
overallStart = workers.first.startTime;
|
||||
}
|
||||
|
||||
if (validCheckOuts.isNotEmpty) {
|
||||
validCheckOuts.sort();
|
||||
overallEnd = timeFormat.format(validCheckOuts.last);
|
||||
} else if (workers.isNotEmpty) {
|
||||
overallEnd = workers.first.endTime;
|
||||
}
|
||||
|
||||
return BillingInvoice(
|
||||
id: invoice.id,
|
||||
title: invoice.title ?? 'N/A',
|
||||
locationAddress: invoice.locationAddress ?? 'Remote',
|
||||
clientName: invoice.clientName ?? 'N/A',
|
||||
date: dateLabel,
|
||||
totalAmount: invoice.totalAmount,
|
||||
workersCount: invoice.staffCount ?? 0,
|
||||
totalHours: invoice.totalHours ?? 0.0,
|
||||
status: invoice.status.name.toUpperCase(),
|
||||
workers: workers,
|
||||
startTime: overallStart,
|
||||
endTime: overallEnd,
|
||||
/// Computes ISO-8601 date range for the selected period tab.
|
||||
SpendBreakdownParams _dateRangeFor(BillingPeriodTab tab) {
|
||||
final DateTime now = DateTime.now().toUtc();
|
||||
final int days = tab == BillingPeriodTab.week ? 7 : 30;
|
||||
final DateTime start = now.subtract(Duration(days: days));
|
||||
return SpendBreakdownParams(
|
||||
startDate: start.toIso8601String(),
|
||||
endDate: now.toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
List<SpendingBreakdownItem> _mapSpendingItemsToUiModel(
|
||||
List<InvoiceItem> items,
|
||||
) {
|
||||
final Map<String, SpendingBreakdownItem> aggregation =
|
||||
<String, SpendingBreakdownItem>{};
|
||||
|
||||
for (final InvoiceItem item in items) {
|
||||
final String category = item.staffId;
|
||||
final SpendingBreakdownItem? existing = aggregation[category];
|
||||
if (existing != null) {
|
||||
aggregation[category] = SpendingBreakdownItem(
|
||||
category: category,
|
||||
hours: existing.hours + item.workHours.round(),
|
||||
amount: existing.amount + item.amount,
|
||||
);
|
||||
} else {
|
||||
aggregation[category] = SpendingBreakdownItem(
|
||||
category: category,
|
||||
hours: item.workHours.round(),
|
||||
amount: item.amount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return aggregation.values.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
|
||||
/// Base class for all billing events.
|
||||
abstract class BillingEvent extends Equatable {
|
||||
@@ -16,11 +17,14 @@ class BillingLoadStarted extends BillingEvent {
|
||||
const BillingLoadStarted();
|
||||
}
|
||||
|
||||
/// Event triggered when the spend breakdown period tab changes.
|
||||
class BillingPeriodChanged extends BillingEvent {
|
||||
const BillingPeriodChanged(this.period);
|
||||
/// Creates a [BillingPeriodChanged] event.
|
||||
const BillingPeriodChanged(this.periodTab);
|
||||
|
||||
final BillingPeriod period;
|
||||
/// The selected period tab.
|
||||
final BillingPeriodTab periodTab;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[period];
|
||||
List<Object?> get props => <Object?>[periodTab];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../models/billing_invoice_model.dart';
|
||||
import '../models/spending_breakdown_model.dart';
|
||||
|
||||
/// The loading status of the billing feature.
|
||||
enum BillingStatus {
|
||||
@@ -18,83 +16,104 @@ enum BillingStatus {
|
||||
failure,
|
||||
}
|
||||
|
||||
/// Which period the spend breakdown covers.
|
||||
enum BillingPeriodTab {
|
||||
/// Last 7 days.
|
||||
week,
|
||||
|
||||
/// Last 30 days.
|
||||
month,
|
||||
}
|
||||
|
||||
/// Represents the state of the billing feature.
|
||||
class BillingState extends Equatable {
|
||||
/// Creates a [BillingState].
|
||||
const BillingState({
|
||||
this.status = BillingStatus.initial,
|
||||
this.currentBill = 0.0,
|
||||
this.savings = 0.0,
|
||||
this.pendingInvoices = const <BillingInvoice>[],
|
||||
this.invoiceHistory = const <BillingInvoice>[],
|
||||
this.spendingBreakdown = const <SpendingBreakdownItem>[],
|
||||
this.bankAccounts = const <BusinessBankAccount>[],
|
||||
this.period = BillingPeriod.week,
|
||||
this.currentBillCents = 0,
|
||||
this.savingsCents = 0,
|
||||
this.pendingInvoices = const <Invoice>[],
|
||||
this.invoiceHistory = const <Invoice>[],
|
||||
this.spendBreakdown = const <SpendItem>[],
|
||||
this.bankAccounts = const <BillingAccount>[],
|
||||
this.periodTab = BillingPeriodTab.week,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
/// The current feature status.
|
||||
final BillingStatus status;
|
||||
|
||||
/// The total amount for the current billing period.
|
||||
final double currentBill;
|
||||
/// The total amount for the current billing period in cents.
|
||||
final int currentBillCents;
|
||||
|
||||
/// Total savings achieved compared to traditional agencies.
|
||||
final double savings;
|
||||
/// Total savings in cents.
|
||||
final int savingsCents;
|
||||
|
||||
/// Invoices awaiting client approval.
|
||||
final List<BillingInvoice> pendingInvoices;
|
||||
final List<Invoice> pendingInvoices;
|
||||
|
||||
/// History of paid invoices.
|
||||
final List<BillingInvoice> invoiceHistory;
|
||||
final List<Invoice> invoiceHistory;
|
||||
|
||||
/// Breakdown of spending by category.
|
||||
final List<SpendingBreakdownItem> spendingBreakdown;
|
||||
final List<SpendItem> spendBreakdown;
|
||||
|
||||
/// Bank accounts associated with the business.
|
||||
final List<BusinessBankAccount> bankAccounts;
|
||||
final List<BillingAccount> bankAccounts;
|
||||
|
||||
/// Selected period for the breakdown.
|
||||
final BillingPeriod period;
|
||||
/// Selected period tab for the breakdown.
|
||||
final BillingPeriodTab periodTab;
|
||||
|
||||
/// Error message if loading failed.
|
||||
final String? errorMessage;
|
||||
|
||||
/// Current bill formatted as dollars.
|
||||
double get currentBillDollars => currentBillCents / 100.0;
|
||||
|
||||
/// Savings formatted as dollars.
|
||||
double get savingsDollars => savingsCents / 100.0;
|
||||
|
||||
/// Total spend across the breakdown in cents.
|
||||
int get spendTotalCents => spendBreakdown.fold(
|
||||
0,
|
||||
(int sum, SpendItem item) => sum + item.amountCents,
|
||||
);
|
||||
|
||||
/// Creates a copy of this state with updated fields.
|
||||
BillingState copyWith({
|
||||
BillingStatus? status,
|
||||
double? currentBill,
|
||||
double? savings,
|
||||
List<BillingInvoice>? pendingInvoices,
|
||||
List<BillingInvoice>? invoiceHistory,
|
||||
List<SpendingBreakdownItem>? spendingBreakdown,
|
||||
List<BusinessBankAccount>? bankAccounts,
|
||||
BillingPeriod? period,
|
||||
int? currentBillCents,
|
||||
int? savingsCents,
|
||||
List<Invoice>? pendingInvoices,
|
||||
List<Invoice>? invoiceHistory,
|
||||
List<SpendItem>? spendBreakdown,
|
||||
List<BillingAccount>? bankAccounts,
|
||||
BillingPeriodTab? periodTab,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return BillingState(
|
||||
status: status ?? this.status,
|
||||
currentBill: currentBill ?? this.currentBill,
|
||||
savings: savings ?? this.savings,
|
||||
currentBillCents: currentBillCents ?? this.currentBillCents,
|
||||
savingsCents: savingsCents ?? this.savingsCents,
|
||||
pendingInvoices: pendingInvoices ?? this.pendingInvoices,
|
||||
invoiceHistory: invoiceHistory ?? this.invoiceHistory,
|
||||
spendingBreakdown: spendingBreakdown ?? this.spendingBreakdown,
|
||||
spendBreakdown: spendBreakdown ?? this.spendBreakdown,
|
||||
bankAccounts: bankAccounts ?? this.bankAccounts,
|
||||
period: period ?? this.period,
|
||||
periodTab: periodTab ?? this.periodTab,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
status,
|
||||
currentBill,
|
||||
savings,
|
||||
pendingInvoices,
|
||||
invoiceHistory,
|
||||
spendingBreakdown,
|
||||
bankAccounts,
|
||||
period,
|
||||
errorMessage,
|
||||
];
|
||||
status,
|
||||
currentBillCents,
|
||||
savingsCents,
|
||||
pendingInvoices,
|
||||
invoiceHistory,
|
||||
spendBreakdown,
|
||||
bankAccounts,
|
||||
periodTab,
|
||||
errorMessage,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../../../domain/usecases/approve_invoice.dart';
|
||||
import '../../../domain/usecases/dispute_invoice.dart';
|
||||
import 'shift_completion_review_event.dart';
|
||||
import 'shift_completion_review_state.dart';
|
||||
|
||||
import 'package:billing/src/domain/usecases/approve_invoice.dart';
|
||||
import 'package:billing/src/domain/usecases/dispute_invoice.dart';
|
||||
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_state.dart';
|
||||
|
||||
/// BLoC for approving or disputing an invoice from the review page.
|
||||
class ShiftCompletionReviewBloc
|
||||
extends Bloc<ShiftCompletionReviewEvent, ShiftCompletionReviewState>
|
||||
with BlocErrorHandler<ShiftCompletionReviewState> {
|
||||
/// Creates a [ShiftCompletionReviewBloc].
|
||||
ShiftCompletionReviewBloc({
|
||||
required ApproveInvoiceUseCase approveInvoice,
|
||||
required DisputeInvoiceUseCase disputeInvoice,
|
||||
}) : _approveInvoice = approveInvoice,
|
||||
_disputeInvoice = disputeInvoice,
|
||||
super(const ShiftCompletionReviewState()) {
|
||||
}) : _approveInvoice = approveInvoice,
|
||||
_disputeInvoice = disputeInvoice,
|
||||
super(const ShiftCompletionReviewState()) {
|
||||
on<ShiftCompletionReviewApproved>(_onApproved);
|
||||
on<ShiftCompletionReviewDisputed>(_onDisputed);
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class BillingInvoice extends Equatable {
|
||||
const BillingInvoice({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.locationAddress,
|
||||
required this.clientName,
|
||||
required this.date,
|
||||
required this.totalAmount,
|
||||
required this.workersCount,
|
||||
required this.totalHours,
|
||||
required this.status,
|
||||
this.workers = const <BillingWorkerRecord>[],
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String locationAddress;
|
||||
final String clientName;
|
||||
final String date;
|
||||
final double totalAmount;
|
||||
final int workersCount;
|
||||
final double totalHours;
|
||||
final String status;
|
||||
final List<BillingWorkerRecord> workers;
|
||||
final String? startTime;
|
||||
final String? endTime;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
title,
|
||||
locationAddress,
|
||||
clientName,
|
||||
date,
|
||||
totalAmount,
|
||||
workersCount,
|
||||
totalHours,
|
||||
status,
|
||||
workers,
|
||||
startTime,
|
||||
endTime,
|
||||
];
|
||||
}
|
||||
|
||||
class BillingWorkerRecord extends Equatable {
|
||||
const BillingWorkerRecord({
|
||||
required this.workerName,
|
||||
required this.roleName,
|
||||
required this.totalAmount,
|
||||
required this.hours,
|
||||
required this.rate,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.breakMinutes,
|
||||
this.workerAvatarUrl,
|
||||
});
|
||||
|
||||
final String workerName;
|
||||
final String roleName;
|
||||
final double totalAmount;
|
||||
final double hours;
|
||||
final double rate;
|
||||
final String startTime;
|
||||
final String endTime;
|
||||
final int breakMinutes;
|
||||
final String? workerAvatarUrl;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
workerName,
|
||||
roleName,
|
||||
totalAmount,
|
||||
hours,
|
||||
rate,
|
||||
startTime,
|
||||
endTime,
|
||||
breakMinutes,
|
||||
workerAvatarUrl,
|
||||
];
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Represents a single item in the spending breakdown.
|
||||
class SpendingBreakdownItem extends Equatable {
|
||||
/// Creates a [SpendingBreakdownItem].
|
||||
const SpendingBreakdownItem({
|
||||
required this.category,
|
||||
required this.hours,
|
||||
required this.amount,
|
||||
});
|
||||
|
||||
/// The category name (e.g., "Server Staff").
|
||||
final String category;
|
||||
|
||||
/// The total hours worked in this category.
|
||||
final int hours;
|
||||
|
||||
/// The total amount spent in this category.
|
||||
final double amount;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[category, hours, amount];
|
||||
}
|
||||
@@ -5,13 +5,13 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_event.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
import '../widgets/billing_page_skeleton.dart';
|
||||
import '../widgets/invoice_history_section.dart';
|
||||
import '../widgets/pending_invoices_section.dart';
|
||||
import '../widgets/spending_breakdown_card.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
import 'package:billing/src/presentation/widgets/billing_page_skeleton.dart';
|
||||
import 'package:billing/src/presentation/widgets/invoice_history_section.dart';
|
||||
import 'package:billing/src/presentation/widgets/pending_invoices_section.dart';
|
||||
import 'package:billing/src/presentation/widgets/spending_breakdown_card.dart';
|
||||
|
||||
/// The entry point page for the client billing feature.
|
||||
///
|
||||
@@ -32,8 +32,7 @@ class BillingPage extends StatelessWidget {
|
||||
|
||||
/// The main view for the client billing feature.
|
||||
///
|
||||
/// This widget displays the billing dashboard content based on the current
|
||||
/// state of the [BillingBloc].
|
||||
/// Displays the billing dashboard content based on the current [BillingState].
|
||||
class BillingView extends StatefulWidget {
|
||||
/// Creates a [BillingView].
|
||||
const BillingView({super.key});
|
||||
@@ -125,7 +124,7 @@ class _BillingViewState extends State<BillingView> {
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
'\$${state.currentBill.toStringAsFixed(2)}',
|
||||
'\$${state.currentBillDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.displayM.copyWith(
|
||||
color: UiColors.white,
|
||||
fontSize: 40,
|
||||
@@ -152,7 +151,8 @@ class _BillingViewState extends State<BillingView> {
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
t.client_billing.saved_amount(
|
||||
amount: state.savings.toStringAsFixed(0),
|
||||
amount: state.savingsDollars
|
||||
.toStringAsFixed(0),
|
||||
),
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: UiColors.accentForeground,
|
||||
@@ -221,7 +221,6 @@ class _BillingViewState extends State<BillingView> {
|
||||
if (state.pendingInvoices.isNotEmpty) ...<Widget>[
|
||||
PendingInvoicesSection(invoices: state.pendingInvoices),
|
||||
],
|
||||
// const PaymentMethodCard(),
|
||||
const SpendingBreakdownCard(),
|
||||
if (state.invoiceHistory.isNotEmpty)
|
||||
InvoiceHistorySection(invoices: state.invoiceHistory),
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../models/billing_invoice_model.dart';
|
||||
import '../widgets/completion_review/completion_review_actions.dart';
|
||||
import '../widgets/completion_review/completion_review_amount.dart';
|
||||
import '../widgets/completion_review/completion_review_info.dart';
|
||||
import '../widgets/completion_review/completion_review_search_and_tabs.dart';
|
||||
import '../widgets/completion_review/completion_review_worker_card.dart';
|
||||
import '../widgets/completion_review/completion_review_workers_header.dart';
|
||||
import 'package:billing/src/presentation/widgets/completion_review/completion_review_actions.dart';
|
||||
import 'package:billing/src/presentation/widgets/completion_review/completion_review_amount.dart';
|
||||
import 'package:billing/src/presentation/widgets/completion_review/completion_review_info.dart';
|
||||
|
||||
/// Page for reviewing and approving/disputing an invoice.
|
||||
class ShiftCompletionReviewPage extends StatefulWidget {
|
||||
/// Creates a [ShiftCompletionReviewPage].
|
||||
const ShiftCompletionReviewPage({this.invoice, super.key});
|
||||
|
||||
final BillingInvoice? invoice;
|
||||
/// The invoice to review.
|
||||
final Invoice? invoice;
|
||||
|
||||
@override
|
||||
State<ShiftCompletionReviewPage> createState() =>
|
||||
@@ -21,31 +23,45 @@ class ShiftCompletionReviewPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
||||
late BillingInvoice invoice;
|
||||
String searchQuery = '';
|
||||
int selectedTab = 0; // 0: Needs Review (mocked as empty), 1: All
|
||||
/// The resolved invoice, or null if route data is missing/invalid.
|
||||
late final Invoice? invoice;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Use widget.invoice if provided, else try to get from arguments
|
||||
invoice = widget.invoice ?? Modular.args.data as BillingInvoice;
|
||||
invoice = widget.invoice ??
|
||||
(Modular.args.data is Invoice
|
||||
? Modular.args.data as Invoice
|
||||
: null);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<BillingWorkerRecord> filteredWorkers = invoice.workers.where((
|
||||
BillingWorkerRecord w,
|
||||
) {
|
||||
if (searchQuery.isEmpty) return true;
|
||||
return w.workerName.toLowerCase().contains(searchQuery.toLowerCase()) ||
|
||||
w.roleName.toLowerCase().contains(searchQuery.toLowerCase());
|
||||
}).toList();
|
||||
final Invoice? resolvedInvoice = invoice;
|
||||
if (resolvedInvoice == null) {
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
title: t.client_billing.review_and_approve,
|
||||
showBackButton: true,
|
||||
),
|
||||
body: Center(
|
||||
child: Text(
|
||||
t.errors.generic.unknown,
|
||||
style: UiTypography.body1m.textError,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final DateFormat formatter = DateFormat('EEEE, MMMM d');
|
||||
final String dateLabel = resolvedInvoice.dueDate != null
|
||||
? formatter.format(resolvedInvoice.dueDate!)
|
||||
: 'N/A';
|
||||
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
title: invoice.title,
|
||||
subtitle: invoice.clientName,
|
||||
title: resolvedInvoice.invoiceNumber,
|
||||
subtitle: resolvedInvoice.vendorName ?? '',
|
||||
showBackButton: true,
|
||||
),
|
||||
body: SafeArea(
|
||||
@@ -55,26 +71,13 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
CompletionReviewInfo(invoice: invoice),
|
||||
CompletionReviewInfo(
|
||||
dateLabel: dateLabel,
|
||||
vendorName: resolvedInvoice.vendorName,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
CompletionReviewAmount(invoice: invoice),
|
||||
CompletionReviewAmount(amountCents: resolvedInvoice.amountCents),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
// CompletionReviewWorkersHeader(workersCount: invoice.workersCount),
|
||||
// const SizedBox(height: UiConstants.space4),
|
||||
// CompletionReviewSearchAndTabs(
|
||||
// selectedTab: selectedTab,
|
||||
// workersCount: invoice.workersCount,
|
||||
// onTabChanged: (int index) =>
|
||||
// setState(() => selectedTab = index),
|
||||
// onSearchChanged: (String val) =>
|
||||
// setState(() => searchQuery = val),
|
||||
// ),
|
||||
// const SizedBox(height: UiConstants.space4),
|
||||
// ...filteredWorkers.map(
|
||||
// (BillingWorkerRecord worker) =>
|
||||
// CompletionReviewWorkerCard(worker: worker),
|
||||
// ),
|
||||
// const SizedBox(height: UiConstants.space4),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -87,7 +90,9 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
||||
top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
|
||||
),
|
||||
),
|
||||
child: SafeArea(child: CompletionReviewActions(invoiceId: invoice.id)),
|
||||
child: SafeArea(
|
||||
child: CompletionReviewActions(invoiceId: resolvedInvoice.invoiceId),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,17 @@ import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_event.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
import '../models/billing_invoice_model.dart';
|
||||
import '../widgets/invoices_list_skeleton.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
import 'package:billing/src/presentation/widgets/invoices_list_skeleton.dart';
|
||||
|
||||
/// Page displaying invoices that are ready.
|
||||
class InvoiceReadyPage extends StatelessWidget {
|
||||
/// Creates an [InvoiceReadyPage].
|
||||
const InvoiceReadyPage({super.key});
|
||||
|
||||
@override
|
||||
@@ -21,7 +24,9 @@ class InvoiceReadyPage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// View for the invoice ready page.
|
||||
class InvoiceReadyView extends StatelessWidget {
|
||||
/// Creates an [InvoiceReadyView].
|
||||
const InvoiceReadyView({super.key});
|
||||
|
||||
@override
|
||||
@@ -60,7 +65,7 @@ class InvoiceReadyView extends StatelessWidget {
|
||||
separatorBuilder: (BuildContext context, int index) =>
|
||||
const SizedBox(height: 16),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final BillingInvoice invoice = state.invoiceHistory[index];
|
||||
final Invoice invoice = state.invoiceHistory[index];
|
||||
return _InvoiceSummaryCard(invoice: invoice);
|
||||
},
|
||||
);
|
||||
@@ -72,10 +77,17 @@ class InvoiceReadyView extends StatelessWidget {
|
||||
|
||||
class _InvoiceSummaryCard extends StatelessWidget {
|
||||
const _InvoiceSummaryCard({required this.invoice});
|
||||
final BillingInvoice invoice;
|
||||
|
||||
final Invoice invoice;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final DateFormat formatter = DateFormat('MMM d, yyyy');
|
||||
final String dateLabel = invoice.dueDate != null
|
||||
? formatter.format(invoice.dueDate!)
|
||||
: 'N/A';
|
||||
final double amountDollars = invoice.amountCents / 100.0;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
@@ -106,22 +118,26 @@ class _InvoiceSummaryCard extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'READY',
|
||||
invoice.status.value.toUpperCase(),
|
||||
style: UiTypography.titleUppercase4b.copyWith(
|
||||
color: UiColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(invoice.date, style: UiTypography.footnote2r.textTertiary),
|
||||
Text(dateLabel, style: UiTypography.footnote2r.textTertiary),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(invoice.title, style: UiTypography.title2b.textPrimary),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
invoice.locationAddress,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
invoice.invoiceNumber,
|
||||
style: UiTypography.title2b.textPrimary,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (invoice.vendorName != null)
|
||||
Text(
|
||||
invoice.vendorName!,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
@@ -134,7 +150,7 @@ class _InvoiceSummaryCard extends StatelessWidget {
|
||||
style: UiTypography.titleUppercase4m.textSecondary,
|
||||
),
|
||||
Text(
|
||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
||||
'\$${amountDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.title2b.primary,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -5,12 +5,14 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
import '../widgets/invoices_list_skeleton.dart';
|
||||
import '../widgets/pending_invoices_section.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
import 'package:billing/src/presentation/widgets/invoices_list_skeleton.dart';
|
||||
import 'package:billing/src/presentation/widgets/pending_invoices_section.dart';
|
||||
|
||||
/// Page listing all invoices awaiting client approval.
|
||||
class PendingInvoicesPage extends StatelessWidget {
|
||||
/// Creates a [PendingInvoicesPage].
|
||||
const PendingInvoicesPage({super.key});
|
||||
|
||||
@override
|
||||
@@ -44,7 +46,7 @@ class PendingInvoicesPage extends StatelessWidget {
|
||||
UiConstants.space5,
|
||||
UiConstants.space5,
|
||||
UiConstants.space5,
|
||||
100, // Bottom padding for scroll clearance
|
||||
100,
|
||||
),
|
||||
itemCount: state.pendingInvoices.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
@@ -87,6 +89,3 @@ class PendingInvoicesPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// We need to export the card widget from the section file if we want to reuse it,
|
||||
// or move it to its own file. I'll move it to a shared file or just make it public in the section file.
|
||||
|
||||
@@ -6,23 +6,26 @@ import 'package:flutter/material.dart';
|
||||
class BillingHeader extends StatelessWidget {
|
||||
/// Creates a [BillingHeader].
|
||||
const BillingHeader({
|
||||
required this.currentBill,
|
||||
required this.savings,
|
||||
required this.currentBillCents,
|
||||
required this.savingsCents,
|
||||
required this.onBack,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The amount of the current bill.
|
||||
final double currentBill;
|
||||
/// The amount of the current bill in cents.
|
||||
final int currentBillCents;
|
||||
|
||||
/// The amount saved in the current period.
|
||||
final double savings;
|
||||
/// The savings amount in cents.
|
||||
final int savingsCents;
|
||||
|
||||
/// Callback when the back button is pressed.
|
||||
final VoidCallback onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double billDollars = currentBillCents / 100.0;
|
||||
final double savingsDollars = savingsCents / 100.0;
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
UiConstants.space5,
|
||||
@@ -54,10 +57,9 @@ class BillingHeader extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
'\$${currentBill.toStringAsFixed(2)}',
|
||||
'\$${billDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.display1b.copyWith(color: UiColors.white),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -79,7 +81,7 @@ class BillingHeader extends StatelessWidget {
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
t.client_billing.saved_amount(
|
||||
amount: savings.toStringAsFixed(0),
|
||||
amount: savingsDollars.toStringAsFixed(0),
|
||||
),
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: UiColors.foreground,
|
||||
|
||||
@@ -5,87 +5,91 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../../blocs/shift_completion_review/shift_completion_review_bloc.dart';
|
||||
import '../../blocs/shift_completion_review/shift_completion_review_event.dart';
|
||||
import '../../blocs/shift_completion_review/shift_completion_review_state.dart';
|
||||
import '../../blocs/billing_bloc.dart';
|
||||
import '../../blocs/billing_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_state.dart';
|
||||
|
||||
/// Action buttons (approve / flag) at the bottom of the review page.
|
||||
class CompletionReviewActions extends StatelessWidget {
|
||||
/// Creates a [CompletionReviewActions].
|
||||
const CompletionReviewActions({required this.invoiceId, super.key});
|
||||
|
||||
/// The invoice ID to act upon.
|
||||
final String invoiceId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<ShiftCompletionReviewBloc>.value(
|
||||
value: Modular.get<ShiftCompletionReviewBloc>(),
|
||||
return BlocProvider<ShiftCompletionReviewBloc>(
|
||||
create: (_) => Modular.get<ShiftCompletionReviewBloc>(),
|
||||
child:
|
||||
BlocConsumer<ShiftCompletionReviewBloc, ShiftCompletionReviewState>(
|
||||
listener: (BuildContext context, ShiftCompletionReviewState state) {
|
||||
if (state.status == ShiftCompletionReviewStatus.success) {
|
||||
final String message = state.message == 'approved'
|
||||
? t.client_billing.approved_success
|
||||
: t.client_billing.flagged_success;
|
||||
final UiSnackbarType type = state.message == 'approved'
|
||||
? UiSnackbarType.success
|
||||
: UiSnackbarType.warning;
|
||||
listener: (BuildContext context, ShiftCompletionReviewState state) {
|
||||
if (state.status == ShiftCompletionReviewStatus.success) {
|
||||
final String message = state.message == 'approved'
|
||||
? t.client_billing.approved_success
|
||||
: t.client_billing.flagged_success;
|
||||
final UiSnackbarType type = state.message == 'approved'
|
||||
? UiSnackbarType.success
|
||||
: UiSnackbarType.warning;
|
||||
|
||||
UiSnackbar.show(context, message: message, type: type);
|
||||
Modular.get<BillingBloc>().add(const BillingLoadStarted());
|
||||
Modular.to.toAwaitingApproval();
|
||||
} else if (state.status == ShiftCompletionReviewStatus.failure) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: state.errorMessage ?? t.errors.generic.unknown,
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, ShiftCompletionReviewState state) {
|
||||
final bool isLoading =
|
||||
state.status == ShiftCompletionReviewStatus.loading;
|
||||
UiSnackbar.show(context, message: message, type: type);
|
||||
Modular.get<BillingBloc>().add(const BillingLoadStarted());
|
||||
Modular.to.toAwaitingApproval();
|
||||
} else if (state.status == ShiftCompletionReviewStatus.failure) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: state.errorMessage ?? t.errors.generic.unknown,
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, ShiftCompletionReviewState state) {
|
||||
final bool isLoading =
|
||||
state.status == ShiftCompletionReviewStatus.loading;
|
||||
|
||||
return Row(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: UiButton.secondary(
|
||||
text: t.client_billing.actions.flag_review,
|
||||
leadingIcon: UiIcons.warning,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => _showFlagDialog(context, state),
|
||||
size: UiButtonSize.large,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: UiColors.destructive,
|
||||
side: BorderSide.none,
|
||||
),
|
||||
),
|
||||
return Row(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: UiButton.secondary(
|
||||
text: t.client_billing.actions.flag_review,
|
||||
leadingIcon: UiIcons.warning,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => _showFlagDialog(context, state),
|
||||
size: UiButtonSize.large,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: UiColors.destructive,
|
||||
side: BorderSide.none,
|
||||
),
|
||||
Expanded(
|
||||
child: UiButton.primary(
|
||||
text: t.client_billing.actions.approve_pay,
|
||||
leadingIcon: isLoading ? null : UiIcons.checkCircle,
|
||||
isLoading: isLoading,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () {
|
||||
BlocProvider.of<ShiftCompletionReviewBloc>(
|
||||
context,
|
||||
).add(ShiftCompletionReviewApproved(invoiceId));
|
||||
},
|
||||
size: UiButtonSize.large,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: UiButton.primary(
|
||||
text: t.client_billing.actions.approve_pay,
|
||||
leadingIcon: isLoading ? null : UiIcons.checkCircle,
|
||||
isLoading: isLoading,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () {
|
||||
BlocProvider.of<ShiftCompletionReviewBloc>(
|
||||
context,
|
||||
).add(ShiftCompletionReviewApproved(invoiceId));
|
||||
},
|
||||
size: UiButtonSize.large,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFlagDialog(BuildContext context, ShiftCompletionReviewState state) {
|
||||
void _showFlagDialog(
|
||||
BuildContext context, ShiftCompletionReviewState state) {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
|
||||
@@ -2,15 +2,18 @@ import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/billing_invoice_model.dart';
|
||||
|
||||
/// Displays the total invoice amount on the review page.
|
||||
class CompletionReviewAmount extends StatelessWidget {
|
||||
const CompletionReviewAmount({required this.invoice, super.key});
|
||||
/// Creates a [CompletionReviewAmount].
|
||||
const CompletionReviewAmount({required this.amountCents, super.key});
|
||||
|
||||
final BillingInvoice invoice;
|
||||
/// The invoice total in cents.
|
||||
final int amountCents;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double amountDollars = amountCents / 100.0;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
@@ -27,13 +30,9 @@ class CompletionReviewAmount extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
||||
'\$${amountDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.headline1b.textPrimary.copyWith(fontSize: 40),
|
||||
),
|
||||
Text(
|
||||
'${invoice.totalHours.toStringAsFixed(1)} ${t.client_billing.hours_suffix} • \$${(invoice.totalAmount / (invoice.totalHours > 0.1 ? invoice.totalHours : 1)).toStringAsFixed(2)}${t.client_billing.avg_rate_suffix}',
|
||||
style: UiTypography.footnote2b.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/billing_invoice_model.dart';
|
||||
|
||||
/// Displays invoice metadata (date, vendor) on the review page.
|
||||
class CompletionReviewInfo extends StatelessWidget {
|
||||
const CompletionReviewInfo({required this.invoice, super.key});
|
||||
/// Creates a [CompletionReviewInfo].
|
||||
const CompletionReviewInfo({
|
||||
required this.dateLabel,
|
||||
this.vendorName,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final BillingInvoice invoice;
|
||||
/// Formatted date string.
|
||||
final String dateLabel;
|
||||
|
||||
/// Vendor name, if available.
|
||||
final String? vendorName;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -14,12 +22,9 @@ class CompletionReviewInfo extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
_buildInfoRow(UiIcons.calendar, invoice.date),
|
||||
_buildInfoRow(
|
||||
UiIcons.clock,
|
||||
'${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}',
|
||||
),
|
||||
_buildInfoRow(UiIcons.mapPin, invoice.locationAddress),
|
||||
_buildInfoRow(UiIcons.calendar, dateLabel),
|
||||
if (vendorName != null)
|
||||
_buildInfoRow(UiIcons.building, vendorName!),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,126 +1,17 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/billing_invoice_model.dart';
|
||||
|
||||
/// Card showing a single worker's details in the completion review.
|
||||
///
|
||||
/// Currently unused -- the V2 Invoice entity does not include per-worker
|
||||
/// breakdown data. This widget is retained as a placeholder for when the
|
||||
/// backend adds worker-level invoice detail endpoints.
|
||||
class CompletionReviewWorkerCard extends StatelessWidget {
|
||||
const CompletionReviewWorkerCard({required this.worker, super.key});
|
||||
|
||||
final BillingWorkerRecord worker;
|
||||
/// Creates a [CompletionReviewWorkerCard].
|
||||
const CompletionReviewWorkerCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: UiColors.bgSecondary,
|
||||
backgroundImage: worker.workerAvatarUrl != null
|
||||
? NetworkImage(worker.workerAvatarUrl!)
|
||||
: null,
|
||||
child: worker.workerAvatarUrl == null
|
||||
? const Icon(
|
||||
UiIcons.user,
|
||||
size: 20,
|
||||
color: UiColors.iconSecondary,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
worker.workerName,
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
Text(
|
||||
worker.roleName,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'\$${worker.totalAmount.toStringAsFixed(2)}',
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
Text(
|
||||
'${worker.hours}h x \$${worker.rate.toStringAsFixed(2)}/hr',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Text(
|
||||
'${worker.startTime} - ${worker.endTime}',
|
||||
style: UiTypography.footnote2b.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: UiConstants.radiusMd,
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.coffee,
|
||||
size: 12,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${worker.breakMinutes} ${t.client_billing.workers_tab.min_break}',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
UiIconButton.secondary(icon: UiIcons.edit, onTap: () {}),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
UiIconButton.secondary(icon: UiIcons.warning, onTap: () {}),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
// Placeholder until V2 API provides worker-level invoice data.
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/billing_invoice_model.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Section showing the history of paid invoices.
|
||||
class InvoiceHistorySection extends StatelessWidget {
|
||||
@@ -9,7 +10,7 @@ class InvoiceHistorySection extends StatelessWidget {
|
||||
const InvoiceHistorySection({required this.invoices, super.key});
|
||||
|
||||
/// The list of historical invoices.
|
||||
final List<BillingInvoice> invoices;
|
||||
final List<Invoice> invoices;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -36,10 +37,10 @@ class InvoiceHistorySection extends StatelessWidget {
|
||||
),
|
||||
child: Column(
|
||||
children: invoices.asMap().entries.map((
|
||||
MapEntry<int, BillingInvoice> entry,
|
||||
MapEntry<int, Invoice> entry,
|
||||
) {
|
||||
final int index = entry.key;
|
||||
final BillingInvoice invoice = entry.value;
|
||||
final Invoice invoice = entry.value;
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
if (index > 0)
|
||||
@@ -58,10 +59,18 @@ class InvoiceHistorySection extends StatelessWidget {
|
||||
class _InvoiceItem extends StatelessWidget {
|
||||
const _InvoiceItem({required this.invoice});
|
||||
|
||||
final BillingInvoice invoice;
|
||||
final Invoice invoice;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final DateFormat formatter = DateFormat('MMM d, yyyy');
|
||||
final String dateLabel = invoice.paymentDate != null
|
||||
? formatter.format(invoice.paymentDate!)
|
||||
: invoice.dueDate != null
|
||||
? formatter.format(invoice.dueDate!)
|
||||
: 'N/A';
|
||||
final double amountDollars = invoice.amountCents / 100.0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
@@ -86,11 +95,11 @@ class _InvoiceItem extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(invoice.title, style: UiTypography.body1r.textPrimary),
|
||||
Text(
|
||||
invoice.date,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
invoice.invoiceNumber,
|
||||
style: UiTypography.body1r.textPrimary,
|
||||
),
|
||||
Text(dateLabel, style: UiTypography.footnote2r.textSecondary),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -98,7 +107,7 @@ class _InvoiceItem extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
||||
'\$${amountDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15),
|
||||
),
|
||||
_StatusBadge(status: invoice.status),
|
||||
@@ -113,11 +122,11 @@ class _InvoiceItem extends StatelessWidget {
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.status});
|
||||
|
||||
final String status;
|
||||
final InvoiceStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isPaid = status.toUpperCase() == 'PAID';
|
||||
final bool isPaid = status == InvoiceStatus.paid;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space1 + 2,
|
||||
|
||||
@@ -3,8 +3,9 @@ import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
|
||||
/// Card showing the current payment method.
|
||||
class PaymentMethodCard extends StatelessWidget {
|
||||
@@ -15,8 +16,8 @@ class PaymentMethodCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<BillingBloc, BillingState>(
|
||||
builder: (BuildContext context, BillingState state) {
|
||||
final List<BusinessBankAccount> accounts = state.bankAccounts;
|
||||
final BusinessBankAccount? account =
|
||||
final List<BillingAccount> accounts = state.bankAccounts;
|
||||
final BillingAccount? account =
|
||||
accounts.isNotEmpty ? accounts.first : null;
|
||||
|
||||
if (account == null) {
|
||||
@@ -24,11 +25,10 @@ class PaymentMethodCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
final String bankLabel =
|
||||
account.bankName.isNotEmpty == true ? account.bankName : '----';
|
||||
account.bankName.isNotEmpty ? account.bankName : '----';
|
||||
final String last4 =
|
||||
account.last4.isNotEmpty == true ? account.last4 : '----';
|
||||
account.last4?.isNotEmpty == true ? account.last4! : '----';
|
||||
final bool isPrimary = account.isPrimary;
|
||||
final String expiryLabel = _formatExpiry(account.expiryTime);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
@@ -87,11 +87,11 @@ class PaymentMethodCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'•••• $last4',
|
||||
'\u2022\u2022\u2022\u2022 $last4',
|
||||
style: UiTypography.body2b.textPrimary,
|
||||
),
|
||||
Text(
|
||||
t.client_billing.expires(date: expiryLabel),
|
||||
account.accountType.name.toUpperCase(),
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
@@ -121,13 +121,4 @@ class PaymentMethodCard extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _formatExpiry(DateTime? expiryTime) {
|
||||
if (expiryTime == null) {
|
||||
return 'N/A';
|
||||
}
|
||||
final String month = expiryTime.month.toString().padLeft(2, '0');
|
||||
final String year = (expiryTime.year % 100).toString().padLeft(2, '0');
|
||||
return '$month/$year';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../models/billing_invoice_model.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Section showing a banner for invoices awaiting approval.
|
||||
class PendingInvoicesSection extends StatelessWidget {
|
||||
@@ -12,7 +12,7 @@ class PendingInvoicesSection extends StatelessWidget {
|
||||
const PendingInvoicesSection({required this.invoices, super.key});
|
||||
|
||||
/// The list of pending invoices.
|
||||
final List<BillingInvoice> invoices;
|
||||
final List<Invoice> invoices;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -93,10 +93,17 @@ class PendingInvoiceCard extends StatelessWidget {
|
||||
/// Creates a [PendingInvoiceCard].
|
||||
const PendingInvoiceCard({required this.invoice, super.key});
|
||||
|
||||
final BillingInvoice invoice;
|
||||
/// The invoice to display.
|
||||
final Invoice invoice;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final DateFormat formatter = DateFormat('EEEE, MMMM d');
|
||||
final String dateLabel = invoice.dueDate != null
|
||||
? formatter.format(invoice.dueDate!)
|
||||
: 'N/A';
|
||||
final double amountDollars = invoice.amountCents / 100.0;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
@@ -108,42 +115,33 @@ class PendingInvoiceCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(invoice.title, style: UiTypography.headline4b.textPrimary),
|
||||
Text(
|
||||
invoice.invoiceNumber,
|
||||
style: UiTypography.headline4b.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: 16,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: Text(
|
||||
invoice.locationAddress,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
if (invoice.vendorName != null) ...<Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.building,
|
||||
size: 16,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
invoice.clientName,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text('•', style: UiTypography.footnote2r.textInactive),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
invoice.date,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: Text(
|
||||
invoice.vendorName!,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
],
|
||||
Text(dateLabel, style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
@@ -157,7 +155,7 @@ class PendingInvoiceCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
t.client_billing.pending_badge.toUpperCase(),
|
||||
invoice.status.value.toUpperCase(),
|
||||
style: UiTypography.titleUppercase4b.copyWith(
|
||||
color: UiColors.textWarning,
|
||||
),
|
||||
@@ -168,40 +166,10 @@ class PendingInvoiceCard extends StatelessWidget {
|
||||
const Divider(height: 1, color: UiColors.border),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
UiIcons.dollar,
|
||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
||||
t.client_billing.stats.total,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 32,
|
||||
color: UiColors.border.withValues(alpha: 0.3),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
UiIcons.users,
|
||||
'${invoice.workersCount}',
|
||||
t.client_billing.stats.workers,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 32,
|
||||
color: UiColors.border.withValues(alpha: 0.3),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
UiIcons.clock,
|
||||
invoice.totalHours.toStringAsFixed(1),
|
||||
t.client_billing.stats.hrs,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: _buildStatItem(
|
||||
UiIcons.dollar,
|
||||
'\$${amountDollars.toStringAsFixed(2)}',
|
||||
t.client_billing.stats.total,
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, color: UiColors.border),
|
||||
|
||||
@@ -3,10 +3,10 @@ import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
import '../blocs/billing_event.dart';
|
||||
import '../models/spending_breakdown_model.dart';
|
||||
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
|
||||
/// Card showing the spending breakdown for the current period.
|
||||
class SpendingBreakdownCard extends StatefulWidget {
|
||||
@@ -37,10 +37,7 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<BillingBloc, BillingState>(
|
||||
builder: (BuildContext context, BillingState state) {
|
||||
final double total = state.spendingBreakdown.fold(
|
||||
0.0,
|
||||
(double sum, SpendingBreakdownItem item) => sum + item.amount,
|
||||
);
|
||||
final double totalDollars = state.spendTotalCents / 100.0;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
@@ -97,11 +94,12 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
||||
),
|
||||
dividerColor: UiColors.transparent,
|
||||
onTap: (int index) {
|
||||
final BillingPeriod period =
|
||||
index == 0 ? BillingPeriod.week : BillingPeriod.month;
|
||||
ReadContext(context).read<BillingBloc>().add(
|
||||
BillingPeriodChanged(period),
|
||||
);
|
||||
final BillingPeriodTab tab = index == 0
|
||||
? BillingPeriodTab.week
|
||||
: BillingPeriodTab.month;
|
||||
ReadContext(context)
|
||||
.read<BillingBloc>()
|
||||
.add(BillingPeriodChanged(tab));
|
||||
},
|
||||
tabs: <Widget>[
|
||||
Tab(text: t.client_billing.week),
|
||||
@@ -112,8 +110,8 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
...state.spendingBreakdown.map(
|
||||
(SpendingBreakdownItem item) => _buildBreakdownRow(item),
|
||||
...state.spendBreakdown.map(
|
||||
(SpendItem item) => _buildBreakdownRow(item),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: UiConstants.space2),
|
||||
@@ -127,7 +125,7 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
||||
style: UiTypography.body2b.textPrimary,
|
||||
),
|
||||
Text(
|
||||
'\$${total.toStringAsFixed(2)}',
|
||||
'\$${totalDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.body2b.textPrimary,
|
||||
),
|
||||
],
|
||||
@@ -139,7 +137,8 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBreakdownRow(SpendingBreakdownItem item) {
|
||||
Widget _buildBreakdownRow(SpendItem item) {
|
||||
final double amountDollars = item.amountCents / 100.0;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space2),
|
||||
child: Row(
|
||||
@@ -151,14 +150,14 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
||||
children: <Widget>[
|
||||
Text(item.category, style: UiTypography.body2r.textPrimary),
|
||||
Text(
|
||||
t.client_billing.hours(count: item.hours),
|
||||
'${item.percentage.toStringAsFixed(1)}%',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'\$${item.amount.toStringAsFixed(2)}',
|
||||
'\$${amountDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -10,12 +10,12 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
|
||||
# Architecture
|
||||
flutter_modular: ^6.3.2
|
||||
flutter_bloc: ^8.1.3
|
||||
equatable: ^2.0.5
|
||||
|
||||
|
||||
# Shared packages
|
||||
design_system:
|
||||
path: ../../../design_system
|
||||
@@ -25,12 +25,10 @@ dependencies:
|
||||
path: ../../../domain
|
||||
krow_core:
|
||||
path: ../../../core
|
||||
krow_data_connect:
|
||||
path: ../../../data_connect
|
||||
|
||||
|
||||
# UI
|
||||
intl: ^0.20.0
|
||||
firebase_data_connect: ^0.2.2+1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'data/repositories_impl/coverage_repository_impl.dart';
|
||||
import 'domain/repositories/coverage_repository.dart';
|
||||
import 'domain/usecases/get_coverage_stats_usecase.dart';
|
||||
import 'domain/usecases/get_shifts_for_date_usecase.dart';
|
||||
import 'presentation/blocs/coverage_bloc.dart';
|
||||
import 'presentation/pages/coverage_page.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:client_coverage/src/data/repositories_impl/coverage_repository_impl.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
import 'package:client_coverage/src/domain/usecases/cancel_late_worker_usecase.dart';
|
||||
import 'package:client_coverage/src/domain/usecases/get_coverage_stats_usecase.dart';
|
||||
import 'package:client_coverage/src/domain/usecases/get_shifts_for_date_usecase.dart';
|
||||
import 'package:client_coverage/src/domain/usecases/submit_worker_review_usecase.dart';
|
||||
import 'package:client_coverage/src/presentation/blocs/coverage_bloc.dart';
|
||||
import 'package:client_coverage/src/presentation/pages/coverage_page.dart';
|
||||
|
||||
/// Modular module for the coverage feature.
|
||||
///
|
||||
/// Uses the V2 REST API via [BaseApiService] for all backend access.
|
||||
class CoverageModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[DataConnectModule()];
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<CoverageRepository>(CoverageRepositoryImpl.new);
|
||||
i.addLazySingleton<CoverageRepository>(
|
||||
() => CoverageRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||
);
|
||||
|
||||
// Use Cases
|
||||
i.addLazySingleton(GetShiftsForDateUseCase.new);
|
||||
i.addLazySingleton(GetCoverageStatsUseCase.new);
|
||||
i.addLazySingleton(SubmitWorkerReviewUseCase.new);
|
||||
i.addLazySingleton(CancelLateWorkerUseCase.new);
|
||||
|
||||
// BLoCs
|
||||
i.addLazySingleton<CoverageBloc>(CoverageBloc.new);
|
||||
@@ -28,7 +37,9 @@ class CoverageModule extends Module {
|
||||
|
||||
@override
|
||||
void routes(RouteManager r) {
|
||||
r.child(ClientPaths.childRoute(ClientPaths.coverage, ClientPaths.coverage),
|
||||
child: (_) => const CoveragePage());
|
||||
r.child(
|
||||
ClientPaths.childRoute(ClientPaths.coverage, ClientPaths.coverage),
|
||||
child: (_) => const CoveragePage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +1,89 @@
|
||||
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/coverage_repository.dart';
|
||||
|
||||
/// Implementation of [CoverageRepository] that delegates to [dc.CoverageConnectorRepository].
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
|
||||
/// V2 API implementation of [CoverageRepository].
|
||||
///
|
||||
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
|
||||
/// connector repository from the data_connect package.
|
||||
/// Uses [BaseApiService] with [ClientEndpoints] 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(
|
||||
ClientEndpoints.coverage,
|
||||
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(
|
||||
ClientEndpoints.coverageStats,
|
||||
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(
|
||||
ClientEndpoints.coverageReviews,
|
||||
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(
|
||||
ClientEndpoints.coverageCancelLateWorker(assignmentId),
|
||||
data: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Arguments for cancelling a late worker's assignment.
|
||||
class CancelLateWorkerArguments extends UseCaseArgument {
|
||||
/// Creates [CancelLateWorkerArguments].
|
||||
const CancelLateWorkerArguments({
|
||||
required this.assignmentId,
|
||||
this.reason,
|
||||
});
|
||||
|
||||
/// The assignment ID to cancel.
|
||||
final String assignmentId;
|
||||
|
||||
/// Optional cancellation reason.
|
||||
final String? reason;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[assignmentId, reason];
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Arguments for fetching coverage statistics for a specific date.
|
||||
///
|
||||
/// This argument class encapsulates the date parameter required by
|
||||
/// the [GetCoverageStatsUseCase].
|
||||
class GetCoverageStatsArguments extends UseCaseArgument {
|
||||
/// Creates [GetCoverageStatsArguments].
|
||||
const GetCoverageStatsArguments({required this.date});
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Arguments for fetching shifts for a specific date.
|
||||
///
|
||||
/// This argument class encapsulates the date parameter required by
|
||||
/// the [GetShiftsForDateUseCase].
|
||||
class GetShiftsForDateArguments extends UseCaseArgument {
|
||||
/// Creates [GetShiftsForDateArguments].
|
||||
const GetShiftsForDateArguments({required this.date});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Arguments for submitting a worker review from the coverage page.
|
||||
class SubmitWorkerReviewArguments extends UseCaseArgument {
|
||||
/// Creates [SubmitWorkerReviewArguments].
|
||||
const SubmitWorkerReviewArguments({
|
||||
required this.staffId,
|
||||
required this.rating,
|
||||
this.assignmentId,
|
||||
this.feedback,
|
||||
this.issueFlags,
|
||||
this.markAsFavorite,
|
||||
});
|
||||
|
||||
/// The ID of the worker being reviewed.
|
||||
final String staffId;
|
||||
|
||||
/// The rating value (1-5).
|
||||
final int rating;
|
||||
|
||||
/// The assignment ID, if reviewing for a specific assignment.
|
||||
final String? assignmentId;
|
||||
|
||||
/// Optional text feedback.
|
||||
final String? feedback;
|
||||
|
||||
/// Optional list of issue flag labels.
|
||||
final List<String>? issueFlags;
|
||||
|
||||
/// Whether to mark/unmark the worker as a favorite.
|
||||
final bool? markAsFavorite;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
staffId,
|
||||
rating,
|
||||
assignmentId,
|
||||
feedback,
|
||||
issueFlags,
|
||||
markAsFavorite,
|
||||
];
|
||||
}
|
||||
@@ -2,22 +2,35 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for coverage-related operations.
|
||||
///
|
||||
/// This interface defines the contract for accessing coverage data,
|
||||
/// Defines the contract for accessing coverage data via the V2 REST API,
|
||||
/// acting as a boundary between the Domain and Data layers.
|
||||
/// It allows the Domain layer to remain independent of specific data sources.
|
||||
///
|
||||
/// Implementation of this interface must delegate all data access through
|
||||
/// the `packages/data_connect` layer, ensuring compliance with Clean Architecture.
|
||||
abstract interface class CoverageRepository {
|
||||
/// Fetches shifts for a specific date.
|
||||
///
|
||||
/// Returns a list of [CoverageShift] entities representing all shifts
|
||||
/// scheduled for the given [date].
|
||||
Future<List<CoverageShift>> getShiftsForDate({required DateTime date});
|
||||
/// Fetches shifts with assigned workers for a specific [date].
|
||||
Future<List<ShiftWithWorkers>> getShiftsForDate({required DateTime date});
|
||||
|
||||
/// Fetches coverage statistics for a specific date.
|
||||
///
|
||||
/// Returns [CoverageStats] containing aggregated metrics including
|
||||
/// total workers needed, confirmed, checked in, en route, and late.
|
||||
/// Fetches aggregated coverage statistics for a specific [date].
|
||||
Future<CoverageStats> getCoverageStats({required DateTime date});
|
||||
|
||||
/// Submits a worker review from the coverage page.
|
||||
///
|
||||
/// [staffId] identifies the worker being reviewed.
|
||||
/// [rating] is an integer from 1 to 5.
|
||||
/// Optional fields: [assignmentId], [feedback], [issueFlags], [markAsFavorite].
|
||||
Future<void> submitWorkerReview({
|
||||
required String staffId,
|
||||
required int rating,
|
||||
String? assignmentId,
|
||||
String? feedback,
|
||||
List<String>? issueFlags,
|
||||
bool? markAsFavorite,
|
||||
});
|
||||
|
||||
/// Cancels a late worker's assignment.
|
||||
///
|
||||
/// [assignmentId] identifies the assignment to cancel.
|
||||
/// [reason] is an optional cancellation reason.
|
||||
Future<void> cancelLateWorker({
|
||||
required String assignmentId,
|
||||
String? reason,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'package:client_coverage/src/domain/arguments/cancel_late_worker_arguments.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
|
||||
/// Use case for cancelling a late worker's assignment.
|
||||
///
|
||||
/// Delegates to [CoverageRepository] to cancel the assignment via V2 API.
|
||||
class CancelLateWorkerUseCase
|
||||
implements UseCase<CancelLateWorkerArguments, void> {
|
||||
/// Creates a [CancelLateWorkerUseCase].
|
||||
CancelLateWorkerUseCase(this._repository);
|
||||
|
||||
final CoverageRepository _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(CancelLateWorkerArguments arguments) {
|
||||
return _repository.cancelLateWorker(
|
||||
assignmentId: arguments.assignmentId,
|
||||
reason: arguments.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,12 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../arguments/get_coverage_stats_arguments.dart';
|
||||
import '../repositories/coverage_repository.dart';
|
||||
import 'package:client_coverage/src/domain/arguments/get_coverage_stats_arguments.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
|
||||
/// Use case for fetching coverage statistics for a specific date.
|
||||
/// Use case for fetching aggregated coverage statistics for a specific date.
|
||||
///
|
||||
/// This use case encapsulates the logic for retrieving coverage metrics including
|
||||
/// total workers needed, confirmed, checked in, en route, and late.
|
||||
/// It delegates the data retrieval to the [CoverageRepository].
|
||||
///
|
||||
/// Follows the KROW Clean Architecture pattern by:
|
||||
/// - Extending from [UseCase] base class
|
||||
/// - Using [GetCoverageStatsArguments] for input
|
||||
/// - Returning domain entities ([CoverageStats])
|
||||
/// - Delegating to repository abstraction
|
||||
/// Delegates to [CoverageRepository] and returns a [CoverageStats] entity.
|
||||
class GetCoverageStatsUseCase
|
||||
implements UseCase<GetCoverageStatsArguments, CoverageStats> {
|
||||
/// Creates a [GetCoverageStatsUseCase].
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../arguments/get_shifts_for_date_arguments.dart';
|
||||
import '../repositories/coverage_repository.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Use case for fetching shifts for a specific date.
|
||||
import 'package:client_coverage/src/domain/arguments/get_shifts_for_date_arguments.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
|
||||
/// Use case for fetching shifts with workers for a specific date.
|
||||
///
|
||||
/// This use case encapsulates the logic for retrieving all shifts scheduled for a given date.
|
||||
/// It delegates the data retrieval to the [CoverageRepository].
|
||||
///
|
||||
/// Follows the KROW Clean Architecture pattern by:
|
||||
/// - Extending from [UseCase] base class
|
||||
/// - Using [GetShiftsForDateArguments] for input
|
||||
/// - Returning domain entities ([CoverageShift])
|
||||
/// - Delegating to repository abstraction
|
||||
/// Delegates to [CoverageRepository] and returns V2 [ShiftWithWorkers] entities.
|
||||
class GetShiftsForDateUseCase
|
||||
implements UseCase<GetShiftsForDateArguments, List<CoverageShift>> {
|
||||
implements UseCase<GetShiftsForDateArguments, List<ShiftWithWorkers>> {
|
||||
/// Creates a [GetShiftsForDateUseCase].
|
||||
GetShiftsForDateUseCase(this._repository);
|
||||
|
||||
final CoverageRepository _repository;
|
||||
|
||||
@override
|
||||
Future<List<CoverageShift>> call(GetShiftsForDateArguments arguments) {
|
||||
Future<List<ShiftWithWorkers>> call(GetShiftsForDateArguments arguments) {
|
||||
return _repository.getShiftsForDate(date: arguments.date);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'package:client_coverage/src/domain/arguments/submit_worker_review_arguments.dart';
|
||||
import 'package:client_coverage/src/domain/repositories/coverage_repository.dart';
|
||||
|
||||
/// Use case for submitting a worker review from the coverage page.
|
||||
///
|
||||
/// Validates the rating range and delegates to [CoverageRepository].
|
||||
class SubmitWorkerReviewUseCase
|
||||
implements UseCase<SubmitWorkerReviewArguments, void> {
|
||||
/// Creates a [SubmitWorkerReviewUseCase].
|
||||
SubmitWorkerReviewUseCase(this._repository);
|
||||
|
||||
final CoverageRepository _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(SubmitWorkerReviewArguments arguments) async {
|
||||
if (arguments.rating < 1 || arguments.rating > 5) {
|
||||
throw ArgumentError('Rating must be between 1 and 5');
|
||||
}
|
||||
return _repository.submitWorkerReview(
|
||||
staffId: arguments.staffId,
|
||||
rating: arguments.rating,
|
||||
assignmentId: arguments.assignmentId,
|
||||
feedback: arguments.feedback,
|
||||
issueFlags: arguments.issueFlags,
|
||||
markAsFavorite: arguments.markAsFavorite,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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!),
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(ClientEndpoints.dashboard);
|
||||
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(ClientEndpoints.reorders);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -33,25 +35,23 @@ class ReorderWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
final TranslationsClientHomeReorderEn i18n = t.client_home.reorder;
|
||||
|
||||
final List<ReorderItem> recentOrders = orders;
|
||||
final Size size = MediaQuery.sizeOf(context);
|
||||
|
||||
return SectionLayout(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
child: SizedBox(
|
||||
height: 164,
|
||||
height: size.height * 0.18,
|
||||
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,
|
||||
width: size.width * 0.8,
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
@@ -71,9 +71,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(
|
||||
@@ -89,34 +87,35 @@ class ReorderWidget extends StatelessWidget {
|
||||
children: <Widget>[
|
||||
Text(
|
||||
order.title,
|
||||
style: UiTypography.body2b,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
order.location,
|
||||
style:
|
||||
UiTypography.footnote1r.textSecondary,
|
||||
style: UiTypography.body2m,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (order.hubName != null &&
|
||||
order.hubName!.isNotEmpty)
|
||||
Text(
|
||||
order.hubName!,
|
||||
style:
|
||||
UiTypography.footnote1r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'\$${totalCost.toStringAsFixed(0)}',
|
||||
style: UiTypography.body1b,
|
||||
),
|
||||
Text(
|
||||
'${i18n.per_hr(amount: order.hourlyRate.toString())} · ${order.hours}h',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
// 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(
|
||||
// '${order.positionCount} positions',
|
||||
// style: UiTypography.footnote2r.textSecondary,
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
@@ -124,7 +123,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 +131,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 +139,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 +156,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 +189,7 @@ class _Badge extends StatelessWidget {
|
||||
required this.bg,
|
||||
required this.textColor,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String text;
|
||||
final Color color;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 [ClientEndpoints].
|
||||
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(ClientEndpoints.hubs);
|
||||
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(ClientEndpoints.costCenters);
|
||||
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(
|
||||
ClientEndpoints.hubCreate,
|
||||
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,68 @@ 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(
|
||||
ClientEndpoints.hubUpdate(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(ClientEndpoints.hubDelete(hubId));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> assignNfcTag({
|
||||
required String hubId,
|
||||
required String nfcTagId,
|
||||
}) async {
|
||||
await _apiService.post(
|
||||
ClientEndpoints.hubAssignNfc(hubId),
|
||||
data: <String, dynamic>{'nfcTagId': nfcTagId},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<HubManager>> getManagers(String hubId) async {
|
||||
final ApiResponse response =
|
||||
await _apiService.get(ClientEndpoints.hubManagers(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 {
|
||||
for (final String membershipId in businessMembershipIds) {
|
||||
await _apiService.post(
|
||||
ClientEndpoints.hubAssignManagers(hubId),
|
||||
data: <String, dynamic>{
|
||||
'businessMembershipId': membershipId,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import 'package:google_places_flutter/google_places_flutter.dart';
|
||||
import 'package:google_places_flutter/model/prediction.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../../util/hubs_constants.dart';
|
||||
|
||||
class HubAddressAutocomplete extends StatelessWidget {
|
||||
const HubAddressAutocomplete({
|
||||
required this.controller,
|
||||
@@ -26,12 +24,11 @@ class HubAddressAutocomplete extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return GooglePlaceAutoCompleteTextField(
|
||||
textEditingController: controller,
|
||||
boxDecoration: null,
|
||||
boxDecoration: const BoxDecoration(),
|
||||
focusNode: focusNode,
|
||||
inputDecoration: decoration ?? const InputDecoration(),
|
||||
googleAPIKey: AppConfig.googleMapsApiKey,
|
||||
debounceTime: 500,
|
||||
//countries: HubsConstants.supportedCountries,
|
||||
isLatLngRequired: true,
|
||||
getPlaceDetailWithLatLng: (Prediction prediction) {
|
||||
onSelected?.call(prediction);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user