feat: Migrate staff profile features from Data Connect to V2 REST API
- Removed data_connect package from mobile pubspec.yaml. - Added documentation for V2 profile migration status and QA findings. - Implemented new session management with ClientSessionStore and StaffSessionStore. - Created V2SessionService for handling user sessions via the V2 API. - Developed use cases for cancelling late worker assignments and submitting worker reviews. - Added arguments and use cases for payment chart retrieval and profile completion checks. - Implemented repository interfaces and their implementations for staff main and profile features. - Ensured proper error handling and validation in use cases.
This commit is contained in:
@@ -1,59 +1,99 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
import '../../utils/test_phone_numbers.dart';
|
||||
import '../../domain/ui_entities/auth_mode.dart';
|
||||
|
||||
import '../../domain/repositories/auth_repository_interface.dart';
|
||||
import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart';
|
||||
import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart';
|
||||
import 'package:staff_authentication/src/utils/test_phone_numbers.dart';
|
||||
|
||||
/// Implementation of [AuthRepositoryInterface].
|
||||
/// V2 API implementation of [AuthRepositoryInterface].
|
||||
///
|
||||
/// Uses the Firebase Auth SDK for client-side phone verification,
|
||||
/// then calls the V2 unified API to hydrate the session context.
|
||||
/// All Data Connect dependencies have been removed.
|
||||
class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
AuthRepositoryImpl() : _service = DataConnectService.instance;
|
||||
/// Creates an [AuthRepositoryImpl].
|
||||
///
|
||||
/// Requires a [domain.BaseApiService] for V2 API calls.
|
||||
AuthRepositoryImpl({required domain.BaseApiService apiService})
|
||||
: _apiService = apiService;
|
||||
|
||||
final DataConnectService _service;
|
||||
/// The V2 API service for backend calls.
|
||||
final domain.BaseApiService _apiService;
|
||||
|
||||
/// Firebase Auth instance for client-side phone verification.
|
||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||
|
||||
/// Completer for the pending phone verification request.
|
||||
Completer<String?>? _pendingVerification;
|
||||
|
||||
@override
|
||||
Stream<domain.User?> get currentUser =>
|
||||
_service.auth.authStateChanges().map((User? firebaseUser) {
|
||||
_auth.authStateChanges().map((User? firebaseUser) {
|
||||
if (firebaseUser == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return domain.User(
|
||||
id: firebaseUser.uid,
|
||||
email: firebaseUser.email ?? '',
|
||||
email: firebaseUser.email,
|
||||
displayName: firebaseUser.displayName,
|
||||
phone: firebaseUser.phoneNumber,
|
||||
role: 'staff',
|
||||
status: domain.UserStatus.active,
|
||||
);
|
||||
});
|
||||
|
||||
/// Signs in with a phone number and returns a verification ID.
|
||||
/// Initiates phone verification via the V2 API.
|
||||
///
|
||||
/// Calls `POST /auth/staff/phone/start` first. The server decides the
|
||||
/// verification mode:
|
||||
/// - `CLIENT_FIREBASE_SDK` — mobile must do Firebase phone auth client-side
|
||||
/// - `IDENTITY_TOOLKIT_SMS` — server sent the SMS, returns `sessionInfo`
|
||||
///
|
||||
/// For mobile without recaptcha tokens, the server returns
|
||||
/// `CLIENT_FIREBASE_SDK` and we fall back to the Firebase Auth SDK.
|
||||
@override
|
||||
Future<String?> signInWithPhone({required String phoneNumber}) async {
|
||||
// Step 1: Try V2 to let the server decide the auth mode.
|
||||
// Falls back to CLIENT_FIREBASE_SDK if the API call fails (e.g. server
|
||||
// down, 500, or non-JSON response).
|
||||
String mode = 'CLIENT_FIREBASE_SDK';
|
||||
String? sessionInfo;
|
||||
|
||||
try {
|
||||
final domain.ApiResponse startResponse = await _apiService.post(
|
||||
V2ApiEndpoints.staffPhoneStart,
|
||||
data: <String, dynamic>{
|
||||
'phoneNumber': phoneNumber,
|
||||
},
|
||||
);
|
||||
|
||||
final Map<String, dynamic> startData =
|
||||
startResponse.data as Map<String, dynamic>;
|
||||
mode = startData['mode'] as String? ?? 'CLIENT_FIREBASE_SDK';
|
||||
sessionInfo = startData['sessionInfo'] as String?;
|
||||
} catch (_) {
|
||||
// V2 start call failed — fall back to client-side Firebase SDK.
|
||||
}
|
||||
|
||||
// Step 2: If server sent the SMS, return the sessionInfo for verify step.
|
||||
if (mode == 'IDENTITY_TOOLKIT_SMS') {
|
||||
return sessionInfo;
|
||||
}
|
||||
|
||||
// Step 3: CLIENT_FIREBASE_SDK mode — do Firebase phone auth client-side.
|
||||
final Completer<String?> completer = Completer<String?>();
|
||||
_pendingVerification = completer;
|
||||
|
||||
await _service.auth.verifyPhoneNumber(
|
||||
await _auth.verifyPhoneNumber(
|
||||
phoneNumber: phoneNumber,
|
||||
verificationCompleted: (PhoneAuthCredential credential) {
|
||||
// Skip auto-verification for test numbers to allow manual code entry
|
||||
if (TestPhoneNumbers.isTestNumber(phoneNumber)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For real numbers, we can support auto-verification if desired.
|
||||
// But since this method returns a verificationId for manual OTP entry,
|
||||
// we might not handle direct sign-in here unless the architecture changes.
|
||||
// Currently, we just ignore it for the completer flow,
|
||||
// or we could sign in directly if the credential is provided.
|
||||
if (TestPhoneNumbers.isTestNumber(phoneNumber)) return;
|
||||
},
|
||||
verificationFailed: (FirebaseAuthException e) {
|
||||
if (!completer.isCompleted) {
|
||||
// Map Firebase network errors to NetworkException
|
||||
if (e.code == 'network-request-failed' ||
|
||||
e.message?.contains('Unable to resolve host') == true) {
|
||||
completer.completeError(
|
||||
@@ -94,35 +134,36 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
_pendingVerification = null;
|
||||
}
|
||||
|
||||
/// Signs out the current user.
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
return await _service.signOut();
|
||||
}
|
||||
|
||||
/// Verifies an OTP code and returns the authenticated user.
|
||||
/// Verifies the OTP and completes authentication via the V2 API.
|
||||
///
|
||||
/// 1. Signs in with the Firebase credential (client-side).
|
||||
/// 2. Gets the Firebase ID token.
|
||||
/// 3. Calls `POST /auth/staff/phone/verify` with the ID token and mode.
|
||||
/// 4. Parses the V2 auth envelope and populates the session.
|
||||
@override
|
||||
Future<domain.User?> verifyOtp({
|
||||
required String verificationId,
|
||||
required String smsCode,
|
||||
required AuthMode mode,
|
||||
}) async {
|
||||
// Step 1: Sign in with Firebase credential (client-side).
|
||||
final PhoneAuthCredential credential = PhoneAuthProvider.credential(
|
||||
verificationId: verificationId,
|
||||
smsCode: smsCode,
|
||||
);
|
||||
final UserCredential userCredential = await _service.run(() async {
|
||||
try {
|
||||
return await _service.auth.signInWithCredential(credential);
|
||||
} on FirebaseAuthException catch (e) {
|
||||
if (e.code == 'invalid-verification-code') {
|
||||
throw const domain.InvalidCredentialsException(
|
||||
technicalMessage: 'Invalid OTP code entered.',
|
||||
);
|
||||
}
|
||||
rethrow;
|
||||
|
||||
final UserCredential userCredential;
|
||||
try {
|
||||
userCredential = await _auth.signInWithCredential(credential);
|
||||
} on FirebaseAuthException catch (e) {
|
||||
if (e.code == 'invalid-verification-code') {
|
||||
throw const domain.InvalidCredentialsException(
|
||||
technicalMessage: 'Invalid OTP code entered.',
|
||||
);
|
||||
}
|
||||
}, requiresAuthentication: false);
|
||||
rethrow;
|
||||
}
|
||||
|
||||
final User? firebaseUser = userCredential.user;
|
||||
if (firebaseUser == null) {
|
||||
throw const domain.SignInFailedException(
|
||||
@@ -131,115 +172,68 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||
);
|
||||
}
|
||||
|
||||
final QueryResult<GetUserByIdData, GetUserByIdVariables> response =
|
||||
await _service.run(
|
||||
() => _service.connector.getUserById(id: firebaseUser.uid).execute(),
|
||||
requiresAuthentication: false,
|
||||
);
|
||||
final GetUserByIdUser? user = response.data.user;
|
||||
|
||||
GetStaffByUserIdStaffs? staffRecord;
|
||||
|
||||
if (mode == AuthMode.signup) {
|
||||
if (user == null) {
|
||||
await _service.run(
|
||||
() => _service.connector
|
||||
.createUser(id: firebaseUser.uid, role: UserBaseRole.USER)
|
||||
.userRole('STAFF')
|
||||
.execute(),
|
||||
requiresAuthentication: false,
|
||||
);
|
||||
} else {
|
||||
// User exists in PostgreSQL. Check if they have a STAFF profile.
|
||||
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
|
||||
staffResponse = await _service.run(
|
||||
() => _service.connector
|
||||
.getStaffByUserId(userId: firebaseUser.uid)
|
||||
.execute(),
|
||||
requiresAuthentication: false,
|
||||
);
|
||||
|
||||
if (staffResponse.data.staffs.isNotEmpty) {
|
||||
// If profile exists, they should use Login mode.
|
||||
await _service.signOut();
|
||||
throw const domain.AccountExistsException(
|
||||
technicalMessage:
|
||||
'This user already has a staff profile. Please log in.',
|
||||
);
|
||||
}
|
||||
|
||||
// If they don't have a staff profile but they exist as BUSINESS,
|
||||
// they are allowed to "Sign Up" for Staff.
|
||||
// We update their userRole to 'BOTH'.
|
||||
if (user.userRole == 'BUSINESS') {
|
||||
await _service.run(
|
||||
() => _service.connector
|
||||
.updateUser(id: firebaseUser.uid)
|
||||
.userRole('BOTH')
|
||||
.execute(),
|
||||
requiresAuthentication: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (user == null) {
|
||||
await _service.signOut();
|
||||
throw const domain.UserNotFoundException(
|
||||
technicalMessage: 'Authenticated user profile not found in database.',
|
||||
);
|
||||
}
|
||||
// Allow STAFF or BOTH roles to log in to the Staff App
|
||||
if (user.userRole != 'STAFF' && user.userRole != 'BOTH') {
|
||||
await _service.signOut();
|
||||
throw const domain.UnauthorizedAppException(
|
||||
technicalMessage: 'User is not authorized for this app.',
|
||||
);
|
||||
}
|
||||
|
||||
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
|
||||
staffResponse = await _service.run(
|
||||
() => _service.connector
|
||||
.getStaffByUserId(userId: firebaseUser.uid)
|
||||
.execute(),
|
||||
requiresAuthentication: false,
|
||||
// Step 2: Get the Firebase ID token.
|
||||
final String? idToken = await firebaseUser.getIdToken();
|
||||
if (idToken == null) {
|
||||
throw const domain.SignInFailedException(
|
||||
technicalMessage: 'Failed to obtain Firebase ID token.',
|
||||
);
|
||||
if (staffResponse.data.staffs.isEmpty) {
|
||||
await _service.signOut();
|
||||
}
|
||||
|
||||
// Step 3: Call V2 verify endpoint with the Firebase ID token.
|
||||
final String v2Mode = mode == AuthMode.signup ? 'sign-up' : 'sign-in';
|
||||
final domain.ApiResponse response = await _apiService.post(
|
||||
V2ApiEndpoints.staffPhoneVerify,
|
||||
data: <String, dynamic>{
|
||||
'idToken': idToken,
|
||||
'mode': v2Mode,
|
||||
},
|
||||
);
|
||||
|
||||
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
|
||||
|
||||
// Step 4: Check for business logic errors from the V2 API.
|
||||
final bool requiresProfileSetup =
|
||||
data['requiresProfileSetup'] as bool? ?? false;
|
||||
final Map<String, dynamic>? staffData =
|
||||
data['staff'] as Map<String, dynamic>?;
|
||||
final Map<String, dynamic>? userData =
|
||||
data['user'] as Map<String, dynamic>?;
|
||||
|
||||
// Handle mode-specific logic:
|
||||
// - Sign-up: staff may be null (requiresProfileSetup=true)
|
||||
// - Sign-in: staff must exist
|
||||
if (mode == AuthMode.login) {
|
||||
if (staffData == null) {
|
||||
await _auth.signOut();
|
||||
throw const domain.UserNotFoundException(
|
||||
technicalMessage:
|
||||
'Your account is not registered yet. Please register first.',
|
||||
);
|
||||
}
|
||||
staffRecord = staffResponse.data.staffs.first;
|
||||
}
|
||||
|
||||
//TO-DO: create(registration) user and staff account
|
||||
//TO-DO: save user data locally
|
||||
// Build the domain user from the V2 response.
|
||||
final domain.User domainUser = domain.User(
|
||||
id: firebaseUser.uid,
|
||||
email: user?.email ?? '',
|
||||
phone: user?.phone,
|
||||
role: user?.role.stringValue ?? 'USER',
|
||||
);
|
||||
final domain.Staff? domainStaff = staffRecord == null
|
||||
? null
|
||||
: domain.Staff(
|
||||
id: staffRecord.id,
|
||||
authProviderId: staffRecord.userId,
|
||||
name: staffRecord.fullName,
|
||||
email: staffRecord.email ?? '',
|
||||
phone: staffRecord.phone,
|
||||
status: domain.StaffStatus.completedProfile,
|
||||
address: staffRecord.addres,
|
||||
avatar: staffRecord.photoUrl,
|
||||
);
|
||||
StaffSessionStore.instance.setSession(
|
||||
StaffSession(
|
||||
staff: domainStaff,
|
||||
ownerId: staffRecord?.ownerId,
|
||||
),
|
||||
id: userData?['id'] as String? ?? firebaseUser.uid,
|
||||
email: userData?['email'] as String?,
|
||||
displayName: userData?['displayName'] as String?,
|
||||
phone: userData?['phone'] as String? ?? firebaseUser.phoneNumber,
|
||||
status: domain.UserStatus.active,
|
||||
);
|
||||
|
||||
return domainUser;
|
||||
}
|
||||
|
||||
/// Signs out via the V2 API and locally.
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
try {
|
||||
await _apiService.post(V2ApiEndpoints.signOut);
|
||||
} catch (_) {
|
||||
// Sign-out should not fail even if the API call fails.
|
||||
// The local sign-out below will clear the session regardless.
|
||||
}
|
||||
await _auth.signOut();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../../domain/repositories/place_repository.dart';
|
||||
import 'package:staff_authentication/src/domain/repositories/place_repository.dart';
|
||||
|
||||
class PlaceRepositoryImpl implements PlaceRepository {
|
||||
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart' as auth;
|
||||
import '../../domain/repositories/profile_setup_repository.dart';
|
||||
|
||||
import 'package:staff_authentication/src/domain/repositories/profile_setup_repository.dart';
|
||||
|
||||
/// V2 API implementation of [ProfileSetupRepository].
|
||||
///
|
||||
/// Submits the staff profile setup data to the V2 unified API
|
||||
/// endpoint `POST /staff/profile/setup`.
|
||||
class ProfileSetupRepositoryImpl implements ProfileSetupRepository {
|
||||
/// Creates a [ProfileSetupRepositoryImpl].
|
||||
///
|
||||
/// Requires a [BaseApiService] for V2 API calls.
|
||||
ProfileSetupRepositoryImpl({required BaseApiService apiService})
|
||||
: _apiService = apiService;
|
||||
|
||||
ProfileSetupRepositoryImpl() : _service = DataConnectService.instance;
|
||||
final DataConnectService _service;
|
||||
/// The V2 API service for backend calls.
|
||||
final BaseApiService _apiService;
|
||||
|
||||
@override
|
||||
Future<void> submitProfile({
|
||||
@@ -18,46 +26,27 @@ class ProfileSetupRepositoryImpl implements ProfileSetupRepository {
|
||||
required List<String> industries,
|
||||
required List<String> skills,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final auth.User? firebaseUser = _service.auth.currentUser;
|
||||
if (firebaseUser == null) {
|
||||
throw const NotAuthenticatedException(
|
||||
technicalMessage: 'User not authenticated.',
|
||||
);
|
||||
}
|
||||
final ApiResponse response = await _apiService.post(
|
||||
V2ApiEndpoints.staffProfileSetup,
|
||||
data: <String, dynamic>{
|
||||
'fullName': fullName,
|
||||
if (bio != null && bio.isNotEmpty) 'bio': bio,
|
||||
'preferredLocations': preferredLocations,
|
||||
'maxDistanceMiles': maxDistanceMiles.toInt(),
|
||||
'industries': industries,
|
||||
'skills': skills,
|
||||
},
|
||||
);
|
||||
|
||||
final StaffSession? session = StaffSessionStore.instance.session;
|
||||
final String email = session?.staff?.email ?? '';
|
||||
final String? phone = firebaseUser.phoneNumber;
|
||||
|
||||
final fdc.OperationResult<CreateStaffData, CreateStaffVariables> result =
|
||||
await _service.connector
|
||||
.createStaff(userId: firebaseUser.uid, fullName: fullName)
|
||||
.bio(bio)
|
||||
.preferredLocations(preferredLocations)
|
||||
.maxDistanceMiles(maxDistanceMiles.toInt())
|
||||
.industries(industries)
|
||||
.skills(skills)
|
||||
.email(email.isEmpty ? null : email)
|
||||
.phone(phone)
|
||||
.execute();
|
||||
|
||||
final String staffId = result.data.staff_insert.id;
|
||||
|
||||
final Staff staff = Staff(
|
||||
id: staffId,
|
||||
authProviderId: firebaseUser.uid,
|
||||
name: fullName,
|
||||
email: email,
|
||||
phone: phone,
|
||||
status: StaffStatus.completedProfile,
|
||||
// Check for API-level errors.
|
||||
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
|
||||
if (data['code'] != null &&
|
||||
data['code'].toString() != '200' &&
|
||||
data['code'].toString() != '201') {
|
||||
throw SignInFailedException(
|
||||
technicalMessage:
|
||||
data['message']?.toString() ?? 'Profile setup failed.',
|
||||
);
|
||||
|
||||
if (session != null) {
|
||||
StaffSessionStore.instance.setSession(
|
||||
StaffSession(staff: staff, ownerId: session.ownerId),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../ui_entities/auth_mode.dart';
|
||||
import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart';
|
||||
|
||||
/// Represents the arguments required for the [VerifyOtpUseCase].
|
||||
///
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../ui_entities/auth_mode.dart';
|
||||
|
||||
import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart';
|
||||
|
||||
/// Interface for authentication repository.
|
||||
///
|
||||
/// Defines the contract for staff phone-based authentication,
|
||||
/// OTP verification, and sign-out operations.
|
||||
abstract interface class AuthRepositoryInterface {
|
||||
/// Stream of the current Firebase Auth user mapped to a domain [User].
|
||||
Stream<User?> get currentUser;
|
||||
|
||||
/// Signs in with a phone number and returns a verification ID.
|
||||
/// Initiates phone verification and returns a verification ID.
|
||||
///
|
||||
/// Uses the Firebase Auth SDK client-side to send an SMS code.
|
||||
Future<String?> signInWithPhone({required String phoneNumber});
|
||||
|
||||
/// Cancels any pending phone verification request (if possible).
|
||||
void cancelPendingPhoneVerification();
|
||||
|
||||
/// Verifies the OTP code and returns the authenticated user.
|
||||
/// Verifies the OTP code and completes authentication via the V2 API.
|
||||
///
|
||||
/// After Firebase credential sign-in, calls the V2 verify endpoint
|
||||
/// to hydrate the session context. Returns the authenticated [User]
|
||||
/// or `null` if verification fails.
|
||||
Future<User?> verifyOtp({
|
||||
required String verificationId,
|
||||
required String smsCode,
|
||||
required AuthMode mode,
|
||||
});
|
||||
|
||||
/// Signs out the current user.
|
||||
/// Signs out the current user via the V2 API and locally.
|
||||
Future<void> signOut();
|
||||
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import '../repositories/place_repository.dart';
|
||||
import 'package:staff_authentication/src/domain/repositories/place_repository.dart';
|
||||
|
||||
/// Use case for searching cities via the Places API.
|
||||
///
|
||||
/// Delegates to [PlaceRepository] for autocomplete results.
|
||||
class SearchCitiesUseCase {
|
||||
|
||||
/// Creates a [SearchCitiesUseCase].
|
||||
SearchCitiesUseCase(this._repository);
|
||||
|
||||
/// The repository for place search operations.
|
||||
final PlaceRepository _repository;
|
||||
|
||||
/// Searches for cities matching the given [query].
|
||||
Future<List<String>> call(String query) {
|
||||
return _repository.searchCities(query);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../arguments/sign_in_with_phone_arguments.dart';
|
||||
import '../repositories/auth_repository_interface.dart';
|
||||
import 'package:staff_authentication/src/domain/arguments/sign_in_with_phone_arguments.dart';
|
||||
import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart';
|
||||
|
||||
/// Use case for signing in with a phone number.
|
||||
///
|
||||
/// This use case delegates the sign-in logic to the [AuthRepositoryInterface].
|
||||
/// Delegates the sign-in logic to the [AuthRepositoryInterface].
|
||||
class SignInWithPhoneUseCase
|
||||
implements UseCase<SignInWithPhoneArguments, String?> {
|
||||
|
||||
/// Creates a [SignInWithPhoneUseCase].
|
||||
///
|
||||
/// Requires an [AuthRepositoryInterface] to interact with the authentication data source.
|
||||
SignInWithPhoneUseCase(this._repository);
|
||||
|
||||
/// The repository for authentication operations.
|
||||
final AuthRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
@@ -19,6 +18,7 @@ class SignInWithPhoneUseCase
|
||||
return _repository.signInWithPhone(phoneNumber: arguments.phoneNumber);
|
||||
}
|
||||
|
||||
/// Cancels any pending phone verification request.
|
||||
void cancelPending() {
|
||||
_repository.cancelPendingPhoneVerification();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import '../repositories/profile_setup_repository.dart';
|
||||
import 'package:staff_authentication/src/domain/repositories/profile_setup_repository.dart';
|
||||
|
||||
/// Use case for submitting the staff profile setup.
|
||||
///
|
||||
/// Delegates to [ProfileSetupRepository] to persist the profile data.
|
||||
class SubmitProfileSetup {
|
||||
|
||||
/// Creates a [SubmitProfileSetup].
|
||||
SubmitProfileSetup(this.repository);
|
||||
|
||||
/// The repository for profile setup operations.
|
||||
final ProfileSetupRepository repository;
|
||||
|
||||
/// Submits the profile setup with the given data.
|
||||
Future<void> call({
|
||||
required String fullName,
|
||||
String? bio,
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../arguments/verify_otp_arguments.dart';
|
||||
import '../repositories/auth_repository_interface.dart';
|
||||
import 'package:staff_authentication/src/domain/arguments/verify_otp_arguments.dart';
|
||||
import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart';
|
||||
|
||||
/// Use case for verifying an OTP code.
|
||||
///
|
||||
/// This use case delegates the OTP verification logic to the [AuthRepositoryInterface].
|
||||
/// Delegates the OTP verification logic to the [AuthRepositoryInterface].
|
||||
class VerifyOtpUseCase implements UseCase<VerifyOtpArguments, User?> {
|
||||
|
||||
/// Creates a [VerifyOtpUseCase].
|
||||
///
|
||||
/// Requires an [AuthRepositoryInterface] to interact with the authentication data source.
|
||||
VerifyOtpUseCase(this._repository);
|
||||
|
||||
/// The repository for authentication operations.
|
||||
final AuthRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/arguments/sign_in_with_phone_arguments.dart';
|
||||
import '../../domain/arguments/verify_otp_arguments.dart';
|
||||
import '../../domain/usecases/sign_in_with_phone_usecase.dart';
|
||||
import '../../domain/usecases/verify_otp_usecase.dart';
|
||||
import 'auth_event.dart';
|
||||
import 'auth_state.dart';
|
||||
import 'package:staff_authentication/src/domain/arguments/sign_in_with_phone_arguments.dart';
|
||||
import 'package:staff_authentication/src/domain/arguments/verify_otp_arguments.dart';
|
||||
import 'package:staff_authentication/src/domain/usecases/sign_in_with_phone_usecase.dart';
|
||||
import 'package:staff_authentication/src/domain/usecases/verify_otp_usecase.dart';
|
||||
import 'package:staff_authentication/src/presentation/blocs/auth_event.dart';
|
||||
import 'package:staff_authentication/src/presentation/blocs/auth_state.dart';
|
||||
|
||||
/// BLoC responsible for handling authentication logic.
|
||||
///
|
||||
/// Coordinates phone verification and OTP submission via use cases.
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState>
|
||||
with BlocErrorHandler<AuthState>
|
||||
implements Disposable {
|
||||
|
||||
/// Creates an [AuthBloc].
|
||||
AuthBloc({
|
||||
required SignInWithPhoneUseCase signInUseCase,
|
||||
required VerifyOtpUseCase verifyOtpUseCase,
|
||||
}) : _signInUseCase = signInUseCase,
|
||||
_verifyOtpUseCase = verifyOtpUseCase,
|
||||
super(const AuthState()) {
|
||||
}) : _signInUseCase = signInUseCase,
|
||||
_verifyOtpUseCase = verifyOtpUseCase,
|
||||
super(const AuthState()) {
|
||||
on<AuthSignInRequested>(_onSignInRequested);
|
||||
on<AuthOtpSubmitted>(_onOtpSubmitted);
|
||||
on<AuthErrorCleared>(_onErrorCleared);
|
||||
@@ -30,15 +32,26 @@ class AuthBloc extends Bloc<AuthEvent, AuthState>
|
||||
on<AuthResetRequested>(_onResetRequested);
|
||||
on<AuthCooldownTicked>(_onCooldownTicked);
|
||||
}
|
||||
|
||||
/// The use case for signing in with a phone number.
|
||||
final SignInWithPhoneUseCase _signInUseCase;
|
||||
|
||||
/// The use case for verifying an OTP.
|
||||
final VerifyOtpUseCase _verifyOtpUseCase;
|
||||
|
||||
/// Token to track the latest request and ignore stale completions.
|
||||
int _requestToken = 0;
|
||||
|
||||
/// Timestamp of the last code request for cooldown enforcement.
|
||||
DateTime? _lastCodeRequestAt;
|
||||
|
||||
/// When the cooldown expires.
|
||||
DateTime? _cooldownUntil;
|
||||
|
||||
/// Duration users must wait between code requests.
|
||||
static const Duration _resendCooldown = Duration(seconds: 31);
|
||||
|
||||
/// Timer for ticking down the cooldown.
|
||||
Timer? _cooldownTimer;
|
||||
|
||||
/// Clears any authentication error from the state.
|
||||
@@ -138,6 +151,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState>
|
||||
);
|
||||
}
|
||||
|
||||
/// Handles cooldown tick events.
|
||||
void _onCooldownTicked(
|
||||
AuthCooldownTicked event,
|
||||
Emitter<AuthState> emit,
|
||||
@@ -165,22 +179,27 @@ class AuthBloc extends Bloc<AuthEvent, AuthState>
|
||||
);
|
||||
}
|
||||
|
||||
/// Starts the cooldown timer with the given remaining seconds.
|
||||
void _startCooldown(int secondsRemaining) {
|
||||
_cancelCooldownTimer();
|
||||
int remaining = secondsRemaining;
|
||||
add(AuthCooldownTicked(remaining));
|
||||
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (Timer timer) {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
timer.cancel();
|
||||
_cooldownTimer = null;
|
||||
add(const AuthCooldownTicked(0));
|
||||
return;
|
||||
}
|
||||
add(AuthCooldownTicked(remaining));
|
||||
});
|
||||
_cooldownTimer = Timer.periodic(
|
||||
const Duration(seconds: 1),
|
||||
(Timer timer) {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
timer.cancel();
|
||||
_cooldownTimer = null;
|
||||
add(const AuthCooldownTicked(0));
|
||||
return;
|
||||
}
|
||||
add(AuthCooldownTicked(remaining));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancels the cooldown timer if active.
|
||||
void _cancelCooldownTimer() {
|
||||
_cooldownTimer?.cancel();
|
||||
_cooldownTimer = null;
|
||||
@@ -218,4 +237,3 @@ class AuthBloc extends Bloc<AuthEvent, AuthState>
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../../../domain/usecases/submit_profile_setup_usecase.dart';
|
||||
import 'package:staff_authentication/src/domain/usecases/submit_profile_setup_usecase.dart';
|
||||
import 'package:staff_authentication/src/domain/usecases/search_cities_usecase.dart';
|
||||
import 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_event.dart';
|
||||
import 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_state.dart';
|
||||
|
||||
import '../../../domain/usecases/search_cities_usecase.dart';
|
||||
|
||||
import 'profile_setup_event.dart';
|
||||
import 'profile_setup_state.dart';
|
||||
|
||||
export 'profile_setup_event.dart';
|
||||
export 'profile_setup_state.dart';
|
||||
export 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_event.dart';
|
||||
export 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_state.dart';
|
||||
|
||||
/// BLoC responsible for managing the profile setup state and logic.
|
||||
class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState>
|
||||
with BlocErrorHandler<ProfileSetupState> {
|
||||
/// Creates a [ProfileSetupBloc].
|
||||
ProfileSetupBloc({
|
||||
required SubmitProfileSetup submitProfileSetup,
|
||||
required SearchCitiesUseCase searchCities,
|
||||
}) : _submitProfileSetup = submitProfileSetup,
|
||||
_searchCities = searchCities,
|
||||
super(const ProfileSetupState()) {
|
||||
}) : _submitProfileSetup = submitProfileSetup,
|
||||
_searchCities = searchCities,
|
||||
super(const ProfileSetupState()) {
|
||||
on<ProfileSetupFullNameChanged>(_onFullNameChanged);
|
||||
on<ProfileSetupBioChanged>(_onBioChanged);
|
||||
on<ProfileSetupLocationsChanged>(_onLocationsChanged);
|
||||
@@ -30,7 +29,10 @@ class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState>
|
||||
on<ProfileSetupClearLocationSuggestions>(_onClearLocationSuggestions);
|
||||
}
|
||||
|
||||
/// The use case for submitting the profile setup.
|
||||
final SubmitProfileSetup _submitProfileSetup;
|
||||
|
||||
/// The use case for searching cities.
|
||||
final SearchCitiesUseCase _searchCities;
|
||||
|
||||
/// Handles the [ProfileSetupFullNameChanged] event.
|
||||
@@ -109,6 +111,7 @@ class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState>
|
||||
);
|
||||
}
|
||||
|
||||
/// Handles location query changes for autocomplete search.
|
||||
Future<void> _onLocationQueryChanged(
|
||||
ProfileSetupLocationQueryChanged event,
|
||||
Emitter<ProfileSetupState> emit,
|
||||
@@ -118,17 +121,16 @@ class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState>
|
||||
return;
|
||||
}
|
||||
|
||||
// For search, we might want to handle errors silently or distinctively
|
||||
// Using simple try-catch here as it's a search-as-you-type feature where error dialogs are intrusive
|
||||
try {
|
||||
final List<String> results = await _searchCities(event.query);
|
||||
emit(state.copyWith(locationSuggestions: results));
|
||||
} catch (e) {
|
||||
// Quietly fail or clear
|
||||
// Quietly fail for search-as-you-type.
|
||||
emit(state.copyWith(locationSuggestions: <String>[]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the location suggestions list.
|
||||
void _onClearLocationSuggestions(
|
||||
ProfileSetupClearLocationSuggestions event,
|
||||
Emitter<ProfileSetupState> emit,
|
||||
@@ -136,4 +138,3 @@ class ProfileSetupBloc extends Bloc<ProfileSetupEvent, ProfileSetupState>
|
||||
emit(state.copyWith(locationSuggestions: <String>[]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../widgets/get_started_page/get_started_actions.dart';
|
||||
import '../widgets/get_started_page/get_started_background.dart';
|
||||
import '../widgets/get_started_page/get_started_header.dart';
|
||||
import 'package:staff_authentication/src/presentation/widgets/get_started_page/get_started_actions.dart';
|
||||
import 'package:staff_authentication/src/presentation/widgets/get_started_page/get_started_background.dart';
|
||||
import 'package:staff_authentication/src/presentation/widgets/get_started_page/get_started_header.dart';
|
||||
|
||||
/// The entry point page for staff authentication.
|
||||
///
|
||||
|
||||
@@ -9,8 +9,8 @@ import 'package:staff_authentication/src/presentation/blocs/auth_state.dart';
|
||||
import 'package:staff_authentication/staff_authentication.dart';
|
||||
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../widgets/phone_verification_page/otp_verification.dart';
|
||||
import '../widgets/phone_verification_page/phone_input.dart';
|
||||
import 'package:staff_authentication/src/presentation/widgets/phone_verification_page/otp_verification.dart';
|
||||
import 'package:staff_authentication/src/presentation/widgets/phone_verification_page/phone_input.dart';
|
||||
|
||||
/// A combined page for phone number entry and OTP verification.
|
||||
///
|
||||
|
||||
@@ -6,11 +6,11 @@ import 'package:flutter_modular/flutter_modular.dart'
|
||||
hide ModularWatchExtension;
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../blocs/profile_setup/profile_setup_bloc.dart';
|
||||
import '../widgets/profile_setup_page/profile_setup_basic_info.dart';
|
||||
import '../widgets/profile_setup_page/profile_setup_experience.dart';
|
||||
import '../widgets/profile_setup_page/profile_setup_header.dart';
|
||||
import '../widgets/profile_setup_page/profile_setup_location.dart';
|
||||
import 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_bloc.dart';
|
||||
import 'package:staff_authentication/src/presentation/widgets/profile_setup_page/profile_setup_basic_info.dart';
|
||||
import 'package:staff_authentication/src/presentation/widgets/profile_setup_page/profile_setup_experience.dart';
|
||||
import 'package:staff_authentication/src/presentation/widgets/profile_setup_page/profile_setup_header.dart';
|
||||
import 'package:staff_authentication/src/presentation/widgets/profile_setup_page/profile_setup_location.dart';
|
||||
|
||||
/// Page for setting up the user profile after authentication.
|
||||
class ProfileSetupPage extends StatefulWidget {
|
||||
|
||||
@@ -4,8 +4,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../blocs/auth_event.dart';
|
||||
import '../../../blocs/auth_bloc.dart';
|
||||
import 'package:staff_authentication/src/presentation/blocs/auth_event.dart';
|
||||
import 'package:staff_authentication/src/presentation/blocs/auth_bloc.dart';
|
||||
|
||||
/// A widget that displays a 6-digit OTP input field.
|
||||
///
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../common/auth_trouble_link.dart';
|
||||
import 'package:staff_authentication/src/presentation/widgets/common/auth_trouble_link.dart';
|
||||
|
||||
/// A widget that displays the primary action button and trouble link for OTP verification.
|
||||
class OtpVerificationActions extends StatelessWidget {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:staff_authentication/src/presentation/widgets/common/section_title_subtitle.dart';
|
||||
|
||||
/// A widget for setting up skills and preferred industries.
|
||||
@@ -27,6 +26,36 @@ class ProfileSetupExperience extends StatelessWidget {
|
||||
/// Callback for when industries change.
|
||||
final ValueChanged<List<String>> onIndustriesChanged;
|
||||
|
||||
/// Available skill options with their API values and labels.
|
||||
static const List<String> _skillValues = <String>[
|
||||
'food_service',
|
||||
'bartending',
|
||||
'event_setup',
|
||||
'hospitality',
|
||||
'warehouse',
|
||||
'customer_service',
|
||||
'cleaning',
|
||||
'security',
|
||||
'retail',
|
||||
'cooking',
|
||||
'cashier',
|
||||
'server',
|
||||
'barista',
|
||||
'host_hostess',
|
||||
'busser',
|
||||
'driving',
|
||||
];
|
||||
|
||||
/// Available industry options with their API values.
|
||||
static const List<String> _industryValues = <String>[
|
||||
'hospitality',
|
||||
'food_service',
|
||||
'warehouse',
|
||||
'events',
|
||||
'retail',
|
||||
'healthcare',
|
||||
];
|
||||
|
||||
/// Toggles a skill.
|
||||
void _toggleSkill({required String skill}) {
|
||||
final List<String> updatedList = List<String>.from(skills);
|
||||
@@ -71,15 +100,14 @@ class ProfileSetupExperience extends StatelessWidget {
|
||||
Wrap(
|
||||
spacing: UiConstants.space2,
|
||||
runSpacing: UiConstants.space2,
|
||||
children: ExperienceSkill.values.map((ExperienceSkill skill) {
|
||||
final bool isSelected = skills.contains(skill.value);
|
||||
// Dynamic translation access
|
||||
children: _skillValues.map((String skill) {
|
||||
final bool isSelected = skills.contains(skill);
|
||||
final String label = _getSkillLabel(skill);
|
||||
|
||||
return UiChip(
|
||||
label: label,
|
||||
isSelected: isSelected,
|
||||
onTap: () => _toggleSkill(skill: skill.value),
|
||||
onTap: () => _toggleSkill(skill: skill),
|
||||
leadingIcon: isSelected ? UiIcons.check : null,
|
||||
variant: UiChipVariant.primary,
|
||||
);
|
||||
@@ -97,14 +125,14 @@ class ProfileSetupExperience extends StatelessWidget {
|
||||
Wrap(
|
||||
spacing: UiConstants.space2,
|
||||
runSpacing: UiConstants.space2,
|
||||
children: Industry.values.map((Industry industry) {
|
||||
final bool isSelected = industries.contains(industry.value);
|
||||
children: _industryValues.map((String industry) {
|
||||
final bool isSelected = industries.contains(industry);
|
||||
final String label = _getIndustryLabel(industry);
|
||||
|
||||
return UiChip(
|
||||
label: label,
|
||||
isSelected: isSelected,
|
||||
onTap: () => _toggleIndustry(industry: industry.value),
|
||||
onTap: () => _toggleIndustry(industry: industry),
|
||||
leadingIcon: isSelected ? UiIcons.check : null,
|
||||
variant: isSelected
|
||||
? UiChipVariant.accent
|
||||
@@ -116,131 +144,71 @@ class ProfileSetupExperience extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
String _getSkillLabel(ExperienceSkill skill) {
|
||||
String _getSkillLabel(String skill) {
|
||||
final TranslationsStaffAuthenticationProfileSetupPageExperienceSkillsEn
|
||||
skillsI18n = t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills;
|
||||
switch (skill) {
|
||||
case ExperienceSkill.foodService:
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills
|
||||
.food_service;
|
||||
case ExperienceSkill.bartending:
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills
|
||||
.bartending;
|
||||
case ExperienceSkill.warehouse:
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills
|
||||
.warehouse;
|
||||
case ExperienceSkill.retail:
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills
|
||||
.retail;
|
||||
// Note: 'events' was removed from enum in favor of 'event_setup' or industry.
|
||||
// Using 'events' translation for eventSetup if available or fallback.
|
||||
case ExperienceSkill.eventSetup:
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills
|
||||
.events;
|
||||
case ExperienceSkill.customerService:
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills
|
||||
.customer_service;
|
||||
case ExperienceSkill.cleaning:
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills
|
||||
.cleaning;
|
||||
case ExperienceSkill.security:
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills
|
||||
.security;
|
||||
case ExperienceSkill.driving:
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills
|
||||
.driving;
|
||||
case ExperienceSkill.cooking:
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.skills
|
||||
.cooking;
|
||||
case 'food_service':
|
||||
return skillsI18n.food_service;
|
||||
case 'bartending':
|
||||
return skillsI18n.bartending;
|
||||
case 'warehouse':
|
||||
return skillsI18n.warehouse;
|
||||
case 'retail':
|
||||
return skillsI18n.retail;
|
||||
case 'event_setup':
|
||||
return skillsI18n.events;
|
||||
case 'customer_service':
|
||||
return skillsI18n.customer_service;
|
||||
case 'cleaning':
|
||||
return skillsI18n.cleaning;
|
||||
case 'security':
|
||||
return skillsI18n.security;
|
||||
case 'driving':
|
||||
return skillsI18n.driving;
|
||||
case 'cooking':
|
||||
return skillsI18n.cooking;
|
||||
case 'cashier':
|
||||
return skillsI18n.cashier;
|
||||
case 'server':
|
||||
return skillsI18n.server;
|
||||
case 'barista':
|
||||
return skillsI18n.barista;
|
||||
case 'host_hostess':
|
||||
return skillsI18n.host_hostess;
|
||||
case 'busser':
|
||||
return skillsI18n.busser;
|
||||
default:
|
||||
return skill.value;
|
||||
return skill;
|
||||
}
|
||||
}
|
||||
|
||||
String _getIndustryLabel(Industry industry) {
|
||||
String _getIndustryLabel(String industry) {
|
||||
final TranslationsStaffAuthenticationProfileSetupPageExperienceIndustriesEn
|
||||
industriesI18n = t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.industries;
|
||||
switch (industry) {
|
||||
case Industry.hospitality:
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.industries
|
||||
.hospitality;
|
||||
case Industry.foodService:
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.industries
|
||||
.food_service;
|
||||
case Industry.warehouse:
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.industries
|
||||
.warehouse;
|
||||
case Industry.events:
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.industries
|
||||
.events;
|
||||
case Industry.retail:
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.industries
|
||||
.retail;
|
||||
case Industry.healthcare:
|
||||
return t
|
||||
.staff_authentication
|
||||
.profile_setup_page
|
||||
.experience
|
||||
.industries
|
||||
.healthcare;
|
||||
case 'hospitality':
|
||||
return industriesI18n.hospitality;
|
||||
case 'food_service':
|
||||
return industriesI18n.food_service;
|
||||
case 'warehouse':
|
||||
return industriesI18n.warehouse;
|
||||
case 'events':
|
||||
return industriesI18n.events;
|
||||
case 'retail':
|
||||
return industriesI18n.retail;
|
||||
case 'healthcare':
|
||||
return industriesI18n.healthcare;
|
||||
default:
|
||||
return industry.value;
|
||||
return industry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:staff_authentication/src/data/repositories_impl/auth_repository_impl.dart';
|
||||
import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart';
|
||||
import 'package:staff_authentication/src/domain/usecases/sign_in_with_phone_usecase.dart';
|
||||
@@ -20,17 +20,24 @@ import 'package:staff_authentication/src/presentation/pages/phone_verification_p
|
||||
import 'package:staff_authentication/src/presentation/pages/profile_setup_page.dart';
|
||||
import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart';
|
||||
|
||||
/// A [Module] for the staff authentication feature.
|
||||
/// A [Module] for the staff authentication feature.
|
||||
///
|
||||
/// Provides repositories, use cases, and BLoCs for phone-based
|
||||
/// authentication and profile setup. Uses V2 API via [BaseApiService].
|
||||
class StaffAuthenticationModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[DataConnectModule()];
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<ProfileSetupRepository>(ProfileSetupRepositoryImpl.new);
|
||||
i.addLazySingleton<AuthRepositoryInterface>(
|
||||
() => AuthRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||
);
|
||||
i.addLazySingleton<ProfileSetupRepository>(
|
||||
() => ProfileSetupRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||
);
|
||||
i.addLazySingleton<PlaceRepository>(PlaceRepositoryImpl.new);
|
||||
i.addLazySingleton<AuthRepositoryInterface>(AuthRepositoryImpl.new);
|
||||
|
||||
// UseCases
|
||||
i.addLazySingleton(SignInWithPhoneUseCase.new);
|
||||
@@ -53,7 +60,6 @@ class StaffAuthenticationModule extends Module {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void routes(RouteManager r) {
|
||||
r.child(StaffPaths.root, child: (_) => const IntroPage());
|
||||
|
||||
@@ -14,16 +14,12 @@ 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
|
||||
firebase_data_connect: ^0.2.2+1
|
||||
http: ^1.2.0
|
||||
|
||||
|
||||
# Architecture Packages
|
||||
krow_domain:
|
||||
path: ../../../domain
|
||||
krow_data_connect:
|
||||
path: ../../../data_connect
|
||||
krow_core:
|
||||
path: ../../../core
|
||||
design_system:
|
||||
|
||||
@@ -1,235 +1,108 @@
|
||||
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/availability_repository.dart';
|
||||
import 'package:staff_availability/src/domain/repositories/availability_repository.dart';
|
||||
|
||||
/// Implementation of [AvailabilityRepository] using Firebase Data Connect.
|
||||
/// V2 API implementation of [AvailabilityRepository].
|
||||
///
|
||||
/// Note: The backend schema supports recurring availablity (Weekly/DayOfWeek),
|
||||
/// not specific date availability. Therefore, updating availability for a specific
|
||||
/// date will update the availability for that Day of Week globally (Recurring).
|
||||
class AvailabilityRepositoryImpl
|
||||
implements AvailabilityRepository {
|
||||
final dc.DataConnectService _service;
|
||||
/// Uses the unified REST API for all read/write operations.
|
||||
/// - `GET /staff/availability` to list availability for a date range.
|
||||
/// - `PUT /staff/availability` to update a single day.
|
||||
/// - `POST /staff/availability/quick-set` to apply a preset.
|
||||
class AvailabilityRepositoryImpl implements AvailabilityRepository {
|
||||
/// Creates an [AvailabilityRepositoryImpl].
|
||||
AvailabilityRepositoryImpl({required BaseApiService apiService})
|
||||
: _apiService = apiService;
|
||||
|
||||
AvailabilityRepositoryImpl() : _service = dc.DataConnectService.instance;
|
||||
/// The API service used for network requests.
|
||||
final BaseApiService _apiService;
|
||||
|
||||
@override
|
||||
Future<List<DayAvailability>> getAvailability(DateTime start, DateTime end) async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
// 1. Fetch Weekly recurring availability
|
||||
final QueryResult<dc.ListStaffAvailabilitiesByStaffIdData, dc.ListStaffAvailabilitiesByStaffIdVariables> result =
|
||||
await _service.connector.listStaffAvailabilitiesByStaffId(staffId: staffId).limit(100).execute();
|
||||
Future<List<AvailabilityDay>> getAvailability(
|
||||
DateTime start,
|
||||
DateTime end,
|
||||
) async {
|
||||
final String startDate = _toIsoDate(start);
|
||||
final String endDate = _toIsoDate(end);
|
||||
|
||||
final List<dc.ListStaffAvailabilitiesByStaffIdStaffAvailabilities> items = result.data.staffAvailabilities;
|
||||
final ApiResponse response = await _apiService.get(
|
||||
V2ApiEndpoints.staffAvailability,
|
||||
params: <String, dynamic>{
|
||||
'startDate': startDate,
|
||||
'endDate': endDate,
|
||||
},
|
||||
);
|
||||
|
||||
// 2. Map to lookup: DayOfWeek -> Map<SlotName, IsAvailable>
|
||||
final Map<dc.DayOfWeek, Map<dc.AvailabilitySlot, bool>> weeklyMap = {};
|
||||
|
||||
for (final item in items) {
|
||||
dc.DayOfWeek day;
|
||||
try {
|
||||
day = dc.DayOfWeek.values.byName(item.day.stringValue);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
|
||||
final List<dynamic> items = body['items'] as List<dynamic>;
|
||||
|
||||
dc.AvailabilitySlot slot;
|
||||
try {
|
||||
slot = dc.AvailabilitySlot.values.byName(item.slot.stringValue);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
|
||||
bool isAvailable = false;
|
||||
try {
|
||||
final dc.AvailabilityStatus status = dc.AvailabilityStatus.values.byName(item.status.stringValue);
|
||||
isAvailable = _statusToBool(status);
|
||||
} catch (_) {
|
||||
isAvailable = false;
|
||||
}
|
||||
|
||||
if (!weeklyMap.containsKey(day)) {
|
||||
weeklyMap[day] = {};
|
||||
}
|
||||
weeklyMap[day]![slot] = isAvailable;
|
||||
}
|
||||
|
||||
// 3. Generate DayAvailability for requested range
|
||||
final List<DayAvailability> days = [];
|
||||
final int dayCount = end.difference(start).inDays;
|
||||
|
||||
for (int i = 0; i <= dayCount; i++) {
|
||||
final DateTime date = start.add(Duration(days: i));
|
||||
final dc.DayOfWeek dow = _toBackendDay(date.weekday);
|
||||
|
||||
final Map<dc.AvailabilitySlot, bool> daySlots = weeklyMap[dow] ?? {};
|
||||
|
||||
// We define 3 standard slots for every day
|
||||
final List<AvailabilitySlot> slots = [
|
||||
_createSlot(date, dow, daySlots, dc.AvailabilitySlot.MORNING),
|
||||
_createSlot(date, dow, daySlots, dc.AvailabilitySlot.AFTERNOON),
|
||||
_createSlot(date, dow, daySlots, dc.AvailabilitySlot.EVENING),
|
||||
];
|
||||
|
||||
final bool isDayAvailable = slots.any((s) => s.isAvailable);
|
||||
|
||||
days.add(DayAvailability(
|
||||
date: date,
|
||||
isAvailable: isDayAvailable,
|
||||
slots: slots,
|
||||
));
|
||||
}
|
||||
return days;
|
||||
});
|
||||
}
|
||||
|
||||
AvailabilitySlot _createSlot(
|
||||
DateTime date,
|
||||
dc.DayOfWeek dow,
|
||||
Map<dc.AvailabilitySlot, bool> existingSlots,
|
||||
dc.AvailabilitySlot slotEnum,
|
||||
) {
|
||||
final bool isAvailable = existingSlots[slotEnum] ?? false;
|
||||
return AvailabilityAdapter.fromPrimitive(slotEnum.name, isAvailable: isAvailable);
|
||||
return items
|
||||
.map((dynamic e) =>
|
||||
AvailabilityDay.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DayAvailability> updateDayAvailability(DayAvailability availability) async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
final dc.DayOfWeek dow = _toBackendDay(availability.date.weekday);
|
||||
Future<AvailabilityDay> updateDayAvailability({
|
||||
required int dayOfWeek,
|
||||
required AvailabilityStatus status,
|
||||
required List<TimeSlot> slots,
|
||||
}) async {
|
||||
final ApiResponse response = await _apiService.put(
|
||||
V2ApiEndpoints.staffAvailability,
|
||||
data: <String, dynamic>{
|
||||
'dayOfWeek': dayOfWeek,
|
||||
'availabilityStatus': status.toJson(),
|
||||
'slots': slots.map((TimeSlot s) => s.toJson()).toList(),
|
||||
},
|
||||
);
|
||||
|
||||
// Update each slot in the backend.
|
||||
// This updates the recurring rule for this DayOfWeek.
|
||||
for (final AvailabilitySlot slot in availability.slots) {
|
||||
final dc.AvailabilitySlot slotEnum = _toBackendSlot(slot.id);
|
||||
final dc.AvailabilityStatus status = _boolToStatus(slot.isAvailable);
|
||||
final Map<String, dynamic> body = response.data as Map<String, dynamic>;
|
||||
|
||||
await _upsertSlot(staffId, dow, slotEnum, status);
|
||||
}
|
||||
|
||||
return availability;
|
||||
});
|
||||
// The PUT response returns the updated day info.
|
||||
return AvailabilityDay(
|
||||
date: '',
|
||||
dayOfWeek: body['dayOfWeek'] as int,
|
||||
availabilityStatus:
|
||||
AvailabilityStatus.fromJson(body['availabilityStatus'] as String?),
|
||||
slots: _parseSlotsFromResponse(body['slots']),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<DayAvailability>> applyQuickSet(DateTime start, DateTime end, String type) async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
// QuickSet updates the Recurring schedule for all days involved.
|
||||
// However, if the user selects a range that covers e.g. Mon-Fri, we update Mon-Fri.
|
||||
|
||||
final int dayCount = end.difference(start).inDays;
|
||||
final Set<dc.DayOfWeek> processedDays = {};
|
||||
final List<DayAvailability> resultDays = [];
|
||||
Future<void> applyQuickSet({
|
||||
required String quickSetType,
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
List<TimeSlot>? slots,
|
||||
}) async {
|
||||
final Map<String, dynamic> data = <String, dynamic>{
|
||||
'quickSetType': quickSetType,
|
||||
'startDate': start.toUtc().toIso8601String(),
|
||||
'endDate': end.toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
final List<Future<void>> futures = [];
|
||||
|
||||
for (int i = 0; i <= dayCount; i++) {
|
||||
final DateTime date = start.add(Duration(days: i));
|
||||
final dc.DayOfWeek dow = _toBackendDay(date.weekday);
|
||||
|
||||
// Logic to determine if enabled based on type
|
||||
bool enableDay = false;
|
||||
if (type == 'all') {
|
||||
enableDay = true;
|
||||
} else if (type == 'clear') {
|
||||
enableDay = false;
|
||||
} else if (type == 'weekdays') {
|
||||
enableDay = (dow != dc.DayOfWeek.SATURDAY && dow != dc.DayOfWeek.SUNDAY);
|
||||
} else if (type == 'weekends') {
|
||||
enableDay = (dow == dc.DayOfWeek.SATURDAY || dow == dc.DayOfWeek.SUNDAY);
|
||||
}
|
||||
|
||||
// Only update backend once per DayOfWeek (since it's recurring)
|
||||
if (!processedDays.contains(dow)) {
|
||||
processedDays.add(dow);
|
||||
final dc.AvailabilityStatus status = _boolToStatus(enableDay);
|
||||
|
||||
futures.add(_upsertSlot(staffId, dow, dc.AvailabilitySlot.MORNING, status));
|
||||
futures.add(_upsertSlot(staffId, dow, dc.AvailabilitySlot.AFTERNOON, status));
|
||||
futures.add(_upsertSlot(staffId, dow, dc.AvailabilitySlot.EVENING, status));
|
||||
}
|
||||
|
||||
// Prepare return object
|
||||
final slots = [
|
||||
AvailabilityAdapter.fromPrimitive('MORNING', isAvailable: enableDay),
|
||||
AvailabilityAdapter.fromPrimitive('AFTERNOON', isAvailable: enableDay),
|
||||
AvailabilityAdapter.fromPrimitive('EVENING', isAvailable: enableDay),
|
||||
];
|
||||
|
||||
resultDays.add(DayAvailability(
|
||||
date: date,
|
||||
isAvailable: enableDay,
|
||||
slots: slots,
|
||||
));
|
||||
}
|
||||
|
||||
// Execute all updates in parallel
|
||||
await Future.wait(futures);
|
||||
|
||||
return resultDays;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _upsertSlot(String staffId, dc.DayOfWeek day, dc.AvailabilitySlot slot, dc.AvailabilityStatus status) async {
|
||||
// Check if exists
|
||||
final result = await _service.connector.getStaffAvailabilityByKey(
|
||||
staffId: staffId,
|
||||
day: day,
|
||||
slot: slot,
|
||||
).execute();
|
||||
|
||||
if (result.data.staffAvailability != null) {
|
||||
// Update
|
||||
await _service.connector.updateStaffAvailability(
|
||||
staffId: staffId,
|
||||
day: day,
|
||||
slot: slot,
|
||||
).status(status).execute();
|
||||
} else {
|
||||
// Create
|
||||
await _service.connector.createStaffAvailability(
|
||||
staffId: staffId,
|
||||
day: day,
|
||||
slot: slot,
|
||||
).status(status).execute();
|
||||
if (slots != null && slots.isNotEmpty) {
|
||||
data['slots'] = slots.map((TimeSlot s) => s.toJson()).toList();
|
||||
}
|
||||
|
||||
await _apiService.post(
|
||||
V2ApiEndpoints.staffAvailabilityQuickSet,
|
||||
data: data,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Private Helpers ---
|
||||
|
||||
dc.DayOfWeek _toBackendDay(int weekday) {
|
||||
switch (weekday) {
|
||||
case DateTime.monday: return dc.DayOfWeek.MONDAY;
|
||||
case DateTime.tuesday: return dc.DayOfWeek.TUESDAY;
|
||||
case DateTime.wednesday: return dc.DayOfWeek.WEDNESDAY;
|
||||
case DateTime.thursday: return dc.DayOfWeek.THURSDAY;
|
||||
case DateTime.friday: return dc.DayOfWeek.FRIDAY;
|
||||
case DateTime.saturday: return dc.DayOfWeek.SATURDAY;
|
||||
case DateTime.sunday: return dc.DayOfWeek.SUNDAY;
|
||||
default: return dc.DayOfWeek.MONDAY;
|
||||
}
|
||||
/// Formats a [DateTime] as `YYYY-MM-DD`.
|
||||
String _toIsoDate(DateTime date) {
|
||||
return '${date.year.toString().padLeft(4, '0')}-'
|
||||
'${date.month.toString().padLeft(2, '0')}-'
|
||||
'${date.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
dc.AvailabilitySlot _toBackendSlot(String id) {
|
||||
switch (id.toLowerCase()) {
|
||||
case 'morning': return dc.AvailabilitySlot.MORNING;
|
||||
case 'afternoon': return dc.AvailabilitySlot.AFTERNOON;
|
||||
case 'evening': return dc.AvailabilitySlot.EVENING;
|
||||
default: return dc.AvailabilitySlot.MORNING;
|
||||
}
|
||||
}
|
||||
|
||||
bool _statusToBool(dc.AvailabilityStatus status) {
|
||||
return status == dc.AvailabilityStatus.CONFIRMED_AVAILABLE;
|
||||
}
|
||||
|
||||
dc.AvailabilityStatus _boolToStatus(bool isAvailable) {
|
||||
return isAvailable ? dc.AvailabilityStatus.CONFIRMED_AVAILABLE : dc.AvailabilityStatus.BLOCKED;
|
||||
/// Safely parses a dynamic slots value into [TimeSlot] list.
|
||||
List<TimeSlot> _parseSlotsFromResponse(dynamic rawSlots) {
|
||||
if (rawSlots is! List<dynamic>) return <TimeSlot>[];
|
||||
return rawSlots
|
||||
.map((dynamic e) => TimeSlot.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Contract for fetching and updating staff availability.
|
||||
abstract class AvailabilityRepository {
|
||||
/// Fetches availability for a given date range (usually a week).
|
||||
Future<List<DayAvailability>> getAvailability(DateTime start, DateTime end);
|
||||
Future<List<AvailabilityDay>> getAvailability(
|
||||
DateTime start,
|
||||
DateTime end,
|
||||
);
|
||||
|
||||
/// Updates the availability for a specific day.
|
||||
Future<DayAvailability> updateDayAvailability(DayAvailability availability);
|
||||
|
||||
/// Applies a preset configuration (e.g. All Week, Weekdays only) to a range.
|
||||
Future<List<DayAvailability>> applyQuickSet(DateTime start, DateTime end, String type);
|
||||
/// Updates the availability for a specific day of the week.
|
||||
Future<AvailabilityDay> updateDayAvailability({
|
||||
required int dayOfWeek,
|
||||
required AvailabilityStatus status,
|
||||
required List<TimeSlot> slots,
|
||||
});
|
||||
|
||||
/// Applies a preset configuration (e.g. "all", "weekdays") to the week.
|
||||
Future<void> applyQuickSet({
|
||||
required String quickSetType,
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
List<TimeSlot> slots,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,28 +1,38 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/availability_repository.dart';
|
||||
|
||||
/// Use case to apply a quick-set availability pattern (e.g., "Weekdays", "All Week") to a week.
|
||||
class ApplyQuickSetUseCase extends UseCase<ApplyQuickSetParams, List<DayAvailability>> {
|
||||
final AvailabilityRepository repository;
|
||||
import 'package:staff_availability/src/domain/repositories/availability_repository.dart';
|
||||
|
||||
/// Use case to apply a quick-set availability pattern to the current week.
|
||||
///
|
||||
/// Supported types: `all`, `weekdays`, `weekends`, `clear`.
|
||||
class ApplyQuickSetUseCase extends UseCase<ApplyQuickSetParams, void> {
|
||||
/// Creates an [ApplyQuickSetUseCase].
|
||||
ApplyQuickSetUseCase(this.repository);
|
||||
|
||||
/// [type] can be 'all', 'weekdays', 'weekends', 'clear'
|
||||
/// The availability repository.
|
||||
final AvailabilityRepository repository;
|
||||
|
||||
@override
|
||||
Future<List<DayAvailability>> call(ApplyQuickSetParams params) {
|
||||
final end = params.start.add(const Duration(days: 6));
|
||||
return repository.applyQuickSet(params.start, end, params.type);
|
||||
Future<void> call(ApplyQuickSetParams params) {
|
||||
final DateTime end = params.start.add(const Duration(days: 6));
|
||||
return repository.applyQuickSet(
|
||||
quickSetType: params.type,
|
||||
start: params.start,
|
||||
end: end,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters for [ApplyQuickSetUseCase].
|
||||
class ApplyQuickSetParams extends UseCaseArgument {
|
||||
final DateTime start;
|
||||
final String type;
|
||||
|
||||
/// Creates [ApplyQuickSetParams].
|
||||
const ApplyQuickSetParams(this.start, this.type);
|
||||
|
||||
/// The Monday of the target week.
|
||||
final DateTime start;
|
||||
|
||||
/// Quick-set type: `all`, `weekdays`, `weekends`, or `clear`.
|
||||
final String type;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [start, type];
|
||||
List<Object?> get props => <Object?>[start, type];
|
||||
}
|
||||
|
||||
@@ -1,30 +1,36 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/availability_repository.dart';
|
||||
import 'package:staff_availability/src/domain/repositories/availability_repository.dart';
|
||||
|
||||
/// Use case to fetch availability for a specific week.
|
||||
///
|
||||
/// This encapsulates the logic of calculating the week range and fetching data
|
||||
/// from the repository.
|
||||
class GetWeeklyAvailabilityUseCase extends UseCase<GetWeeklyAvailabilityParams, List<DayAvailability>> {
|
||||
final AvailabilityRepository repository;
|
||||
|
||||
///
|
||||
/// Calculates the week range from the given start date and delegates
|
||||
/// to the repository.
|
||||
class GetWeeklyAvailabilityUseCase
|
||||
extends UseCase<GetWeeklyAvailabilityParams, List<AvailabilityDay>> {
|
||||
/// Creates a [GetWeeklyAvailabilityUseCase].
|
||||
GetWeeklyAvailabilityUseCase(this.repository);
|
||||
|
||||
/// The availability repository.
|
||||
final AvailabilityRepository repository;
|
||||
|
||||
@override
|
||||
Future<List<DayAvailability>> call(GetWeeklyAvailabilityParams params) async {
|
||||
// Calculate end of week (assuming start is start of week)
|
||||
final end = params.start.add(const Duration(days: 6));
|
||||
Future<List<AvailabilityDay>> call(
|
||||
GetWeeklyAvailabilityParams params,
|
||||
) async {
|
||||
final DateTime end = params.start.add(const Duration(days: 6));
|
||||
return repository.getAvailability(params.start, end);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters for [GetWeeklyAvailabilityUseCase].
|
||||
class GetWeeklyAvailabilityParams extends UseCaseArgument {
|
||||
final DateTime start;
|
||||
|
||||
/// Creates [GetWeeklyAvailabilityParams].
|
||||
const GetWeeklyAvailabilityParams(this.start);
|
||||
|
||||
/// The Monday of the target week.
|
||||
final DateTime start;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [start];
|
||||
List<Object?> get props => <Object?>[start];
|
||||
}
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/availability_repository.dart';
|
||||
import 'package:staff_availability/src/domain/repositories/availability_repository.dart';
|
||||
|
||||
/// Use case to update the availability configuration for a specific day.
|
||||
class UpdateDayAvailabilityUseCase extends UseCase<UpdateDayAvailabilityParams, DayAvailability> {
|
||||
final AvailabilityRepository repository;
|
||||
|
||||
class UpdateDayAvailabilityUseCase
|
||||
extends UseCase<UpdateDayAvailabilityParams, AvailabilityDay> {
|
||||
/// Creates an [UpdateDayAvailabilityUseCase].
|
||||
UpdateDayAvailabilityUseCase(this.repository);
|
||||
|
||||
/// The availability repository.
|
||||
final AvailabilityRepository repository;
|
||||
|
||||
@override
|
||||
Future<DayAvailability> call(UpdateDayAvailabilityParams params) {
|
||||
return repository.updateDayAvailability(params.availability);
|
||||
Future<AvailabilityDay> call(UpdateDayAvailabilityParams params) {
|
||||
return repository.updateDayAvailability(
|
||||
dayOfWeek: params.dayOfWeek,
|
||||
status: params.status,
|
||||
slots: params.slots,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters for [UpdateDayAvailabilityUseCase].
|
||||
class UpdateDayAvailabilityParams extends UseCaseArgument {
|
||||
final DayAvailability availability;
|
||||
/// Creates [UpdateDayAvailabilityParams].
|
||||
const UpdateDayAvailabilityParams({
|
||||
required this.dayOfWeek,
|
||||
required this.status,
|
||||
required this.slots,
|
||||
});
|
||||
|
||||
const UpdateDayAvailabilityParams(this.availability);
|
||||
/// Day of week (0 = Sunday, 6 = Saturday).
|
||||
final int dayOfWeek;
|
||||
|
||||
/// New availability status.
|
||||
final AvailabilityStatus status;
|
||||
|
||||
/// Time slots for this day.
|
||||
final List<TimeSlot> slots;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [availability];
|
||||
List<Object?> get props => <Object?>[dayOfWeek, status, slots];
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../domain/usecases/apply_quick_set_usecase.dart';
|
||||
import '../../domain/usecases/get_weekly_availability_usecase.dart';
|
||||
import '../../domain/usecases/update_day_availability_usecase.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'availability_event.dart';
|
||||
import 'availability_state.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:staff_availability/src/domain/usecases/apply_quick_set_usecase.dart';
|
||||
import 'package:staff_availability/src/domain/usecases/get_weekly_availability_usecase.dart';
|
||||
import 'package:staff_availability/src/domain/usecases/update_day_availability_usecase.dart';
|
||||
import 'package:staff_availability/src/presentation/blocs/availability_event.dart';
|
||||
import 'package:staff_availability/src/presentation/blocs/availability_state.dart';
|
||||
|
||||
/// Manages availability state for the staff availability page.
|
||||
///
|
||||
/// Coordinates loading, toggling, and quick-set operations through
|
||||
/// domain use cases.
|
||||
class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
|
||||
with BlocErrorHandler<AvailabilityState> {
|
||||
final GetWeeklyAvailabilityUseCase getWeeklyAvailability;
|
||||
final UpdateDayAvailabilityUseCase updateDayAvailability;
|
||||
final ApplyQuickSetUseCase applyQuickSet;
|
||||
|
||||
/// Creates an [AvailabilityBloc].
|
||||
AvailabilityBloc({
|
||||
required this.getWeeklyAvailability,
|
||||
required this.updateDayAvailability,
|
||||
@@ -25,6 +27,15 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
|
||||
on<PerformQuickSet>(_onPerformQuickSet);
|
||||
}
|
||||
|
||||
/// Use case for loading weekly availability.
|
||||
final GetWeeklyAvailabilityUseCase getWeeklyAvailability;
|
||||
|
||||
/// Use case for updating a single day.
|
||||
final UpdateDayAvailabilityUseCase updateDayAvailability;
|
||||
|
||||
/// Use case for applying a quick-set preset.
|
||||
final ApplyQuickSetUseCase applyQuickSet;
|
||||
|
||||
Future<void> _onLoadAvailability(
|
||||
LoadAvailability event,
|
||||
Emitter<AvailabilityState> emit,
|
||||
@@ -33,15 +44,18 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final days = await getWeeklyAvailability(
|
||||
final List<AvailabilityDay> days = await getWeeklyAvailability(
|
||||
GetWeeklyAvailabilityParams(event.weekStart),
|
||||
);
|
||||
|
||||
// Determine selected date: preselected, or first day of the week.
|
||||
final DateTime selectedDate = event.preselectedDate ?? event.weekStart;
|
||||
|
||||
emit(
|
||||
AvailabilityLoaded(
|
||||
days: days,
|
||||
currentWeekStart: event.weekStart,
|
||||
selectedDate: event.preselectedDate ??
|
||||
(days.isNotEmpty ? days.first.date : DateTime.now()),
|
||||
selectedDate: selectedDate,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -51,7 +65,6 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
|
||||
|
||||
void _onSelectDate(SelectDate event, Emitter<AvailabilityState> emit) {
|
||||
if (state is AvailabilityLoaded) {
|
||||
// Clear success message on navigation
|
||||
emit(
|
||||
(state as AvailabilityLoaded).copyWith(
|
||||
selectedDate: event.date,
|
||||
@@ -66,19 +79,18 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
|
||||
Emitter<AvailabilityState> emit,
|
||||
) async {
|
||||
if (state is AvailabilityLoaded) {
|
||||
final currentState = state as AvailabilityLoaded;
|
||||
|
||||
// Clear message
|
||||
final AvailabilityLoaded currentState = state as AvailabilityLoaded;
|
||||
emit(currentState.copyWith(clearSuccessMessage: true));
|
||||
|
||||
final newWeekStart = currentState.currentWeekStart.add(
|
||||
final DateTime newWeekStart = currentState.currentWeekStart.add(
|
||||
Duration(days: event.direction * 7),
|
||||
);
|
||||
|
||||
final diff = currentState.selectedDate
|
||||
// Preserve the relative day offset when navigating.
|
||||
final int diff = currentState.selectedDate
|
||||
.difference(currentState.currentWeekStart)
|
||||
.inDays;
|
||||
final newSelectedDate = newWeekStart.add(Duration(days: diff));
|
||||
final DateTime newSelectedDate = newWeekStart.add(Duration(days: diff));
|
||||
|
||||
add(LoadAvailability(newWeekStart, preselectedDate: newSelectedDate));
|
||||
}
|
||||
@@ -89,14 +101,22 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
|
||||
Emitter<AvailabilityState> emit,
|
||||
) async {
|
||||
if (state is AvailabilityLoaded) {
|
||||
final currentState = state as AvailabilityLoaded;
|
||||
final AvailabilityLoaded currentState = state as AvailabilityLoaded;
|
||||
|
||||
final newDay = event.day.copyWith(isAvailable: !event.day.isAvailable);
|
||||
final updatedDays = currentState.days.map((d) {
|
||||
return d.date == event.day.date ? newDay : d;
|
||||
}).toList();
|
||||
// Toggle: available -> unavailable, anything else -> available.
|
||||
final AvailabilityStatus newStatus = event.day.isAvailable
|
||||
? AvailabilityStatus.unavailable
|
||||
: AvailabilityStatus.available;
|
||||
|
||||
final AvailabilityDay newDay = event.day.copyWith(
|
||||
availabilityStatus: newStatus,
|
||||
);
|
||||
|
||||
// Optimistic update.
|
||||
final List<AvailabilityDay> updatedDays = currentState.days
|
||||
.map((AvailabilityDay d) => d.date == event.day.date ? newDay : d)
|
||||
.toList();
|
||||
|
||||
// Optimistic update
|
||||
emit(currentState.copyWith(
|
||||
days: updatedDays,
|
||||
clearSuccessMessage: true,
|
||||
@@ -105,8 +125,13 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
await updateDayAvailability(UpdateDayAvailabilityParams(newDay));
|
||||
// Success feedback
|
||||
await updateDayAvailability(
|
||||
UpdateDayAvailabilityParams(
|
||||
dayOfWeek: newDay.dayOfWeek,
|
||||
status: newStatus,
|
||||
slots: newDay.slots,
|
||||
),
|
||||
);
|
||||
if (state is AvailabilityLoaded) {
|
||||
emit(
|
||||
(state as AvailabilityLoaded).copyWith(
|
||||
@@ -116,7 +141,7 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
|
||||
}
|
||||
},
|
||||
onError: (String errorKey) {
|
||||
// Revert
|
||||
// Revert on failure.
|
||||
if (state is AvailabilityLoaded) {
|
||||
return (state as AvailabilityLoaded).copyWith(
|
||||
days: currentState.days,
|
||||
@@ -133,22 +158,41 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
|
||||
Emitter<AvailabilityState> emit,
|
||||
) async {
|
||||
if (state is AvailabilityLoaded) {
|
||||
final currentState = state as AvailabilityLoaded;
|
||||
final AvailabilityLoaded currentState = state as AvailabilityLoaded;
|
||||
|
||||
final updatedSlots = event.day.slots.map((s) {
|
||||
if (s.id == event.slotId) {
|
||||
return s.copyWith(isAvailable: !s.isAvailable);
|
||||
}
|
||||
return s;
|
||||
}).toList();
|
||||
// Remove the slot at the given index to toggle it off,
|
||||
// or re-add if already removed. For V2, toggling a slot means
|
||||
// removing it from the list (unavailable) or the day remains
|
||||
// with the remaining slots.
|
||||
// For simplicity, we toggle the overall day status instead of
|
||||
// individual slot removal since the V2 API sends full slot arrays.
|
||||
|
||||
final newDay = event.day.copyWith(slots: updatedSlots);
|
||||
// Build a new slots list by removing or keeping the target slot.
|
||||
final List<TimeSlot> currentSlots =
|
||||
List<TimeSlot>.from(event.day.slots);
|
||||
|
||||
final updatedDays = currentState.days.map((d) {
|
||||
return d.date == event.day.date ? newDay : d;
|
||||
}).toList();
|
||||
// If there's only one slot and we remove it, day becomes unavailable.
|
||||
// If there are multiple, remove the indexed one.
|
||||
if (event.slotIndex >= 0 && event.slotIndex < currentSlots.length) {
|
||||
currentSlots.removeAt(event.slotIndex);
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
final AvailabilityStatus newStatus = currentSlots.isEmpty
|
||||
? AvailabilityStatus.unavailable
|
||||
: (currentSlots.length < event.day.slots.length
|
||||
? AvailabilityStatus.partial
|
||||
: event.day.availabilityStatus);
|
||||
|
||||
final AvailabilityDay newDay = event.day.copyWith(
|
||||
availabilityStatus: newStatus,
|
||||
slots: currentSlots,
|
||||
);
|
||||
|
||||
final List<AvailabilityDay> updatedDays = currentState.days
|
||||
.map((AvailabilityDay d) => d.date == event.day.date ? newDay : d)
|
||||
.toList();
|
||||
|
||||
// Optimistic update.
|
||||
emit(currentState.copyWith(
|
||||
days: updatedDays,
|
||||
clearSuccessMessage: true,
|
||||
@@ -157,8 +201,13 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
await updateDayAvailability(UpdateDayAvailabilityParams(newDay));
|
||||
// Success feedback
|
||||
await updateDayAvailability(
|
||||
UpdateDayAvailabilityParams(
|
||||
dayOfWeek: newDay.dayOfWeek,
|
||||
status: newStatus,
|
||||
slots: currentSlots,
|
||||
),
|
||||
);
|
||||
if (state is AvailabilityLoaded) {
|
||||
emit(
|
||||
(state as AvailabilityLoaded).copyWith(
|
||||
@@ -168,7 +217,7 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
|
||||
}
|
||||
},
|
||||
onError: (String errorKey) {
|
||||
// Revert
|
||||
// Revert on failure.
|
||||
if (state is AvailabilityLoaded) {
|
||||
return (state as AvailabilityLoaded).copyWith(
|
||||
days: currentState.days,
|
||||
@@ -185,7 +234,7 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
|
||||
Emitter<AvailabilityState> emit,
|
||||
) async {
|
||||
if (state is AvailabilityLoaded) {
|
||||
final currentState = state as AvailabilityLoaded;
|
||||
final AvailabilityLoaded currentState = state as AvailabilityLoaded;
|
||||
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
@@ -197,13 +246,18 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final newDays = await applyQuickSet(
|
||||
await applyQuickSet(
|
||||
ApplyQuickSetParams(currentState.currentWeekStart, event.type),
|
||||
);
|
||||
|
||||
// Reload the week to get updated data from the server.
|
||||
final List<AvailabilityDay> refreshed = await getWeeklyAvailability(
|
||||
GetWeeklyAvailabilityParams(currentState.currentWeekStart),
|
||||
);
|
||||
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
days: newDays,
|
||||
days: refreshed,
|
||||
isActionInProgress: false,
|
||||
successMessage: 'Availability updated',
|
||||
),
|
||||
@@ -221,4 +275,3 @@ class AvailabilityBloc extends Bloc<AvailabilityEvent, AvailabilityState>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
// --- State ---
|
||||
class AvailabilityState extends Equatable {
|
||||
final DateTime currentWeekStart;
|
||||
final DateTime selectedDate;
|
||||
final Map<String, bool> dayAvailability;
|
||||
final Map<String, Map<String, bool>> timeSlotAvailability;
|
||||
|
||||
const AvailabilityState({
|
||||
required this.currentWeekStart,
|
||||
required this.selectedDate,
|
||||
required this.dayAvailability,
|
||||
required this.timeSlotAvailability,
|
||||
});
|
||||
|
||||
AvailabilityState copyWith({
|
||||
DateTime? currentWeekStart,
|
||||
DateTime? selectedDate,
|
||||
Map<String, bool>? dayAvailability,
|
||||
Map<String, Map<String, bool>>? timeSlotAvailability,
|
||||
}) {
|
||||
return AvailabilityState(
|
||||
currentWeekStart: currentWeekStart ?? this.currentWeekStart,
|
||||
selectedDate: selectedDate ?? this.selectedDate,
|
||||
dayAvailability: dayAvailability ?? this.dayAvailability,
|
||||
timeSlotAvailability: timeSlotAvailability ?? this.timeSlotAvailability,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object> get props => [
|
||||
currentWeekStart,
|
||||
selectedDate,
|
||||
dayAvailability,
|
||||
timeSlotAvailability,
|
||||
];
|
||||
}
|
||||
|
||||
// --- Cubit ---
|
||||
class AvailabilityCubit extends Cubit<AvailabilityState> {
|
||||
AvailabilityCubit()
|
||||
: super(AvailabilityState(
|
||||
currentWeekStart: _getStartOfWeek(DateTime.now()),
|
||||
selectedDate: DateTime.now(),
|
||||
dayAvailability: {
|
||||
'monday': true,
|
||||
'tuesday': true,
|
||||
'wednesday': true,
|
||||
'thursday': true,
|
||||
'friday': true,
|
||||
'saturday': false,
|
||||
'sunday': false,
|
||||
},
|
||||
timeSlotAvailability: {
|
||||
'monday': {'morning': true, 'afternoon': true, 'evening': true},
|
||||
'tuesday': {'morning': true, 'afternoon': true, 'evening': true},
|
||||
'wednesday': {'morning': true, 'afternoon': true, 'evening': true},
|
||||
'thursday': {'morning': true, 'afternoon': true, 'evening': true},
|
||||
'friday': {'morning': true, 'afternoon': true, 'evening': true},
|
||||
'saturday': {'morning': false, 'afternoon': false, 'evening': false},
|
||||
'sunday': {'morning': false, 'afternoon': false, 'evening': false},
|
||||
},
|
||||
));
|
||||
|
||||
static DateTime _getStartOfWeek(DateTime date) {
|
||||
final diff = date.weekday - 1; // Mon=1 -> 0
|
||||
final start = date.subtract(Duration(days: diff));
|
||||
return DateTime(start.year, start.month, start.day);
|
||||
}
|
||||
|
||||
void selectDate(DateTime date) {
|
||||
emit(state.copyWith(selectedDate: date));
|
||||
}
|
||||
|
||||
void navigateWeek(int weeks) {
|
||||
emit(state.copyWith(
|
||||
currentWeekStart: state.currentWeekStart.add(Duration(days: weeks * 7)),
|
||||
));
|
||||
}
|
||||
|
||||
void toggleDay(String dayKey) {
|
||||
final currentObj = Map<String, bool>.from(state.dayAvailability);
|
||||
currentObj[dayKey] = !(currentObj[dayKey] ?? false);
|
||||
emit(state.copyWith(dayAvailability: currentObj));
|
||||
}
|
||||
|
||||
void toggleSlot(String dayKey, String slotId) {
|
||||
final allSlots = Map<String, Map<String, bool>>.from(state.timeSlotAvailability);
|
||||
final daySlots = Map<String, bool>.from(allSlots[dayKey] ?? {});
|
||||
|
||||
// Default to true if missing, so we toggle to false
|
||||
final currentVal = daySlots[slotId] ?? true;
|
||||
daySlots[slotId] = !currentVal;
|
||||
|
||||
allSlots[dayKey] = daySlots;
|
||||
emit(state.copyWith(timeSlotAvailability: allSlots));
|
||||
}
|
||||
|
||||
void quickSet(String type) {
|
||||
final newAvailability = <String, bool>{};
|
||||
final days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||
|
||||
switch (type) {
|
||||
case 'all':
|
||||
for (var d in days) {
|
||||
newAvailability[d] = true;
|
||||
}
|
||||
break;
|
||||
case 'weekdays':
|
||||
for (var d in days) {
|
||||
newAvailability[d] = (d != 'saturday' && d != 'sunday');
|
||||
}
|
||||
break;
|
||||
case 'weekends':
|
||||
for (var d in days) {
|
||||
newAvailability[d] = (d == 'saturday' || d == 'sunday');
|
||||
}
|
||||
break;
|
||||
case 'clear':
|
||||
for (var d in days) {
|
||||
newAvailability[d] = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
emit(state.copyWith(dayAvailability: newAvailability));
|
||||
}
|
||||
}
|
||||
@@ -1,54 +1,89 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Base class for availability events.
|
||||
abstract class AvailabilityEvent extends Equatable {
|
||||
/// Creates an [AvailabilityEvent].
|
||||
const AvailabilityEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
/// Requests loading availability for a given week.
|
||||
class LoadAvailability extends AvailabilityEvent {
|
||||
final DateTime weekStart;
|
||||
final DateTime? preselectedDate; // Maintain selection after reload
|
||||
|
||||
/// Creates a [LoadAvailability] event.
|
||||
const LoadAvailability(this.weekStart, {this.preselectedDate});
|
||||
|
||||
|
||||
/// The Monday of the week to load.
|
||||
final DateTime weekStart;
|
||||
|
||||
/// Optional date to pre-select after loading.
|
||||
final DateTime? preselectedDate;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [weekStart, preselectedDate];
|
||||
List<Object?> get props => <Object?>[weekStart, preselectedDate];
|
||||
}
|
||||
|
||||
/// User selected a date in the week strip.
|
||||
class SelectDate extends AvailabilityEvent {
|
||||
final DateTime date;
|
||||
/// Creates a [SelectDate] event.
|
||||
const SelectDate(this.date);
|
||||
|
||||
/// The selected date.
|
||||
final DateTime date;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [date];
|
||||
List<Object?> get props => <Object?>[date];
|
||||
}
|
||||
|
||||
/// Toggles the overall availability status of a day.
|
||||
class ToggleDayStatus extends AvailabilityEvent {
|
||||
final DayAvailability day;
|
||||
/// Creates a [ToggleDayStatus] event.
|
||||
const ToggleDayStatus(this.day);
|
||||
|
||||
/// The day to toggle.
|
||||
final AvailabilityDay day;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [day];
|
||||
List<Object?> get props => <Object?>[day];
|
||||
}
|
||||
|
||||
/// Toggles an individual time slot within a day.
|
||||
class ToggleSlotStatus extends AvailabilityEvent {
|
||||
final DayAvailability day;
|
||||
final String slotId;
|
||||
const ToggleSlotStatus(this.day, this.slotId);
|
||||
/// Creates a [ToggleSlotStatus] event.
|
||||
const ToggleSlotStatus(this.day, this.slotIndex);
|
||||
|
||||
/// The parent day.
|
||||
final AvailabilityDay day;
|
||||
|
||||
/// Index of the slot to toggle within [day.slots].
|
||||
final int slotIndex;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [day, slotId];
|
||||
List<Object?> get props => <Object?>[day, slotIndex];
|
||||
}
|
||||
|
||||
/// Navigates forward or backward by one week.
|
||||
class NavigateWeek extends AvailabilityEvent {
|
||||
final int direction; // -1 or 1
|
||||
/// Creates a [NavigateWeek] event.
|
||||
const NavigateWeek(this.direction);
|
||||
|
||||
/// -1 for previous week, 1 for next week.
|
||||
final int direction;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [direction];
|
||||
List<Object?> get props => <Object?>[direction];
|
||||
}
|
||||
|
||||
/// Applies a quick-set preset to the current week.
|
||||
class PerformQuickSet extends AvailabilityEvent {
|
||||
final String type; // all, weekdays, weekends, clear
|
||||
/// Creates a [PerformQuickSet] event.
|
||||
const PerformQuickSet(this.type);
|
||||
|
||||
/// One of: `all`, `weekdays`, `weekends`, `clear`.
|
||||
final String type;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [type];
|
||||
List<Object?> get props => <Object?>[type];
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Base class for availability states.
|
||||
abstract class AvailabilityState extends Equatable {
|
||||
/// Creates an [AvailabilityState].
|
||||
const AvailabilityState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
/// Initial state before any data is loaded.
|
||||
class AvailabilityInitial extends AvailabilityState {}
|
||||
|
||||
/// Loading state while fetching availability data.
|
||||
class AvailabilityLoading extends AvailabilityState {}
|
||||
|
||||
/// State when availability data has been loaded.
|
||||
class AvailabilityLoaded extends AvailabilityState {
|
||||
final List<DayAvailability> days;
|
||||
final DateTime currentWeekStart;
|
||||
final DateTime selectedDate;
|
||||
final bool isActionInProgress;
|
||||
final String? successMessage;
|
||||
|
||||
/// Creates an [AvailabilityLoaded] state.
|
||||
const AvailabilityLoaded({
|
||||
required this.days,
|
||||
required this.currentWeekStart,
|
||||
@@ -26,20 +27,41 @@ class AvailabilityLoaded extends AvailabilityState {
|
||||
this.successMessage,
|
||||
});
|
||||
|
||||
/// Helper to get the currently selected day's availability object
|
||||
DayAvailability get selectedDayAvailability {
|
||||
/// The list of daily availability entries for the current week.
|
||||
final List<AvailabilityDay> days;
|
||||
|
||||
/// The Monday of the currently displayed week.
|
||||
final DateTime currentWeekStart;
|
||||
|
||||
/// The currently selected date in the week strip.
|
||||
final DateTime selectedDate;
|
||||
|
||||
/// Whether a background action (update/quick-set) is in progress.
|
||||
final bool isActionInProgress;
|
||||
|
||||
/// Optional success message for snackbar feedback.
|
||||
final String? successMessage;
|
||||
|
||||
/// The [AvailabilityDay] matching the current [selectedDate].
|
||||
AvailabilityDay get selectedDayAvailability {
|
||||
final String selectedIso = _toIsoDate(selectedDate);
|
||||
return days.firstWhere(
|
||||
(d) => isSameDay(d.date, selectedDate),
|
||||
orElse: () => DayAvailability(date: selectedDate), // Fallback
|
||||
(AvailabilityDay d) => d.date == selectedIso,
|
||||
orElse: () => AvailabilityDay(
|
||||
date: selectedIso,
|
||||
dayOfWeek: selectedDate.weekday % 7,
|
||||
availabilityStatus: AvailabilityStatus.unavailable,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a copy with optionally replaced fields.
|
||||
AvailabilityLoaded copyWith({
|
||||
List<DayAvailability>? days,
|
||||
List<AvailabilityDay>? days,
|
||||
DateTime? currentWeekStart,
|
||||
DateTime? selectedDate,
|
||||
bool? isActionInProgress,
|
||||
String? successMessage, // Nullable override
|
||||
String? successMessage,
|
||||
bool clearSuccessMessage = false,
|
||||
}) {
|
||||
return AvailabilityLoaded(
|
||||
@@ -47,21 +69,41 @@ class AvailabilityLoaded extends AvailabilityState {
|
||||
currentWeekStart: currentWeekStart ?? this.currentWeekStart,
|
||||
selectedDate: selectedDate ?? this.selectedDate,
|
||||
isActionInProgress: isActionInProgress ?? this.isActionInProgress,
|
||||
successMessage: clearSuccessMessage ? null : (successMessage ?? this.successMessage),
|
||||
successMessage:
|
||||
clearSuccessMessage ? null : (successMessage ?? this.successMessage),
|
||||
);
|
||||
}
|
||||
|
||||
/// Checks whether two [DateTime]s represent the same calendar day.
|
||||
static bool isSameDay(DateTime a, DateTime b) {
|
||||
return a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
}
|
||||
|
||||
/// Formats a [DateTime] as `YYYY-MM-DD`.
|
||||
static String _toIsoDate(DateTime date) {
|
||||
return '${date.year.toString().padLeft(4, '0')}-'
|
||||
'${date.month.toString().padLeft(2, '0')}-'
|
||||
'${date.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [days, currentWeekStart, selectedDate, isActionInProgress, successMessage];
|
||||
List<Object?> get props => <Object?>[
|
||||
days,
|
||||
currentWeekStart,
|
||||
selectedDate,
|
||||
isActionInProgress,
|
||||
successMessage,
|
||||
];
|
||||
}
|
||||
|
||||
/// Error state when availability loading or an action fails.
|
||||
class AvailabilityError extends AvailabilityState {
|
||||
final String message;
|
||||
/// Creates an [AvailabilityError] state.
|
||||
const AvailabilityError(this.message);
|
||||
|
||||
/// Error key for localisation.
|
||||
final String message;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
List<Object?> get props => <Object?>[message];
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@ import 'package:flutter_modular/flutter_modular.dart'
|
||||
hide ModularWatchExtension;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:staff_availability/src/presentation/blocs/availability_bloc.dart';
|
||||
import 'package:staff_availability/src/presentation/blocs/availability_event.dart';
|
||||
import 'package:staff_availability/src/presentation/blocs/availability_state.dart';
|
||||
import 'package:staff_availability/src/presentation/widgets/availability_page_skeleton/availability_page_skeleton.dart';
|
||||
|
||||
import '../blocs/availability_bloc.dart';
|
||||
import '../blocs/availability_event.dart';
|
||||
import '../blocs/availability_state.dart';
|
||||
import '../widgets/availability_page_skeleton/availability_page_skeleton.dart';
|
||||
|
||||
/// Page for managing staff weekly availability.
|
||||
class AvailabilityPage extends StatefulWidget {
|
||||
/// Creates an [AvailabilityPage].
|
||||
const AvailabilityPage({super.key});
|
||||
|
||||
@override
|
||||
@@ -28,10 +29,10 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
_calculateInitialWeek();
|
||||
}
|
||||
|
||||
/// Computes the Monday of the current week and triggers initial load.
|
||||
void _calculateInitialWeek() {
|
||||
final today = DateTime.now();
|
||||
final day = today.weekday; // Mon=1, Sun=7
|
||||
final diff = day - 1; // Assuming Monday start
|
||||
final DateTime today = DateTime.now();
|
||||
final int diff = today.weekday - 1;
|
||||
DateTime currentWeekStart = today.subtract(Duration(days: diff));
|
||||
currentWeekStart = DateTime(
|
||||
currentWeekStart.year,
|
||||
@@ -43,25 +44,25 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i18n = Translations.of(context).staff.availability;
|
||||
return BlocProvider.value(
|
||||
final dynamic i18n = Translations.of(context).staff.availability;
|
||||
return BlocProvider<AvailabilityBloc>.value(
|
||||
value: _bloc,
|
||||
child: Scaffold(
|
||||
appBar: UiAppBar(
|
||||
title: i18n.title,
|
||||
title: i18n.title as String,
|
||||
centerTitle: false,
|
||||
showBackButton: true,
|
||||
),
|
||||
body: BlocListener<AvailabilityBloc, AvailabilityState>(
|
||||
listener: (context, state) {
|
||||
if (state is AvailabilityLoaded && state.successMessage != null) {
|
||||
listener: (BuildContext context, AvailabilityState state) {
|
||||
if (state is AvailabilityLoaded &&
|
||||
state.successMessage != null) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: state.successMessage!,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
}
|
||||
|
||||
if (state is AvailabilityError) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
@@ -71,59 +72,19 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<AvailabilityBloc, AvailabilityState>(
|
||||
builder: (context, state) {
|
||||
builder: (BuildContext context, AvailabilityState state) {
|
||||
if (state is AvailabilityLoading) {
|
||||
return const AvailabilityPageSkeleton();
|
||||
} else if (state is AvailabilityLoaded) {
|
||||
return Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 100),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space5,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: UiConstants.space6,
|
||||
children: [
|
||||
_buildQuickSet(context),
|
||||
_buildWeekNavigation(context, state),
|
||||
_buildSelectedDayAvailability(
|
||||
context,
|
||||
state.selectedDayAvailability,
|
||||
),
|
||||
_buildInfoCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (state.isActionInProgress)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: UiColors.white.withValues(alpha: 0.5),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
return _buildLoaded(context, state);
|
||||
} else if (state is AvailabilityError) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
translateErrorKey(state.message),
|
||||
textAlign: TextAlign.center,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
],
|
||||
child: Text(
|
||||
translateErrorKey(state.message),
|
||||
textAlign: TextAlign.center,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -136,8 +97,48 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoaded(BuildContext context, AvailabilityLoaded state) {
|
||||
return Stack(
|
||||
children: <Widget>[
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 100),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space5,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
_buildQuickSet(context),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
_buildWeekNavigation(context, state),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
_buildSelectedDayAvailability(
|
||||
context,
|
||||
state.selectedDayAvailability,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
_buildInfoCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.isActionInProgress)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: UiColors.white.withValues(alpha: 0.5),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ── Quick Set Section ─────────────────────────────────────────────────
|
||||
|
||||
Widget _buildQuickSet(BuildContext context) {
|
||||
final i18n = Translations.of(context).staff.availability;
|
||||
final dynamic i18n = Translations.of(context).staff.availability;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
@@ -146,30 +147,39 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
i18n.quick_set_title,
|
||||
style: UiTypography.body2b,
|
||||
),
|
||||
children: <Widget>[
|
||||
Text(i18n.quick_set_title as String, style: UiTypography.body2b),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Row(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: _buildQuickSetButton(context, i18n.all_week, 'all'),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: _buildQuickSetButton(context, i18n.weekdays, 'weekdays'),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: _buildQuickSetButton(context, i18n.weekends, 'weekends'),
|
||||
child: _buildQuickSetButton(
|
||||
context,
|
||||
i18n.all_week as String,
|
||||
'all',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: _buildQuickSetButton(
|
||||
context,
|
||||
i18n.clear_all,
|
||||
i18n.weekdays as String,
|
||||
'weekdays',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: _buildQuickSetButton(
|
||||
context,
|
||||
i18n.weekends as String,
|
||||
'weekends',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: _buildQuickSetButton(
|
||||
context,
|
||||
i18n.clear_all as String,
|
||||
'clear',
|
||||
isDestructive: true,
|
||||
),
|
||||
@@ -203,9 +213,8 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
foregroundColor: isDestructive
|
||||
? UiColors.destructive
|
||||
: UiColors.primary,
|
||||
foregroundColor:
|
||||
isDestructive ? UiColors.destructive : UiColors.primary,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
@@ -217,10 +226,15 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWeekNavigation(BuildContext context, AvailabilityLoaded state) {
|
||||
// Middle date for month display
|
||||
final middleDate = state.currentWeekStart.add(const Duration(days: 3));
|
||||
final monthYear = DateFormat('MMMM yyyy').format(middleDate);
|
||||
// ── Week Navigation ───────────────────────────────────────────────────
|
||||
|
||||
Widget _buildWeekNavigation(
|
||||
BuildContext context,
|
||||
AvailabilityLoaded state,
|
||||
) {
|
||||
final DateTime middleDate =
|
||||
state.currentWeekStart.add(const Duration(days: 3));
|
||||
final String monthYear = DateFormat('MMMM yyyy').format(middleDate);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
@@ -230,37 +244,33 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Nav Header
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
_buildNavButton(
|
||||
UiIcons.chevronLeft,
|
||||
() => context.read<AvailabilityBloc>().add(
|
||||
const NavigateWeek(-1),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
monthYear,
|
||||
style: UiTypography.title2b,
|
||||
const NavigateWeek(-1),
|
||||
),
|
||||
),
|
||||
Text(monthYear, style: UiTypography.title2b),
|
||||
_buildNavButton(
|
||||
UiIcons.chevronRight,
|
||||
() => context.read<AvailabilityBloc>().add(
|
||||
const NavigateWeek(1),
|
||||
),
|
||||
const NavigateWeek(1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Days Row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: state.days
|
||||
.map((day) => _buildDayItem(context, day, state.selectedDate))
|
||||
.map((AvailabilityDay day) =>
|
||||
_buildDayItem(context, day, state.selectedDate))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
@@ -285,16 +295,19 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
|
||||
Widget _buildDayItem(
|
||||
BuildContext context,
|
||||
DayAvailability day,
|
||||
AvailabilityDay day,
|
||||
DateTime selectedDate,
|
||||
) {
|
||||
final isSelected = AvailabilityLoaded.isSameDay(day.date, selectedDate);
|
||||
final isAvailable = day.isAvailable;
|
||||
final isToday = AvailabilityLoaded.isSameDay(day.date, DateTime.now());
|
||||
final DateTime dayDate = DateTime.parse(day.date);
|
||||
final bool isSelected = AvailabilityLoaded.isSameDay(dayDate, selectedDate);
|
||||
final bool isAvailable = day.isAvailable;
|
||||
final bool isToday =
|
||||
AvailabilityLoaded.isSameDay(dayDate, DateTime.now());
|
||||
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => context.read<AvailabilityBloc>().add(SelectDate(day.date)),
|
||||
onTap: () =>
|
||||
context.read<AvailabilityBloc>().add(SelectDate(dayDate)),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||
padding: const EdgeInsets.symmetric(vertical: UiConstants.space3),
|
||||
@@ -314,11 +327,11 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Column(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Text(
|
||||
day.date.day.toString().padLeft(2, '0'),
|
||||
dayDate.day.toString().padLeft(2, '0'),
|
||||
style: UiTypography.title1m.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isSelected
|
||||
@@ -330,7 +343,7 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
DateFormat('EEE').format(day.date),
|
||||
DateFormat('EEE').format(dayDate),
|
||||
style: UiTypography.footnote2r.copyWith(
|
||||
color: isSelected
|
||||
? UiColors.white.withValues(alpha: 0.8)
|
||||
@@ -360,12 +373,15 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Selected Day Detail ───────────────────────────────────────────────
|
||||
|
||||
Widget _buildSelectedDayAvailability(
|
||||
BuildContext context,
|
||||
DayAvailability day,
|
||||
AvailabilityDay day,
|
||||
) {
|
||||
final dateStr = DateFormat('EEEE, MMM d').format(day.date);
|
||||
final isAvailable = day.isAvailable;
|
||||
final DateTime dayDate = DateTime.parse(day.date);
|
||||
final String dateStr = DateFormat('EEEE, MMM d').format(dayDate);
|
||||
final bool isAvailable = day.isAvailable;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
@@ -375,18 +391,14 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header Row
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
dateStr,
|
||||
style: UiTypography.title2b,
|
||||
),
|
||||
children: <Widget>[
|
||||
Text(dateStr, style: UiTypography.title2b),
|
||||
Text(
|
||||
isAvailable
|
||||
? Translations.of(context)
|
||||
@@ -403,94 +415,54 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
),
|
||||
Switch(
|
||||
value: isAvailable,
|
||||
onChanged: (val) =>
|
||||
context.read<AvailabilityBloc>().add(ToggleDayStatus(day)),
|
||||
onChanged: (bool val) => context
|
||||
.read<AvailabilityBloc>()
|
||||
.add(ToggleDayStatus(day)),
|
||||
activeThumbColor: UiColors.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Time Slots (only from Domain)
|
||||
...day.slots.map((slot) {
|
||||
// Get UI config for this slot ID
|
||||
final uiConfig = _getSlotUiConfig(slot.id);
|
||||
|
||||
return _buildTimeSlotItem(context, day, slot, uiConfig);
|
||||
...day.slots.asMap().entries.map((MapEntry<int, TimeSlot> entry) {
|
||||
final int index = entry.key;
|
||||
final TimeSlot slot = entry.value;
|
||||
return _buildTimeSlotItem(context, day, slot, index);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _getSlotUiConfig(String slotId) {
|
||||
switch (slotId) {
|
||||
case 'morning':
|
||||
return {
|
||||
'icon': UiIcons.sunrise,
|
||||
'bg': UiColors.primary.withValues(alpha: 0.1),
|
||||
'iconColor': UiColors.primary,
|
||||
};
|
||||
case 'afternoon':
|
||||
return {
|
||||
'icon': UiIcons.sun,
|
||||
'bg': UiColors.primary.withValues(alpha: 0.2),
|
||||
'iconColor': UiColors.primary,
|
||||
};
|
||||
case 'evening':
|
||||
return {
|
||||
'icon': UiIcons.moon,
|
||||
'bg': UiColors.bgSecondary,
|
||||
'iconColor': UiColors.foreground,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
'icon': UiIcons.clock,
|
||||
'bg': UiColors.bgSecondary,
|
||||
'iconColor': UiColors.iconSecondary,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildTimeSlotItem(
|
||||
BuildContext context,
|
||||
DayAvailability day,
|
||||
AvailabilitySlot slot,
|
||||
Map<String, dynamic> uiConfig,
|
||||
AvailabilityDay day,
|
||||
TimeSlot slot,
|
||||
int index,
|
||||
) {
|
||||
// Determine styles based on state
|
||||
final isEnabled = day.isAvailable;
|
||||
final isActive = slot.isAvailable;
|
||||
final bool isEnabled = day.isAvailable;
|
||||
final Map<String, dynamic> uiConfig = _getSlotUiConfig(slot);
|
||||
|
||||
// Container style
|
||||
Color bgColor;
|
||||
Color borderColor;
|
||||
|
||||
if (!isEnabled) {
|
||||
bgColor = UiColors.bgSecondary;
|
||||
borderColor = UiColors.borderInactive;
|
||||
} else if (isActive) {
|
||||
} else {
|
||||
bgColor = UiColors.primary.withValues(alpha: 0.05);
|
||||
borderColor = UiColors.primary.withValues(alpha: 0.2);
|
||||
} else {
|
||||
bgColor = UiColors.bgSecondary;
|
||||
borderColor = UiColors.borderPrimary;
|
||||
}
|
||||
|
||||
// Text colors
|
||||
final titleColor = (isEnabled && isActive)
|
||||
? UiColors.foreground
|
||||
: UiColors.mutedForeground;
|
||||
final subtitleColor = (isEnabled && isActive)
|
||||
? UiColors.mutedForeground
|
||||
: UiColors.textInactive;
|
||||
final Color titleColor =
|
||||
isEnabled ? UiColors.foreground : UiColors.mutedForeground;
|
||||
final Color subtitleColor =
|
||||
isEnabled ? UiColors.mutedForeground : UiColors.textInactive;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: isEnabled
|
||||
? () => context.read<AvailabilityBloc>().add(
|
||||
ToggleSlotStatus(day, slot.id),
|
||||
)
|
||||
? () => context
|
||||
.read<AvailabilityBloc>()
|
||||
.add(ToggleSlotStatus(day, index))
|
||||
: null,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
@@ -502,40 +474,38 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
border: Border.all(color: borderColor, width: 2),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Icon
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: uiConfig['bg'],
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
color: uiConfig['bg'] as Color,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UiConstants.radiusBase),
|
||||
),
|
||||
child: Icon(
|
||||
uiConfig['icon'],
|
||||
color: uiConfig['iconColor'],
|
||||
uiConfig['icon'] as IconData,
|
||||
color: uiConfig['iconColor'] as Color,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
// Text
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Text(
|
||||
slot.label,
|
||||
'${slot.startTime} - ${slot.endTime}',
|
||||
style: UiTypography.body2m.copyWith(color: titleColor),
|
||||
),
|
||||
Text(
|
||||
slot.timeRange,
|
||||
_slotPeriodLabel(slot),
|
||||
style: UiTypography.body3r.copyWith(color: subtitleColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Checkbox indicator
|
||||
if (isEnabled && isActive)
|
||||
if (isEnabled)
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
@@ -548,18 +518,6 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
size: 16,
|
||||
color: UiColors.white,
|
||||
),
|
||||
)
|
||||
else if (isEnabled && !isActive)
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: UiColors.borderStill,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -567,8 +525,48 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns UI config (icon, bg, iconColor) based on time slot hours.
|
||||
Map<String, dynamic> _getSlotUiConfig(TimeSlot slot) {
|
||||
final int hour = _parseHour(slot.startTime);
|
||||
if (hour < 12) {
|
||||
return <String, dynamic>{
|
||||
'icon': UiIcons.sunrise,
|
||||
'bg': UiColors.primary.withValues(alpha: 0.1),
|
||||
'iconColor': UiColors.primary,
|
||||
};
|
||||
} else if (hour < 17) {
|
||||
return <String, dynamic>{
|
||||
'icon': UiIcons.sun,
|
||||
'bg': UiColors.primary.withValues(alpha: 0.2),
|
||||
'iconColor': UiColors.primary,
|
||||
};
|
||||
} else {
|
||||
return <String, dynamic>{
|
||||
'icon': UiIcons.moon,
|
||||
'bg': UiColors.bgSecondary,
|
||||
'iconColor': UiColors.foreground,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the hour from an `HH:MM` string.
|
||||
int _parseHour(String time) {
|
||||
final List<String> parts = time.split(':');
|
||||
return int.tryParse(parts.first) ?? 0;
|
||||
}
|
||||
|
||||
/// Returns a human-readable period label for a slot.
|
||||
String _slotPeriodLabel(TimeSlot slot) {
|
||||
final int hour = _parseHour(slot.startTime);
|
||||
if (hour < 12) return 'Morning';
|
||||
if (hour < 17) return 'Afternoon';
|
||||
return 'Evening';
|
||||
}
|
||||
|
||||
// ── Info Card ─────────────────────────────────────────────────────────
|
||||
|
||||
Widget _buildInfoCard() {
|
||||
final i18n = Translations.of(context).staff.availability;
|
||||
final dynamic i18n = Translations.of(context).staff.availability;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
@@ -577,20 +575,20 @@ class _AvailabilityPageState extends State<AvailabilityPage> {
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space3,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.clock, size: 20, color: UiColors.primary),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space1,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Text(
|
||||
i18n.auto_match_title,
|
||||
i18n.auto_match_title as String,
|
||||
style: UiTypography.body2m,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
i18n.auto_match_description,
|
||||
i18n.auto_match_description as String,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,31 +1,49 @@
|
||||
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 'package:staff_availability/src/data/repositories_impl/availability_repository_impl.dart';
|
||||
import 'package:staff_availability/src/domain/repositories/availability_repository.dart';
|
||||
import 'package:staff_availability/src/domain/usecases/apply_quick_set_usecase.dart';
|
||||
import 'package:staff_availability/src/domain/usecases/get_weekly_availability_usecase.dart';
|
||||
import 'package:staff_availability/src/domain/usecases/update_day_availability_usecase.dart';
|
||||
import 'package:staff_availability/src/presentation/blocs/availability_bloc.dart';
|
||||
import 'package:staff_availability/src/presentation/pages/availability_page.dart';
|
||||
|
||||
import 'data/repositories_impl/availability_repository_impl.dart';
|
||||
import 'domain/repositories/availability_repository.dart';
|
||||
import 'domain/usecases/apply_quick_set_usecase.dart';
|
||||
import 'domain/usecases/get_weekly_availability_usecase.dart';
|
||||
import 'domain/usecases/update_day_availability_usecase.dart';
|
||||
import 'presentation/blocs/availability_bloc.dart';
|
||||
|
||||
/// Module for the staff availability feature.
|
||||
///
|
||||
/// Uses the V2 REST API via [BaseApiService] for all backend access.
|
||||
class StaffAvailabilityModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => [DataConnectModule()];
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repository
|
||||
i.addLazySingleton<AvailabilityRepository>(AvailabilityRepositoryImpl.new);
|
||||
// Repository — V2 API
|
||||
i.addLazySingleton<AvailabilityRepository>(
|
||||
() => AvailabilityRepositoryImpl(
|
||||
apiService: i.get<BaseApiService>(),
|
||||
),
|
||||
);
|
||||
|
||||
// UseCases
|
||||
i.addLazySingleton(GetWeeklyAvailabilityUseCase.new);
|
||||
i.addLazySingleton(UpdateDayAvailabilityUseCase.new);
|
||||
i.addLazySingleton(ApplyQuickSetUseCase.new);
|
||||
// Use cases
|
||||
i.addLazySingleton<GetWeeklyAvailabilityUseCase>(
|
||||
() => GetWeeklyAvailabilityUseCase(i.get<AvailabilityRepository>()),
|
||||
);
|
||||
i.addLazySingleton<UpdateDayAvailabilityUseCase>(
|
||||
() => UpdateDayAvailabilityUseCase(i.get<AvailabilityRepository>()),
|
||||
);
|
||||
i.addLazySingleton<ApplyQuickSetUseCase>(
|
||||
() => ApplyQuickSetUseCase(i.get<AvailabilityRepository>()),
|
||||
);
|
||||
|
||||
// BLoC
|
||||
i.add(AvailabilityBloc.new);
|
||||
i.add<AvailabilityBloc>(
|
||||
() => AvailabilityBloc(
|
||||
getWeeklyAvailability: i.get<GetWeeklyAvailabilityUseCase>(),
|
||||
updateDayAvailability: i.get<UpdateDayAvailabilityUseCase>(),
|
||||
applyQuickSet: i.get<ApplyQuickSetUseCase>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -19,8 +19,6 @@ dependencies:
|
||||
path: ../../../design_system
|
||||
krow_domain:
|
||||
path: ../../../domain
|
||||
krow_data_connect:
|
||||
path: ../../../data_connect
|
||||
krow_core:
|
||||
path: ../../../core
|
||||
|
||||
@@ -28,8 +26,6 @@ dependencies:
|
||||
equatable: ^2.0.5
|
||||
intl: ^0.20.0
|
||||
flutter_modular: ^6.3.2
|
||||
firebase_data_connect: ^0.2.2+2
|
||||
firebase_auth: ^6.1.4
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -1,235 +1,99 @@
|
||||
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_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../domain/repositories/clock_in_repository_interface.dart';
|
||||
import 'package:staff_clock_in/src/domain/repositories/clock_in_repository_interface.dart';
|
||||
|
||||
/// Implementation of [ClockInRepositoryInterface] using Firebase Data Connect.
|
||||
/// Implementation of [ClockInRepositoryInterface] using the V2 REST API.
|
||||
///
|
||||
/// All backend calls go through [BaseApiService] with [V2ApiEndpoints].
|
||||
/// The old Data Connect implementation has been removed.
|
||||
class ClockInRepositoryImpl implements ClockInRepositoryInterface {
|
||||
ClockInRepositoryImpl() : _service = dc.DataConnectService.instance;
|
||||
/// Creates a [ClockInRepositoryImpl] backed by the V2 API.
|
||||
ClockInRepositoryImpl({required BaseApiService apiService})
|
||||
: _apiService = apiService;
|
||||
|
||||
final dc.DataConnectService _service;
|
||||
final Map<String, String> _shiftToApplicationId = <String, String>{};
|
||||
String? _activeApplicationId;
|
||||
|
||||
({fdc.Timestamp start, fdc.Timestamp end}) _utcDayRange(DateTime localDay) {
|
||||
final DateTime dayStartUtc = DateTime.utc(
|
||||
localDay.year,
|
||||
localDay.month,
|
||||
localDay.day,
|
||||
);
|
||||
final DateTime dayEndUtc = DateTime.utc(
|
||||
localDay.year,
|
||||
localDay.month,
|
||||
localDay.day,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999,
|
||||
999,
|
||||
);
|
||||
return (
|
||||
start: _service.toTimestamp(dayStartUtc),
|
||||
end: _service.toTimestamp(dayEndUtc),
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper to find today's applications ordered with the closest at the end.
|
||||
Future<List<dc.GetApplicationsByStaffIdApplications>> _getTodaysApplications(
|
||||
String staffId,
|
||||
) async {
|
||||
final DateTime now = DateTime.now();
|
||||
final ({fdc.Timestamp start, fdc.Timestamp end}) range = _utcDayRange(now);
|
||||
final fdc.QueryResult<dc.GetApplicationsByStaffIdData,
|
||||
dc.GetApplicationsByStaffIdVariables> result = await _service.run(
|
||||
() => _service.connector
|
||||
.getApplicationsByStaffId(staffId: staffId)
|
||||
.dayStart(range.start)
|
||||
.dayEnd(range.end)
|
||||
.execute(),
|
||||
);
|
||||
|
||||
final List<dc.GetApplicationsByStaffIdApplications> apps =
|
||||
result.data.applications;
|
||||
if (apps.isEmpty) return const <dc.GetApplicationsByStaffIdApplications>[];
|
||||
|
||||
_shiftToApplicationId
|
||||
..clear()
|
||||
..addEntries(apps.map((dc.GetApplicationsByStaffIdApplications app) =>
|
||||
MapEntry<String, String>(app.shiftId, app.id)));
|
||||
|
||||
apps.sort((dc.GetApplicationsByStaffIdApplications a,
|
||||
dc.GetApplicationsByStaffIdApplications b) {
|
||||
final DateTime? aTime =
|
||||
_service.toDateTime(a.shift.startTime) ?? _service.toDateTime(a.shift.date);
|
||||
final DateTime? bTime =
|
||||
_service.toDateTime(b.shift.startTime) ?? _service.toDateTime(b.shift.date);
|
||||
if (aTime == null && bTime == null) return 0;
|
||||
if (aTime == null) return -1;
|
||||
if (bTime == null) return 1;
|
||||
final Duration aDiff = aTime.difference(now).abs();
|
||||
final Duration bDiff = bTime.difference(now).abs();
|
||||
return bDiff.compareTo(aDiff); // closest at the end
|
||||
});
|
||||
|
||||
return apps;
|
||||
}
|
||||
final BaseApiService _apiService;
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getTodaysShifts() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
final List<dc.GetApplicationsByStaffIdApplications> apps =
|
||||
await _getTodaysApplications(staffId);
|
||||
if (apps.isEmpty) return const <Shift>[];
|
||||
|
||||
final List<Shift> shifts = <Shift>[];
|
||||
for (final dc.GetApplicationsByStaffIdApplications app in apps) {
|
||||
final dc.GetApplicationsByStaffIdApplicationsShift shift = app.shift;
|
||||
final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime);
|
||||
final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime);
|
||||
final DateTime? createdDt = _service.toDateTime(app.createdAt);
|
||||
|
||||
final String roleName = app.shiftRole.role.name;
|
||||
final String orderName =
|
||||
(shift.order.eventName ?? '').trim().isNotEmpty
|
||||
? shift.order.eventName!
|
||||
: shift.order.business.businessName;
|
||||
final String title = '$roleName - $orderName';
|
||||
shifts.add(
|
||||
Shift(
|
||||
id: shift.id,
|
||||
title: title,
|
||||
clientName: shift.order.business.businessName,
|
||||
logoUrl: shift.order.business.companyLogoUrl ?? '',
|
||||
hourlyRate: app.shiftRole.role.costPerHour,
|
||||
location: shift.location ?? '',
|
||||
locationAddress: shift.order.teamHub.hubName,
|
||||
date: startDt?.toIso8601String() ?? '',
|
||||
startTime: startDt?.toIso8601String() ?? '',
|
||||
endTime: endDt?.toIso8601String() ?? '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: shift.status?.stringValue,
|
||||
description: shift.description,
|
||||
latitude: shift.latitude,
|
||||
longitude: shift.longitude,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return shifts;
|
||||
});
|
||||
final ApiResponse response = await _apiService.get(
|
||||
V2ApiEndpoints.staffClockInShiftsToday,
|
||||
);
|
||||
final List<dynamic> items = response.data['items'] as List<dynamic>;
|
||||
// TODO: Ask BE to add latitude, longitude, hourlyRate, and clientName
|
||||
// to the listTodayShifts query to avoid mapping gaps and extra API calls.
|
||||
return items
|
||||
.map(
|
||||
(dynamic json) =>
|
||||
_mapTodayShiftJsonToShift(json as Map<String, dynamic>),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AttendanceStatus> getAttendanceStatus() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
final List<dc.GetApplicationsByStaffIdApplications> apps =
|
||||
await _getTodaysApplications(staffId);
|
||||
if (apps.isEmpty) {
|
||||
return const AttendanceStatus(isCheckedIn: false);
|
||||
}
|
||||
|
||||
dc.GetApplicationsByStaffIdApplications? activeApp;
|
||||
for (final dc.GetApplicationsByStaffIdApplications app in apps) {
|
||||
if (app.checkInTime != null && app.checkOutTime == null) {
|
||||
if (activeApp == null) {
|
||||
activeApp = app;
|
||||
} else {
|
||||
final DateTime? current = _service.toDateTime(activeApp.checkInTime);
|
||||
final DateTime? next = _service.toDateTime(app.checkInTime);
|
||||
if (current == null || (next != null && next.isAfter(current))) {
|
||||
activeApp = app;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeApp == null) {
|
||||
_activeApplicationId = null;
|
||||
return const AttendanceStatus(isCheckedIn: false);
|
||||
}
|
||||
|
||||
_activeApplicationId = activeApp.id;
|
||||
|
||||
return AttendanceStatus(
|
||||
isCheckedIn: true,
|
||||
checkInTime: _service.toDateTime(activeApp.checkInTime),
|
||||
checkOutTime: _service.toDateTime(activeApp.checkOutTime),
|
||||
activeShiftId: activeApp.shiftId,
|
||||
activeApplicationId: activeApp.id,
|
||||
);
|
||||
});
|
||||
final ApiResponse response = await _apiService.get(
|
||||
V2ApiEndpoints.staffClockInStatus,
|
||||
);
|
||||
return AttendanceStatus.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AttendanceStatus> clockIn({required String shiftId, String? notes}) async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final String? cachedAppId = _shiftToApplicationId[shiftId];
|
||||
dc.GetApplicationsByStaffIdApplications? app;
|
||||
if (cachedAppId != null) {
|
||||
try {
|
||||
final List<dc.GetApplicationsByStaffIdApplications> apps =
|
||||
await _getTodaysApplications(staffId);
|
||||
app = apps.firstWhere(
|
||||
(dc.GetApplicationsByStaffIdApplications a) => a.id == cachedAppId);
|
||||
} catch (_) {}
|
||||
}
|
||||
app ??= (await _getTodaysApplications(staffId)).firstWhere(
|
||||
(dc.GetApplicationsByStaffIdApplications a) => a.shiftId == shiftId);
|
||||
|
||||
final fdc.Timestamp checkInTs = _service.toTimestamp(DateTime.now());
|
||||
|
||||
await _service.connector
|
||||
.updateApplicationStatus(
|
||||
id: app.id,
|
||||
)
|
||||
.checkInTime(checkInTs)
|
||||
.execute();
|
||||
_activeApplicationId = app.id;
|
||||
|
||||
return getAttendanceStatus();
|
||||
});
|
||||
Future<AttendanceStatus> clockIn({
|
||||
required String shiftId,
|
||||
String? notes,
|
||||
}) async {
|
||||
await _apiService.post(
|
||||
V2ApiEndpoints.staffClockIn,
|
||||
data: <String, dynamic>{
|
||||
'shiftId': shiftId,
|
||||
'sourceType': 'GEO',
|
||||
if (notes != null && notes.isNotEmpty) 'notes': notes,
|
||||
},
|
||||
);
|
||||
// Re-fetch the attendance status to get the canonical state after clock-in.
|
||||
return getAttendanceStatus();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AttendanceStatus> clockOut({
|
||||
String? notes,
|
||||
int? breakTimeMinutes,
|
||||
String? applicationId,
|
||||
String? shiftId,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
await _service.getStaffId(); // Validate session
|
||||
await _apiService.post(
|
||||
V2ApiEndpoints.staffClockOut,
|
||||
data: <String, dynamic>{
|
||||
if (shiftId != null) 'shiftId': shiftId,
|
||||
'sourceType': 'GEO',
|
||||
if (notes != null && notes.isNotEmpty) 'notes': notes,
|
||||
if (breakTimeMinutes != null) 'breakMinutes': breakTimeMinutes,
|
||||
},
|
||||
);
|
||||
// Re-fetch the attendance status to get the canonical state after clock-out.
|
||||
return getAttendanceStatus();
|
||||
}
|
||||
|
||||
final String? targetAppId = applicationId ?? _activeApplicationId;
|
||||
if (targetAppId == null || targetAppId.isEmpty) {
|
||||
throw Exception('No active application id for checkout');
|
||||
}
|
||||
final fdc.QueryResult<dc.GetApplicationByIdData,
|
||||
dc.GetApplicationByIdVariables> appResult =
|
||||
await _service.connector
|
||||
.getApplicationById(id: targetAppId)
|
||||
.execute();
|
||||
final dc.GetApplicationByIdApplication? app = appResult.data.application;
|
||||
|
||||
if (app == null) {
|
||||
throw Exception('Application not found for checkout');
|
||||
}
|
||||
if (app.checkInTime == null || app.checkOutTime != null) {
|
||||
throw Exception('No active shift found to clock out');
|
||||
}
|
||||
|
||||
await _service.connector
|
||||
.updateApplicationStatus(
|
||||
id: targetAppId,
|
||||
)
|
||||
.checkOutTime(_service.toTimestamp(DateTime.now()))
|
||||
.execute();
|
||||
|
||||
return getAttendanceStatus();
|
||||
});
|
||||
/// Maps a V2 `listTodayShifts` JSON item to the domain [Shift] entity.
|
||||
///
|
||||
/// The today-shifts endpoint returns a lightweight shape that lacks some
|
||||
/// [Shift] fields. Missing fields are defaulted:
|
||||
/// - `orderId` defaults to empty string
|
||||
/// - `latitude` / `longitude` default to null (disables geofence)
|
||||
/// - `requiredWorkers` / `assignedWorkers` default to 0
|
||||
// TODO: Ask BE to add latitude/longitude to the listTodayShifts query
|
||||
// to avoid losing geofence validation.
|
||||
static Shift _mapTodayShiftJsonToShift(Map<String, dynamic> json) {
|
||||
return Shift(
|
||||
id: json['shiftId'] as String,
|
||||
orderId: '',
|
||||
title: json['roleName'] as String? ?? '',
|
||||
status: ShiftStatus.fromJson(json['attendanceStatus'] as String?),
|
||||
startsAt: DateTime.parse(json['startTime'] as String),
|
||||
endsAt: DateTime.parse(json['endTime'] as String),
|
||||
locationName: json['location'] as String?,
|
||||
requiredWorkers: 0,
|
||||
assignedWorkers: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Represents the arguments required for the [ClockOutUseCase].
|
||||
/// Arguments required for the [ClockOutUseCase].
|
||||
class ClockOutArguments extends UseCaseArgument {
|
||||
|
||||
/// Creates a [ClockOutArguments] instance.
|
||||
const ClockOutArguments({
|
||||
this.notes,
|
||||
this.breakTimeMinutes,
|
||||
this.applicationId,
|
||||
this.shiftId,
|
||||
});
|
||||
|
||||
/// Optional notes provided by the user during clock-out.
|
||||
final String? notes;
|
||||
|
||||
/// Optional break time in minutes.
|
||||
final int? breakTimeMinutes;
|
||||
|
||||
/// Optional application id for checkout.
|
||||
final String? applicationId;
|
||||
/// The shift id used by the V2 API to resolve the assignment.
|
||||
final String? shiftId;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[notes, breakTimeMinutes, applicationId];
|
||||
List<Object?> get props => <Object?>[notes, breakTimeMinutes, shiftId];
|
||||
}
|
||||
|
||||
@@ -16,10 +16,12 @@ abstract class ClockInRepositoryInterface {
|
||||
Future<AttendanceStatus> clockIn({required String shiftId, String? notes});
|
||||
|
||||
/// Checks the user out for the currently active shift.
|
||||
/// Optionally accepts [breakTimeMinutes] if tracked.
|
||||
///
|
||||
/// The V2 API resolves the assignment from [shiftId]. Optionally accepts
|
||||
/// [breakTimeMinutes] if tracked.
|
||||
Future<AttendanceStatus> clockOut({
|
||||
String? notes,
|
||||
int? breakTimeMinutes,
|
||||
String? applicationId,
|
||||
String? shiftId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ class ClockOutUseCase implements UseCase<ClockOutArguments, AttendanceStatus> {
|
||||
return _repository.clockOut(
|
||||
notes: arguments.notes,
|
||||
breakTimeMinutes: arguments.breakTimeMinutes,
|
||||
applicationId: arguments.applicationId,
|
||||
shiftId: arguments.shiftId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,8 +177,8 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
// Build validation context from combined BLoC states.
|
||||
final ClockInValidationContext validationContext = ClockInValidationContext(
|
||||
isCheckingIn: true,
|
||||
shiftStartTime: _tryParseDateTime(shift?.startTime),
|
||||
shiftEndTime: _tryParseDateTime(shift?.endTime),
|
||||
shiftStartTime: shift?.startsAt,
|
||||
shiftEndTime: shift?.endsAt,
|
||||
hasCoordinates: hasCoordinates,
|
||||
isLocationVerified: geofenceState.isLocationVerified,
|
||||
isLocationTimedOut: geofenceState.isLocationTimedOut,
|
||||
@@ -237,7 +237,7 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
ClockOutArguments(
|
||||
notes: event.notes,
|
||||
breakTimeMinutes: event.breakTimeMinutes ?? 0,
|
||||
applicationId: state.attendance.activeApplicationId,
|
||||
shiftId: state.attendance.activeShiftId,
|
||||
),
|
||||
);
|
||||
emit(state.copyWith(
|
||||
@@ -299,12 +299,6 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
return super.close();
|
||||
}
|
||||
|
||||
/// Safely parses a time string into a [DateTime], returning `null` on failure.
|
||||
static DateTime? _tryParseDateTime(String? value) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
return DateTime.tryParse(value);
|
||||
}
|
||||
|
||||
/// Computes time-window check-in/check-out flags for the given [shift].
|
||||
///
|
||||
/// Uses [TimeWindowValidator] so this business logic stays out of widgets.
|
||||
@@ -314,37 +308,33 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
}
|
||||
|
||||
const TimeWindowValidator validator = TimeWindowValidator();
|
||||
final DateTime? shiftStart = _tryParseDateTime(shift.startTime);
|
||||
final DateTime? shiftEnd = _tryParseDateTime(shift.endTime);
|
||||
final DateTime shiftStart = shift.startsAt;
|
||||
final DateTime shiftEnd = shift.endsAt;
|
||||
|
||||
// Check-in window.
|
||||
bool isCheckInAllowed = true;
|
||||
String? checkInAvailabilityTime;
|
||||
if (shiftStart != null) {
|
||||
final ClockInValidationContext checkInCtx = ClockInValidationContext(
|
||||
isCheckingIn: true,
|
||||
shiftStartTime: shiftStart,
|
||||
);
|
||||
isCheckInAllowed = validator.validate(checkInCtx).isValid;
|
||||
if (!isCheckInAllowed) {
|
||||
checkInAvailabilityTime =
|
||||
TimeWindowValidator.getAvailabilityTime(shiftStart);
|
||||
}
|
||||
final ClockInValidationContext checkInCtx = ClockInValidationContext(
|
||||
isCheckingIn: true,
|
||||
shiftStartTime: shiftStart,
|
||||
);
|
||||
isCheckInAllowed = validator.validate(checkInCtx).isValid;
|
||||
if (!isCheckInAllowed) {
|
||||
checkInAvailabilityTime =
|
||||
TimeWindowValidator.getAvailabilityTime(shiftStart);
|
||||
}
|
||||
|
||||
// Check-out window.
|
||||
bool isCheckOutAllowed = true;
|
||||
String? checkOutAvailabilityTime;
|
||||
if (shiftEnd != null) {
|
||||
final ClockInValidationContext checkOutCtx = ClockInValidationContext(
|
||||
isCheckingIn: false,
|
||||
shiftEndTime: shiftEnd,
|
||||
);
|
||||
isCheckOutAllowed = validator.validate(checkOutCtx).isValid;
|
||||
if (!isCheckOutAllowed) {
|
||||
checkOutAvailabilityTime =
|
||||
TimeWindowValidator.getAvailabilityTime(shiftEnd);
|
||||
}
|
||||
final ClockInValidationContext checkOutCtx = ClockInValidationContext(
|
||||
isCheckingIn: false,
|
||||
shiftEndTime: shiftEnd,
|
||||
);
|
||||
isCheckOutAllowed = validator.validate(checkOutCtx).isValid;
|
||||
if (!isCheckOutAllowed) {
|
||||
checkOutAvailabilityTime =
|
||||
TimeWindowValidator.getAvailabilityTime(shiftEnd);
|
||||
}
|
||||
|
||||
return _TimeWindowFlags(
|
||||
|
||||
@@ -14,7 +14,9 @@ class ClockInState extends Equatable {
|
||||
this.status = ClockInStatus.initial,
|
||||
this.todayShifts = const <Shift>[],
|
||||
this.selectedShift,
|
||||
this.attendance = const AttendanceStatus(),
|
||||
this.attendance = const AttendanceStatus(
|
||||
attendanceStatus: AttendanceStatusType.notClockedIn,
|
||||
),
|
||||
required this.selectedDate,
|
||||
this.checkInMode = 'swipe',
|
||||
this.errorMessage,
|
||||
|
||||
@@ -31,7 +31,7 @@ class ClockInActionSection extends StatelessWidget {
|
||||
const ClockInActionSection({
|
||||
required this.selectedShift,
|
||||
required this.isCheckedIn,
|
||||
required this.checkOutTime,
|
||||
required this.hasCompletedShift,
|
||||
required this.checkInMode,
|
||||
required this.isActionInProgress,
|
||||
this.hasClockinError = false,
|
||||
@@ -55,8 +55,8 @@ class ClockInActionSection extends StatelessWidget {
|
||||
/// Whether the user is currently checked in for the active shift.
|
||||
final bool isCheckedIn;
|
||||
|
||||
/// The check-out time, or null if the user has not checked out.
|
||||
final DateTime? checkOutTime;
|
||||
/// Whether the shift has been completed (clocked out).
|
||||
final bool hasCompletedShift;
|
||||
|
||||
/// The current check-in mode (e.g. "swipe" or "nfc").
|
||||
final String checkInMode;
|
||||
@@ -87,15 +87,15 @@ class ClockInActionSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (selectedShift != null && checkOutTime == null) {
|
||||
return _buildActiveShiftAction(context);
|
||||
if (selectedShift == null) {
|
||||
return const NoShiftsBanner();
|
||||
}
|
||||
|
||||
if (selectedShift != null && checkOutTime != null) {
|
||||
if (hasCompletedShift) {
|
||||
return const ShiftCompletedBanner();
|
||||
}
|
||||
|
||||
return const NoShiftsBanner();
|
||||
return _buildActiveShiftAction(context);
|
||||
}
|
||||
|
||||
/// Builds the action widget for an active (not completed) shift.
|
||||
|
||||
@@ -66,12 +66,16 @@ class _ClockInBodyState extends State<ClockInBody> {
|
||||
final String? activeShiftId = state.attendance.activeShiftId;
|
||||
final bool isActiveSelected =
|
||||
selectedShift != null && selectedShift.id == activeShiftId;
|
||||
final DateTime? checkInTime =
|
||||
isActiveSelected ? state.attendance.checkInTime : null;
|
||||
final DateTime? checkOutTime =
|
||||
isActiveSelected ? state.attendance.checkOutTime : null;
|
||||
final bool isCheckedIn =
|
||||
state.attendance.isCheckedIn && isActiveSelected;
|
||||
final DateTime? clockInAt =
|
||||
isActiveSelected ? state.attendance.clockInAt : null;
|
||||
final bool isClockedIn =
|
||||
state.attendance.isClockedIn && isActiveSelected;
|
||||
// The V2 AttendanceStatus no longer carries checkOutTime.
|
||||
// A closed session means the worker already clocked out for
|
||||
// this shift, which the UI shows via ShiftCompletedBanner.
|
||||
final bool hasCompletedShift = isActiveSelected &&
|
||||
state.attendance.attendanceStatus ==
|
||||
AttendanceStatusType.closed;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -106,8 +110,8 @@ class _ClockInBodyState extends State<ClockInBody> {
|
||||
// action section (check-in/out buttons)
|
||||
ClockInActionSection(
|
||||
selectedShift: selectedShift,
|
||||
isCheckedIn: isCheckedIn,
|
||||
checkOutTime: checkOutTime,
|
||||
isCheckedIn: isClockedIn,
|
||||
hasCompletedShift: hasCompletedShift,
|
||||
checkInMode: state.checkInMode,
|
||||
isActionInProgress:
|
||||
state.status == ClockInStatus.actionInProgress,
|
||||
@@ -119,9 +123,9 @@ class _ClockInBodyState extends State<ClockInBody> {
|
||||
),
|
||||
|
||||
// checked-in banner (only when checked in to the selected shift)
|
||||
if (isCheckedIn && checkInTime != null) ...<Widget>[
|
||||
if (isClockedIn && clockInAt != null) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
CheckedInBanner(checkInTime: checkInTime),
|
||||
CheckedInBanner(checkInTime: clockInAt),
|
||||
],
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
],
|
||||
|
||||
@@ -67,21 +67,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
|
||||
|
||||
// For demo purposes, check if we're within 24 hours of shift
|
||||
final DateTime now = DateTime.now();
|
||||
DateTime shiftStart;
|
||||
try {
|
||||
// Try parsing startTime as full datetime first
|
||||
shiftStart = DateTime.parse(widget.shift!.startTime);
|
||||
} catch (_) {
|
||||
try {
|
||||
// Try parsing date as full datetime
|
||||
shiftStart = DateTime.parse(widget.shift!.date);
|
||||
} catch (_) {
|
||||
// Fall back to combining date and time
|
||||
shiftStart = DateTime.parse(
|
||||
'${widget.shift!.date} ${widget.shift!.startTime}',
|
||||
);
|
||||
}
|
||||
}
|
||||
final DateTime shiftStart = widget.shift!.startsAt;
|
||||
final int hoursUntilShift = shiftStart.difference(now).inHours;
|
||||
final bool inCommuteWindow = hoursUntilShift <= 24 && hoursUntilShift >= 0;
|
||||
|
||||
@@ -112,21 +98,7 @@ class _CommuteTrackerState extends State<CommuteTracker> {
|
||||
int _getMinutesUntilShift() {
|
||||
if (widget.shift == null) return 0;
|
||||
final DateTime now = DateTime.now();
|
||||
DateTime shiftStart;
|
||||
try {
|
||||
// Try parsing startTime as full datetime first
|
||||
shiftStart = DateTime.parse(widget.shift!.startTime);
|
||||
} catch (_) {
|
||||
try {
|
||||
// Try parsing date as full datetime
|
||||
shiftStart = DateTime.parse(widget.shift!.date);
|
||||
} catch (_) {
|
||||
// Fall back to combining date and time
|
||||
shiftStart = DateTime.parse(
|
||||
'${widget.shift!.date} ${widget.shift!.startTime}',
|
||||
);
|
||||
}
|
||||
}
|
||||
final DateTime shiftStart = widget.shift!.startsAt;
|
||||
return shiftStart.difference(now).inMinutes;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
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';
|
||||
|
||||
import 'package:krow_core/core.dart' show formatTime;
|
||||
|
||||
/// A selectable card that displays a single shift's summary information.
|
||||
///
|
||||
/// Shows the shift title, client/location, time range, and hourly rate.
|
||||
/// Shows the shift title, location, and time range.
|
||||
/// Highlights with a primary border when [isSelected] is true.
|
||||
class ShiftCard extends StatelessWidget {
|
||||
/// Creates a shift card for the given [shift].
|
||||
@@ -50,7 +49,7 @@ class ShiftCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Expanded(child: _ShiftDetails(shift: shift, isSelected: isSelected, i18n: i18n)),
|
||||
_ShiftTimeAndRate(shift: shift),
|
||||
_ShiftTimeRange(shift: shift),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -58,7 +57,7 @@ class ShiftCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays the shift title, client name, and location on the left side.
|
||||
/// Displays the shift title and location on the left side.
|
||||
class _ShiftDetails extends StatelessWidget {
|
||||
const _ShiftDetails({
|
||||
required this.shift,
|
||||
@@ -88,8 +87,10 @@ class _ShiftDetails extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(shift.title, style: UiTypography.body2b),
|
||||
// TODO: Ask BE to add clientName to the listTodayShifts response.
|
||||
// Currently showing locationName as subtitle fallback.
|
||||
Text(
|
||||
'${shift.clientName} ${shift.location}',
|
||||
shift.locationName ?? '',
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
@@ -97,30 +98,26 @@ class _ShiftDetails extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays the shift time range and hourly rate on the right side.
|
||||
class _ShiftTimeAndRate extends StatelessWidget {
|
||||
const _ShiftTimeAndRate({required this.shift});
|
||||
/// Displays the shift time range on the right side.
|
||||
class _ShiftTimeRange extends StatelessWidget {
|
||||
const _ShiftTimeRange({required this.shift});
|
||||
|
||||
/// The shift whose time and rate to display.
|
||||
/// The shift whose time to display.
|
||||
final Shift shift;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffClockInEn i18n = Translations.of(
|
||||
context,
|
||||
).staff.clock_in;
|
||||
final String startFormatted = DateFormat('h:mm a').format(shift.startsAt);
|
||||
final String endFormatted = DateFormat('h:mm a').format(shift.endsAt);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'${formatTime(shift.startTime)} - ${formatTime(shift.endTime)}',
|
||||
'$startFormatted - $endFormatted',
|
||||
style: UiTypography.body3m.textSecondary,
|
||||
),
|
||||
Text(
|
||||
i18n.per_hr(amount: shift.hourlyRate),
|
||||
style: UiTypography.body3m.copyWith(color: UiColors.primary),
|
||||
),
|
||||
// TODO: Ask BE to add hourlyRate to the listTodayShifts response.
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'data/repositories_impl/clock_in_repository_impl.dart';
|
||||
import 'data/services/background_geofence_service.dart';
|
||||
@@ -30,8 +31,10 @@ class StaffClockInModule extends Module {
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.add<ClockInRepositoryInterface>(ClockInRepositoryImpl.new);
|
||||
// Repositories (V2 API via BaseApiService from CoreModule)
|
||||
i.add<ClockInRepositoryInterface>(
|
||||
() => ClockInRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||
);
|
||||
|
||||
// Geofence Services (resolve core singletons from DI)
|
||||
i.add<GeofenceServiceInterface>(
|
||||
|
||||
@@ -15,7 +15,7 @@ dependencies:
|
||||
equatable: ^2.0.5
|
||||
intl: ^0.20.2
|
||||
flutter_modular: ^6.3.2
|
||||
|
||||
|
||||
# Internal packages
|
||||
core_localization:
|
||||
path: ../../../core_localization
|
||||
@@ -23,9 +23,5 @@ dependencies:
|
||||
path: ../../../design_system
|
||||
krow_domain:
|
||||
path: ../../../domain
|
||||
krow_data_connect:
|
||||
path: ../../../data_connect
|
||||
krow_core:
|
||||
path: ../../../core
|
||||
firebase_data_connect: ^0.2.2+2
|
||||
firebase_auth: ^6.1.4
|
||||
|
||||
@@ -1,187 +1,33 @@
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:staff_home/src/domain/repositories/home_repository.dart';
|
||||
|
||||
class HomeRepositoryImpl
|
||||
implements HomeRepository {
|
||||
HomeRepositoryImpl() : _service = DataConnectService.instance;
|
||||
/// V2 API implementation of [HomeRepository].
|
||||
///
|
||||
/// Fetches staff dashboard data from `GET /staff/dashboard` and profile
|
||||
/// completion from `GET /staff/profile-completion`.
|
||||
class HomeRepositoryImpl implements HomeRepository {
|
||||
/// Creates a [HomeRepositoryImpl].
|
||||
HomeRepositoryImpl({required BaseApiService apiService})
|
||||
: _apiService = apiService;
|
||||
|
||||
final DataConnectService _service;
|
||||
/// The API service used for network requests.
|
||||
final BaseApiService _apiService;
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getTodayShifts() async {
|
||||
return _getShiftsForDate(DateTime.now());
|
||||
Future<StaffDashboard> getDashboard() async {
|
||||
final ApiResponse response =
|
||||
await _apiService.get(V2ApiEndpoints.staffDashboard);
|
||||
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
|
||||
return StaffDashboard.fromJson(data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getTomorrowShifts() async {
|
||||
return _getShiftsForDate(DateTime.now().add(const Duration(days: 1)));
|
||||
}
|
||||
|
||||
Future<List<Shift>> _getShiftsForDate(DateTime date) async {
|
||||
return _service.run(() async {
|
||||
final staffId = await _service.getStaffId();
|
||||
|
||||
// Create start and end timestamps for the target date
|
||||
final DateTime start = DateTime(date.year, date.month, date.day);
|
||||
final DateTime end =
|
||||
DateTime(date.year, date.month, date.day, 23, 59, 59, 999);
|
||||
|
||||
final response = await _service.run(() => _service.connector
|
||||
.getApplicationsByStaffId(staffId: staffId)
|
||||
.dayStart(_service.toTimestamp(start))
|
||||
.dayEnd(_service.toTimestamp(end))
|
||||
.execute());
|
||||
|
||||
// Filter for CONFIRMED applications (same logic as shifts_repository_impl)
|
||||
final apps = response.data.applications.where((app) =>
|
||||
(app.status is Known &&
|
||||
(app.status as Known).value == ApplicationStatus.CONFIRMED));
|
||||
|
||||
final List<Shift> shifts = [];
|
||||
for (final app in apps) {
|
||||
shifts.add(_mapApplicationToShift(app));
|
||||
}
|
||||
|
||||
return shifts;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getRecommendedShifts() async {
|
||||
// Logic: List ALL open shifts (simple recommendation engine)
|
||||
// Limitation: listShifts might return ALL shifts. We should ideally filter by status=PUBLISHED.
|
||||
return _service.run(() async {
|
||||
final response =
|
||||
await _service.run(() => _service.connector.listShifts().execute());
|
||||
|
||||
return response.data.shifts
|
||||
.where((s) {
|
||||
final isOpen = s.status is Known &&
|
||||
(s.status as Known).value == ShiftStatus.OPEN;
|
||||
if (!isOpen) return false;
|
||||
|
||||
final start = _service.toDateTime(s.startTime);
|
||||
if (start == null) return false;
|
||||
|
||||
return start.isAfter(DateTime.now());
|
||||
})
|
||||
.take(10)
|
||||
.map((s) => _mapConnectorShiftToDomain(s))
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getStaffName() async {
|
||||
final session = StaffSessionStore.instance.session;
|
||||
|
||||
// If session data is available, return staff name immediately
|
||||
if (session?.staff?.name != null) {
|
||||
return session!.staff!.name;
|
||||
}
|
||||
|
||||
// If session is not initialized, attempt to fetch staff data to populate session
|
||||
return await _service.run(() async {
|
||||
final staffId = await _service.getStaffId();
|
||||
final response = await _service.connector
|
||||
.getStaffById(id: staffId)
|
||||
.execute();
|
||||
|
||||
if (response.data.staff == null) {
|
||||
throw Exception('Staff data not found for ID: $staffId');
|
||||
}
|
||||
|
||||
final staff = response.data.staff!;
|
||||
final updatedSession = StaffSession(
|
||||
staff: Staff(
|
||||
id: staff.id,
|
||||
authProviderId: staff.userId,
|
||||
name: staff.fullName,
|
||||
email: staff.email ?? '',
|
||||
phone: staff.phone,
|
||||
status: StaffStatus.completedProfile,
|
||||
address: staff.addres,
|
||||
avatar: staff.photoUrl,
|
||||
),
|
||||
ownerId: staff.ownerId,
|
||||
);
|
||||
StaffSessionStore.instance.setSession(updatedSession);
|
||||
|
||||
return staff.fullName;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Benefit>> getBenefits() async {
|
||||
return _service.run(() async {
|
||||
final staffId = await _service.getStaffId();
|
||||
final response = await _service.connector
|
||||
.listBenefitsDataByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
|
||||
return response.data.benefitsDatas.map((data) {
|
||||
final plan = data.vendorBenefitPlan;
|
||||
final total = plan.total?.toDouble() ?? 0.0;
|
||||
final remaining = data.current.toDouble();
|
||||
return Benefit(
|
||||
title: plan.title,
|
||||
entitlementHours: total,
|
||||
usedHours: (total - remaining).clamp(0.0, total),
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
// Mappers specific to Home's Domain Entity 'Shift'
|
||||
// Note: Home's 'Shift' entity might differ slightly from 'StaffPayment' Shift.
|
||||
|
||||
Shift _mapApplicationToShift(GetApplicationsByStaffIdApplications app) {
|
||||
final s = app.shift;
|
||||
final r = app.shiftRole;
|
||||
|
||||
return ShiftAdapter.fromApplicationData(
|
||||
shiftId: s.id,
|
||||
roleId: r.roleId,
|
||||
roleName: r.role.name,
|
||||
businessName: s.order.business.businessName,
|
||||
companyLogoUrl: s.order.business.companyLogoUrl,
|
||||
costPerHour: r.role.costPerHour,
|
||||
shiftLocation: s.location,
|
||||
teamHubName: s.order.teamHub.hubName,
|
||||
shiftDate: _service.toDateTime(s.date),
|
||||
startTime: _service.toDateTime(r.startTime),
|
||||
endTime: _service.toDateTime(r.endTime),
|
||||
createdAt: _service.toDateTime(app.createdAt),
|
||||
status: 'confirmed',
|
||||
description: s.description,
|
||||
durationDays: s.durationDays,
|
||||
count: r.count,
|
||||
assigned: r.assigned,
|
||||
eventName: s.order.eventName,
|
||||
hasApplied: true,
|
||||
);
|
||||
}
|
||||
|
||||
Shift _mapConnectorShiftToDomain(ListShiftsShifts s) {
|
||||
return Shift(
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
clientName: s.order.business.businessName,
|
||||
hourlyRate: s.cost ?? 0.0,
|
||||
location: s.location ?? 'Unknown',
|
||||
locationAddress: s.locationAddress ?? '',
|
||||
date: _service.toDateTime(s.date)?.toIso8601String() ?? '',
|
||||
startTime: DateFormat('HH:mm')
|
||||
.format(_service.toDateTime(s.startTime) ?? DateTime.now()),
|
||||
endTime: DateFormat('HH:mm')
|
||||
.format(_service.toDateTime(s.endTime) ?? DateTime.now()),
|
||||
createdDate: _service.toDateTime(s.createdAt)?.toIso8601String() ?? '',
|
||||
tipsAvailable: false,
|
||||
mealProvided: false,
|
||||
managers: [],
|
||||
description: s.description,
|
||||
);
|
||||
Future<bool> getProfileCompletion() async {
|
||||
final ApiResponse response =
|
||||
await _apiService.get(V2ApiEndpoints.staffProfileCompletion);
|
||||
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
|
||||
final ProfileCompletion completion = ProfileCompletion.fromJson(data);
|
||||
return completion.completed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Entity representing a shift for the staff home screen.
|
||||
///
|
||||
/// This entity aggregates essential shift details needed for display cards.
|
||||
class Shift extends Equatable {
|
||||
const Shift({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.clientName,
|
||||
this.logoUrl,
|
||||
required this.hourlyRate,
|
||||
required this.location,
|
||||
this.locationAddress,
|
||||
required this.date,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.createdDate,
|
||||
this.tipsAvailable,
|
||||
this.travelTime,
|
||||
this.mealProvided,
|
||||
this.parkingAvailable,
|
||||
this.gasCompensation,
|
||||
this.description,
|
||||
this.instructions,
|
||||
this.managers,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
this.status,
|
||||
this.durationDays,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String clientName;
|
||||
final String? logoUrl;
|
||||
final double hourlyRate;
|
||||
final String location;
|
||||
final String? locationAddress;
|
||||
final String date;
|
||||
final String startTime;
|
||||
final String endTime;
|
||||
final String createdDate;
|
||||
final bool? tipsAvailable;
|
||||
final bool? travelTime;
|
||||
final bool? mealProvided;
|
||||
final bool? parkingAvailable;
|
||||
final bool? gasCompensation;
|
||||
final String? description;
|
||||
final String? instructions;
|
||||
final List<ShiftManager>? managers;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final String? status;
|
||||
final int? durationDays;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
title,
|
||||
clientName,
|
||||
logoUrl,
|
||||
hourlyRate,
|
||||
location,
|
||||
locationAddress,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
createdDate,
|
||||
tipsAvailable,
|
||||
travelTime,
|
||||
mealProvided,
|
||||
parkingAvailable,
|
||||
gasCompensation,
|
||||
description,
|
||||
instructions,
|
||||
managers,
|
||||
latitude,
|
||||
longitude,
|
||||
status,
|
||||
durationDays,
|
||||
];
|
||||
}
|
||||
|
||||
class ShiftManager extends Equatable {
|
||||
const ShiftManager({required this.name, required this.phone, this.avatar});
|
||||
|
||||
final String name;
|
||||
final String phone;
|
||||
final String? avatar;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [name, phone, avatar];
|
||||
}
|
||||
@@ -2,22 +2,14 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for home screen data operations.
|
||||
///
|
||||
/// This interface defines the contract for fetching shift data
|
||||
/// displayed on the worker home screen. Implementations should
|
||||
/// handle data retrieval from appropriate data sources.
|
||||
/// This interface defines the contract for fetching dashboard data
|
||||
/// displayed on the worker home screen. The V2 API returns all data
|
||||
/// in a single `/staff/dashboard` call.
|
||||
abstract class HomeRepository {
|
||||
/// Retrieves the list of shifts scheduled for today.
|
||||
Future<List<Shift>> getTodayShifts();
|
||||
/// Retrieves the staff dashboard containing today's shifts, tomorrow's
|
||||
/// shifts, recommended shifts, benefits, and the staff member's name.
|
||||
Future<StaffDashboard> getDashboard();
|
||||
|
||||
/// Retrieves the list of shifts scheduled for tomorrow.
|
||||
Future<List<Shift>> getTomorrowShifts();
|
||||
|
||||
/// Retrieves shifts recommended for the worker based on their profile.
|
||||
Future<List<Shift>> getRecommendedShifts();
|
||||
|
||||
/// Retrieves the current staff member's name.
|
||||
Future<String?> getStaffName();
|
||||
|
||||
/// Retrieves the list of benefits for the staff member.
|
||||
Future<List<Benefit>> getBenefits();
|
||||
/// Retrieves whether the staff member's profile is complete.
|
||||
Future<bool> getProfileCompletion();
|
||||
}
|
||||
|
||||
@@ -1,42 +1,31 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:staff_home/src/domain/repositories/home_repository.dart';
|
||||
|
||||
/// Use case for fetching all shifts displayed on the home screen.
|
||||
/// Use case for fetching the staff dashboard data.
|
||||
///
|
||||
/// This use case aggregates shift data from multiple time periods
|
||||
/// (today, tomorrow, and recommended) into a single response.
|
||||
class GetHomeShifts {
|
||||
final HomeRepository repository;
|
||||
/// Wraps the repository call and returns the full [StaffDashboard]
|
||||
/// containing shifts, benefits, and the staff member's name.
|
||||
class GetDashboardUseCase {
|
||||
/// Creates a [GetDashboardUseCase].
|
||||
GetDashboardUseCase(this._repository);
|
||||
|
||||
GetHomeShifts(this.repository);
|
||||
/// The repository used for data access.
|
||||
final HomeRepository _repository;
|
||||
|
||||
/// Executes the use case to fetch all home screen shift data.
|
||||
///
|
||||
/// Returns a [HomeShifts] object containing today's shifts,
|
||||
/// tomorrow's shifts, and recommended shifts.
|
||||
Future<HomeShifts> call() async {
|
||||
final today = await repository.getTodayShifts();
|
||||
final tomorrow = await repository.getTomorrowShifts();
|
||||
final recommended = await repository.getRecommendedShifts();
|
||||
return HomeShifts(
|
||||
today: today,
|
||||
tomorrow: tomorrow,
|
||||
recommended: recommended,
|
||||
);
|
||||
}
|
||||
/// Executes the use case to fetch dashboard data.
|
||||
Future<StaffDashboard> call() => _repository.getDashboard();
|
||||
}
|
||||
|
||||
/// Data transfer object containing all shifts for the home screen.
|
||||
/// Use case for checking staff profile completion status.
|
||||
///
|
||||
/// Groups shifts by time period for easy presentation layer consumption.
|
||||
class HomeShifts {
|
||||
final List<Shift> today;
|
||||
final List<Shift> tomorrow;
|
||||
final List<Shift> recommended;
|
||||
/// Returns `true` when all required profile fields are filled.
|
||||
class GetProfileCompletionUseCase {
|
||||
/// Creates a [GetProfileCompletionUseCase].
|
||||
GetProfileCompletionUseCase(this._repository);
|
||||
|
||||
HomeShifts({
|
||||
required this.today,
|
||||
required this.tomorrow,
|
||||
required this.recommended,
|
||||
});
|
||||
/// The repository used for data access.
|
||||
final HomeRepository _repository;
|
||||
|
||||
/// Executes the use case to check profile completion.
|
||||
Future<bool> call() => _repository.getProfileCompletion();
|
||||
}
|
||||
|
||||
@@ -6,27 +6,32 @@ import 'package:staff_home/src/domain/repositories/home_repository.dart';
|
||||
|
||||
part 'benefits_overview_state.dart';
|
||||
|
||||
/// Cubit to manage benefits overview page state.
|
||||
/// Cubit managing the benefits overview page state.
|
||||
///
|
||||
/// Fetches the dashboard and extracts benefits for the detail page.
|
||||
class BenefitsOverviewCubit extends Cubit<BenefitsOverviewState>
|
||||
with BlocErrorHandler<BenefitsOverviewState> {
|
||||
final HomeRepository _repository;
|
||||
|
||||
/// Creates a [BenefitsOverviewCubit].
|
||||
BenefitsOverviewCubit({required HomeRepository repository})
|
||||
: _repository = repository,
|
||||
super(const BenefitsOverviewState.initial());
|
||||
|
||||
/// The repository used for data access.
|
||||
final HomeRepository _repository;
|
||||
|
||||
/// Loads benefits from the dashboard endpoint.
|
||||
Future<void> loadBenefits() async {
|
||||
if (isClosed) return;
|
||||
emit(state.copyWith(status: BenefitsOverviewStatus.loading));
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final benefits = await _repository.getBenefits();
|
||||
final StaffDashboard dashboard = await _repository.getDashboard();
|
||||
if (isClosed) return;
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: BenefitsOverviewStatus.loaded,
|
||||
benefits: benefits,
|
||||
benefits: dashboard.benefits,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,61 +1,56 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:staff_home/src/domain/repositories/home_repository.dart';
|
||||
import 'package:staff_home/src/domain/usecases/get_home_shifts.dart';
|
||||
|
||||
part 'home_state.dart';
|
||||
|
||||
/// Simple Cubit to manage home page state (shifts + loading/error).
|
||||
/// Cubit managing the staff home page state.
|
||||
///
|
||||
/// Fetches the dashboard and profile-completion status concurrently
|
||||
/// using the V2 API via [GetDashboardUseCase] and
|
||||
/// [GetProfileCompletionUseCase].
|
||||
class HomeCubit extends Cubit<HomeState> with BlocErrorHandler<HomeState> {
|
||||
final GetHomeShifts _getHomeShifts;
|
||||
final HomeRepository _repository;
|
||||
/// Creates a [HomeCubit].
|
||||
HomeCubit({
|
||||
required GetDashboardUseCase getDashboard,
|
||||
required GetProfileCompletionUseCase getProfileCompletion,
|
||||
}) : _getDashboard = getDashboard,
|
||||
_getProfileCompletion = getProfileCompletion,
|
||||
super(const HomeState.initial());
|
||||
|
||||
/// Use case that fetches the full staff dashboard.
|
||||
final GetDashboardUseCase _getDashboard;
|
||||
|
||||
/// Use case that checks whether the staff member's profile is complete.
|
||||
///
|
||||
/// Used to determine whether profile-gated features (such as shift browsing)
|
||||
/// should be enabled on the home screen.
|
||||
final GetProfileCompletionUseCase _getProfileCompletion;
|
||||
|
||||
HomeCubit({
|
||||
required HomeRepository repository,
|
||||
required GetProfileCompletionUseCase getProfileCompletion,
|
||||
}) : _getHomeShifts = GetHomeShifts(repository),
|
||||
_repository = repository,
|
||||
_getProfileCompletion = getProfileCompletion,
|
||||
super(const HomeState.initial());
|
||||
|
||||
/// Loads dashboard data and profile completion concurrently.
|
||||
Future<void> loadShifts() async {
|
||||
if (isClosed) return;
|
||||
emit(state.copyWith(status: HomeStatus.loading));
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
// Fetch shifts, name, benefits and profile completion status concurrently
|
||||
final results = await Future.wait([
|
||||
_getHomeShifts.call(),
|
||||
final List<Object> results = await Future.wait(<Future<Object>>[
|
||||
_getDashboard.call(),
|
||||
_getProfileCompletion.call(),
|
||||
_repository.getBenefits(),
|
||||
_repository.getStaffName(),
|
||||
]);
|
||||
|
||||
final homeResult = results[0] as HomeShifts;
|
||||
final isProfileComplete = results[1] as bool;
|
||||
final benefits = results[2] as List<Benefit>;
|
||||
final name = results[3] as String?;
|
||||
|
||||
final StaffDashboard dashboard = results[0] as StaffDashboard;
|
||||
final bool isProfileComplete = results[1] as bool;
|
||||
|
||||
if (isClosed) return;
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: HomeStatus.loaded,
|
||||
todayShifts: homeResult.today,
|
||||
tomorrowShifts: homeResult.tomorrow,
|
||||
recommendedShifts: homeResult.recommended,
|
||||
staffName: name,
|
||||
todayShifts: dashboard.todaysShifts,
|
||||
tomorrowShifts: dashboard.tomorrowsShifts,
|
||||
recommendedShifts: dashboard.recommendedShifts,
|
||||
staffName: dashboard.staffName,
|
||||
isProfileComplete: isProfileComplete,
|
||||
benefits: benefits,
|
||||
benefits: dashboard.benefits,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -66,6 +61,7 @@ class HomeCubit extends Cubit<HomeState> with BlocErrorHandler<HomeState> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Toggles the auto-match preference.
|
||||
void toggleAutoMatch(bool enabled) {
|
||||
emit(state.copyWith(autoMatchEnabled: enabled));
|
||||
}
|
||||
|
||||
@@ -1,37 +1,62 @@
|
||||
part of 'home_cubit.dart';
|
||||
|
||||
/// Status of the home page data loading.
|
||||
enum HomeStatus { initial, loading, loaded, error }
|
||||
|
||||
/// State for the staff home page.
|
||||
///
|
||||
/// Contains today's shifts, tomorrow's shifts, recommended shifts, benefits,
|
||||
/// and profile-completion status from the V2 dashboard API.
|
||||
class HomeState extends Equatable {
|
||||
final HomeStatus status;
|
||||
final List<Shift> todayShifts;
|
||||
final List<Shift> tomorrowShifts;
|
||||
final List<Shift> recommendedShifts;
|
||||
final bool autoMatchEnabled;
|
||||
final bool isProfileComplete;
|
||||
final String? staffName;
|
||||
final String? errorMessage;
|
||||
final List<Benefit> benefits;
|
||||
|
||||
/// Creates a [HomeState].
|
||||
const HomeState({
|
||||
required this.status,
|
||||
this.todayShifts = const [],
|
||||
this.tomorrowShifts = const [],
|
||||
this.recommendedShifts = const [],
|
||||
this.todayShifts = const <TodayShift>[],
|
||||
this.tomorrowShifts = const <AssignedShift>[],
|
||||
this.recommendedShifts = const <OpenShift>[],
|
||||
this.autoMatchEnabled = false,
|
||||
this.isProfileComplete = false,
|
||||
this.staffName,
|
||||
this.errorMessage,
|
||||
this.benefits = const [],
|
||||
this.benefits = const <Benefit>[],
|
||||
});
|
||||
|
||||
/// Initial state with no data loaded.
|
||||
const HomeState.initial() : this(status: HomeStatus.initial);
|
||||
|
||||
/// Current loading status.
|
||||
final HomeStatus status;
|
||||
|
||||
/// Shifts assigned for today.
|
||||
final List<TodayShift> todayShifts;
|
||||
|
||||
/// Shifts assigned for tomorrow.
|
||||
final List<AssignedShift> tomorrowShifts;
|
||||
|
||||
/// Recommended open shifts.
|
||||
final List<OpenShift> recommendedShifts;
|
||||
|
||||
/// Whether auto-match is enabled.
|
||||
final bool autoMatchEnabled;
|
||||
|
||||
/// Whether the staff profile is complete.
|
||||
final bool isProfileComplete;
|
||||
|
||||
/// The staff member's display name.
|
||||
final String? staffName;
|
||||
|
||||
/// Error message if loading failed.
|
||||
final String? errorMessage;
|
||||
|
||||
/// Active benefits.
|
||||
final List<Benefit> benefits;
|
||||
|
||||
/// Creates a copy with the given fields replaced.
|
||||
HomeState copyWith({
|
||||
HomeStatus? status,
|
||||
List<Shift>? todayShifts,
|
||||
List<Shift>? tomorrowShifts,
|
||||
List<Shift>? recommendedShifts,
|
||||
List<TodayShift>? todayShifts,
|
||||
List<AssignedShift>? tomorrowShifts,
|
||||
List<OpenShift>? recommendedShifts,
|
||||
bool? autoMatchEnabled,
|
||||
bool? isProfileComplete,
|
||||
String? staffName,
|
||||
@@ -52,7 +77,7 @@ class HomeState extends Equatable {
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
List<Object?> get props => <Object?>[
|
||||
status,
|
||||
todayShifts,
|
||||
tomorrowShifts,
|
||||
@@ -63,4 +88,4 @@ class HomeState extends Equatable {
|
||||
errorMessage,
|
||||
benefits,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
@@ -6,20 +5,14 @@ import 'package:staff_home/src/presentation/widgets/benefits_overview/benefit_ca
|
||||
|
||||
/// Card widget displaying detailed benefit information.
|
||||
class BenefitCard extends StatelessWidget {
|
||||
/// The benefit to display.
|
||||
final Benefit benefit;
|
||||
|
||||
/// Creates a [BenefitCard].
|
||||
const BenefitCard({required this.benefit, super.key});
|
||||
|
||||
/// The benefit to display.
|
||||
final Benefit benefit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isSickLeave = benefit.title.toLowerCase().contains('sick');
|
||||
final bool isVacation = benefit.title.toLowerCase().contains('vacation');
|
||||
final bool isHolidays = benefit.title.toLowerCase().contains('holiday');
|
||||
|
||||
final i18n = t.staff.home.benefits.overview;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
decoration: BoxDecoration(
|
||||
@@ -29,17 +22,8 @@ class BenefitCard extends StatelessWidget {
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
BenefitCardHeader(benefit: benefit),
|
||||
// const SizedBox(height: UiConstants.space6),
|
||||
// if (isSickLeave) ...[
|
||||
// AccordionHistory(label: i18n.sick_leave_history),
|
||||
// const SizedBox(height: UiConstants.space6),
|
||||
// ],
|
||||
// if (isVacation || isHolidays) ...[
|
||||
// ComplianceBanner(text: i18n.compliance_banner),
|
||||
// const SizedBox(height: UiConstants.space6),
|
||||
// ],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,30 +6,33 @@ import 'package:staff_home/src/presentation/widgets/benefits_overview/circular_p
|
||||
import 'package:staff_home/src/presentation/widgets/benefits_overview/stat_chip.dart';
|
||||
|
||||
/// Header section of a benefit card showing progress circle, title, and stats.
|
||||
///
|
||||
/// Uses V2 [Benefit] entity fields: [Benefit.targetHours],
|
||||
/// [Benefit.trackedHours], and [Benefit.remainingHours].
|
||||
class BenefitCardHeader extends StatelessWidget {
|
||||
/// The benefit to display.
|
||||
final Benefit benefit;
|
||||
|
||||
/// Creates a [BenefitCardHeader].
|
||||
const BenefitCardHeader({required this.benefit, super.key});
|
||||
|
||||
/// The benefit to display.
|
||||
final Benefit benefit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i18n = t.staff.home.benefits.overview;
|
||||
final dynamic i18n = t.staff.home.benefits.overview;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
_buildProgressCircle(),
|
||||
const SizedBox(width: UiConstants.space4),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Text(
|
||||
benefit.title,
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
if (_getSubtitle(benefit.title).isNotEmpty) ...[
|
||||
if (_getSubtitle(benefit.title).isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
_getSubtitle(benefit.title),
|
||||
@@ -46,8 +49,8 @@ class BenefitCardHeader extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildProgressCircle() {
|
||||
final double progress = benefit.entitlementHours > 0
|
||||
? (benefit.remainingHours / benefit.entitlementHours)
|
||||
final double progress = benefit.targetHours > 0
|
||||
? (benefit.remainingHours / benefit.targetHours)
|
||||
: 0.0;
|
||||
|
||||
return SizedBox(
|
||||
@@ -60,14 +63,14 @@ class BenefitCardHeader extends StatelessWidget {
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'${benefit.remainingHours.toInt()}/${benefit.entitlementHours.toInt()}',
|
||||
'${benefit.remainingHours}/${benefit.targetHours}',
|
||||
style: UiTypography.body2b.textPrimary.copyWith(fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
t.client_billing.hours_suffix,
|
||||
style: UiTypography.footnote1r.textSecondary
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -78,27 +81,27 @@ class BenefitCardHeader extends StatelessWidget {
|
||||
|
||||
Widget _buildStatsRow(dynamic i18n) {
|
||||
return Row(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
StatChip(
|
||||
label: i18n.entitlement,
|
||||
value: '${benefit.entitlementHours.toInt()}',
|
||||
value: '${benefit.targetHours}',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
StatChip(
|
||||
label: i18n.used,
|
||||
value: '${benefit.usedHours.toInt()}',
|
||||
value: '${benefit.trackedHours}',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
StatChip(
|
||||
label: i18n.remaining,
|
||||
value: '${benefit.remainingHours.toInt()}',
|
||||
value: '${benefit.remainingHours}',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getSubtitle(String title) {
|
||||
final i18n = t.staff.home.benefits.overview;
|
||||
final dynamic i18n = t.staff.home.benefits.overview;
|
||||
if (title.toLowerCase().contains('sick')) {
|
||||
return i18n.sick_leave_subtitle;
|
||||
} else if (title.toLowerCase().contains('vacation')) {
|
||||
|
||||
@@ -2,23 +2,33 @@ 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 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Card widget for a recommended open shift.
|
||||
///
|
||||
/// Displays the role name, pay rate, time range, and location
|
||||
/// from an [OpenShift] entity.
|
||||
class RecommendedShiftCard extends StatelessWidget {
|
||||
final Shift shift;
|
||||
/// Creates a [RecommendedShiftCard].
|
||||
const RecommendedShiftCard({required this.shift, super.key});
|
||||
|
||||
const RecommendedShiftCard({super.key, required this.shift});
|
||||
/// The open shift to display.
|
||||
final OpenShift shift;
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
return DateFormat('h:mma').format(time).toLowerCase();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final recI18n = t.staff.home.recommended_card;
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final dynamic recI18n = t.staff.home.recommended_card;
|
||||
final Size size = MediaQuery.sizeOf(context);
|
||||
final double hourlyRate = shift.hourlyRateCents / 100;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Modular.to.toShiftDetails(shift);
|
||||
},
|
||||
onTap: () => Modular.to.toShiftDetailsById(shift.shiftId),
|
||||
child: Container(
|
||||
width: size.width * 0.8,
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
@@ -31,10 +41,10 @@ class RecommendedShiftCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: UiConstants.space10,
|
||||
height: UiConstants.space10,
|
||||
@@ -52,20 +62,20 @@ class RecommendedShiftCard extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: UiConstants.space1,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
child: Text(
|
||||
shift.title,
|
||||
shift.roleName,
|
||||
style: UiTypography.body1m.textPrimary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'\$${shift.hourlyRate}/h',
|
||||
'\$${hourlyRate.toStringAsFixed(0)}/h',
|
||||
style: UiTypography.headline4b,
|
||||
),
|
||||
],
|
||||
@@ -73,13 +83,13 @@ class RecommendedShiftCard extends StatelessWidget {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: UiConstants.space1,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
Text(
|
||||
shift.clientName,
|
||||
shift.orderType.toJson(),
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
Text(
|
||||
'\$${shift.hourlyRate.toStringAsFixed(0)}/hr',
|
||||
'\$${hourlyRate.toStringAsFixed(0)}/hr',
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
@@ -91,14 +101,17 @@ class RecommendedShiftCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Row(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.calendar,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(recI18n.today, style: UiTypography.body3r.textSecondary),
|
||||
Text(
|
||||
recI18n.today,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
const Icon(
|
||||
UiIcons.clock,
|
||||
@@ -108,8 +121,8 @@ class RecommendedShiftCard extends StatelessWidget {
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
recI18n.time_range(
|
||||
start: shift.startTime,
|
||||
end: shift.endTime,
|
||||
start: _formatTime(shift.startTime),
|
||||
end: _formatTime(shift.endTime),
|
||||
),
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
@@ -117,7 +130,7 @@ class RecommendedShiftCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Row(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: UiConstants.space3,
|
||||
@@ -126,7 +139,7 @@ class RecommendedShiftCard extends StatelessWidget {
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Expanded(
|
||||
child: Text(
|
||||
shift.locationAddress,
|
||||
shift.location,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart';
|
||||
@@ -10,23 +11,23 @@ import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dar
|
||||
|
||||
/// A widget that displays recommended shifts section.
|
||||
///
|
||||
/// Shows a horizontal scrolling list of shifts recommended for the worker
|
||||
/// based on their profile and preferences.
|
||||
/// Shows a horizontal scrolling list of [OpenShift] entities recommended
|
||||
/// for the worker based on their profile and preferences.
|
||||
class RecommendedShiftsSection extends StatelessWidget {
|
||||
/// Creates a [RecommendedShiftsSection].
|
||||
const RecommendedShiftsSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Translations.of(context);
|
||||
final sectionsI18n = t.staff.home.sections;
|
||||
final emptyI18n = t.staff.home.empty_states;
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final Translations i18nRoot = Translations.of(context);
|
||||
final dynamic sectionsI18n = i18nRoot.staff.home.sections;
|
||||
final dynamic emptyI18n = i18nRoot.staff.home.empty_states;
|
||||
final Size size = MediaQuery.sizeOf(context);
|
||||
|
||||
return SectionLayout(
|
||||
title: sectionsI18n.recommended_for_you,
|
||||
child: BlocBuilder<HomeCubit, HomeState>(
|
||||
builder: (context, state) {
|
||||
builder: (BuildContext context, HomeState state) {
|
||||
if (state.recommendedShifts.isEmpty) {
|
||||
return EmptyStateWidget(message: emptyI18n.no_recommended_shifts);
|
||||
}
|
||||
@@ -36,7 +37,7 @@ class RecommendedShiftsSection extends StatelessWidget {
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: state.recommendedShifts.length,
|
||||
clipBehavior: Clip.none,
|
||||
itemBuilder: (context, index) => Padding(
|
||||
itemBuilder: (BuildContext context, int index) => Padding(
|
||||
padding: const EdgeInsets.only(right: UiConstants.space3),
|
||||
child: RecommendedShiftCard(
|
||||
shift: state.recommendedShifts[index],
|
||||
|
||||
@@ -3,36 +3,35 @@ import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/shift_card.dart';
|
||||
|
||||
/// A widget that displays today's shifts section.
|
||||
///
|
||||
/// Shows a list of shifts scheduled for today, with loading state
|
||||
/// and empty state handling.
|
||||
/// Shows a list of shifts scheduled for today using [TodayShift] entities
|
||||
/// from the V2 dashboard API.
|
||||
class TodaysShiftsSection extends StatelessWidget {
|
||||
/// Creates a [TodaysShiftsSection].
|
||||
const TodaysShiftsSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Translations.of(context);
|
||||
final sectionsI18n = t.staff.home.sections;
|
||||
final emptyI18n = t.staff.home.empty_states;
|
||||
final Translations i18nRoot = Translations.of(context);
|
||||
final dynamic sectionsI18n = i18nRoot.staff.home.sections;
|
||||
final dynamic emptyI18n = i18nRoot.staff.home.empty_states;
|
||||
|
||||
return BlocBuilder<HomeCubit, HomeState>(
|
||||
builder: (context, state) {
|
||||
final shifts = state.todayShifts;
|
||||
builder: (BuildContext context, HomeState state) {
|
||||
final List<TodayShift> shifts = state.todayShifts;
|
||||
return SectionLayout(
|
||||
title: sectionsI18n.todays_shift,
|
||||
action: shifts.isNotEmpty
|
||||
? sectionsI18n.scheduled_count(
|
||||
count: shifts.length,
|
||||
)
|
||||
? sectionsI18n.scheduled_count(count: shifts.length)
|
||||
: null,
|
||||
child: state.status == HomeStatus.loading
|
||||
? const _ShiftsSectionSkeleton()
|
||||
@@ -46,10 +45,7 @@ class TodaysShiftsSection extends StatelessWidget {
|
||||
: Column(
|
||||
children: shifts
|
||||
.map(
|
||||
(shift) => ShiftCard(
|
||||
shift: shift,
|
||||
compact: true,
|
||||
),
|
||||
(TodayShift shift) => _TodayShiftCard(shift: shift),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
@@ -59,6 +55,70 @@ class TodaysShiftsSection extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact card for a today's shift.
|
||||
class _TodayShiftCard extends StatelessWidget {
|
||||
const _TodayShiftCard({required this.shift});
|
||||
|
||||
/// The today-shift to display.
|
||||
final TodayShift shift;
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
return DateFormat('h:mma').format(time).toLowerCase();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => Modular.to.toShiftDetailsById(shift.shiftId),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: UiConstants.space12,
|
||||
height: UiConstants.space12,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Icon(
|
||||
UiIcons.building,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
shift.roleName,
|
||||
style: UiTypography.body1m.textPrimary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
'${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)} \u2022 ${shift.location}',
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline shimmer skeleton for the shifts section loading state.
|
||||
class _ShiftsSectionSkeleton extends StatelessWidget {
|
||||
const _ShiftsSectionSkeleton();
|
||||
@@ -68,20 +128,20 @@ class _ShiftsSectionSkeleton extends StatelessWidget {
|
||||
return UiShimmer(
|
||||
child: UiShimmerList(
|
||||
itemCount: 2,
|
||||
itemBuilder: (index) => Container(
|
||||
itemBuilder: (int index) => Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
children: <Widget>[
|
||||
UiShimmerBox(width: 48, height: 48),
|
||||
SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
children: <Widget>[
|
||||
UiShimmerLine(width: 160, height: 14),
|
||||
SizedBox(height: UiConstants.space2),
|
||||
UiShimmerLine(width: 120, height: 12),
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/shift_card.dart';
|
||||
|
||||
/// A widget that displays tomorrow's shifts section.
|
||||
///
|
||||
/// Shows a list of shifts scheduled for tomorrow with empty state handling.
|
||||
/// Shows a list of [AssignedShift] entities scheduled for tomorrow.
|
||||
class TomorrowsShiftsSection extends StatelessWidget {
|
||||
/// Creates a [TomorrowsShiftsSection].
|
||||
const TomorrowsShiftsSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Translations.of(context);
|
||||
final sectionsI18n = t.staff.home.sections;
|
||||
final emptyI18n = t.staff.home.empty_states;
|
||||
final Translations i18nRoot = Translations.of(context);
|
||||
final dynamic sectionsI18n = i18nRoot.staff.home.sections;
|
||||
final dynamic emptyI18n = i18nRoot.staff.home.empty_states;
|
||||
|
||||
return BlocBuilder<HomeCubit, HomeState>(
|
||||
builder: (context, state) {
|
||||
final shifts = state.tomorrowShifts;
|
||||
|
||||
builder: (BuildContext context, HomeState state) {
|
||||
final List<AssignedShift> shifts = state.tomorrowShifts;
|
||||
|
||||
return SectionLayout(
|
||||
title: sectionsI18n.tomorrow,
|
||||
child: shifts.isEmpty
|
||||
? EmptyStateWidget(
|
||||
message: emptyI18n.no_shifts_tomorrow,
|
||||
)
|
||||
? EmptyStateWidget(message: emptyI18n.no_shifts_tomorrow)
|
||||
: Column(
|
||||
children: shifts
|
||||
.map(
|
||||
(shift) => ShiftCard(
|
||||
shift: shift,
|
||||
compact: true,
|
||||
),
|
||||
(AssignedShift shift) =>
|
||||
_TomorrowShiftCard(shift: shift),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
@@ -45,3 +45,89 @@ class TomorrowsShiftsSection extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact card for a tomorrow's shift.
|
||||
class _TomorrowShiftCard extends StatelessWidget {
|
||||
const _TomorrowShiftCard({required this.shift});
|
||||
|
||||
/// The assigned shift to display.
|
||||
final AssignedShift shift;
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
return DateFormat('h:mma').format(time).toLowerCase();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double hourlyRate = shift.hourlyRateCents / 100;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => Modular.to.toShiftDetailsById(shift.shiftId),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: UiConstants.space12,
|
||||
height: UiConstants.space12,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Icon(
|
||||
UiIcons.building,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
child: Text(
|
||||
shift.roleName,
|
||||
style: UiTypography.body1m.textPrimary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
text:
|
||||
'\$${hourlyRate % 1 == 0 ? hourlyRate.toInt() : hourlyRate.toStringAsFixed(2)}',
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
children: <InlineSpan>[
|
||||
TextSpan(
|
||||
text: '/h',
|
||||
style: UiTypography.body3r,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
'${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)} \u2022 ${shift.location}',
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
class ShiftCard extends StatefulWidget {
|
||||
final Shift shift;
|
||||
final VoidCallback? onApply;
|
||||
final VoidCallback? onDecline;
|
||||
final bool compact;
|
||||
final bool disableTapNavigation; // Added property
|
||||
|
||||
const ShiftCard({
|
||||
super.key,
|
||||
required this.shift,
|
||||
this.onApply,
|
||||
this.onDecline,
|
||||
this.compact = false,
|
||||
this.disableTapNavigation = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ShiftCard> createState() => _ShiftCardState();
|
||||
}
|
||||
|
||||
class _ShiftCardState extends State<ShiftCard> {
|
||||
bool isExpanded = false;
|
||||
|
||||
String _formatTime(String time) {
|
||||
if (time.isEmpty) return '';
|
||||
try {
|
||||
final parts = time.split(':');
|
||||
final hour = int.parse(parts[0]);
|
||||
final minute = int.parse(parts[1]);
|
||||
final dt = DateTime(2022, 1, 1, hour, minute);
|
||||
return DateFormat('h:mma').format(dt).toLowerCase();
|
||||
} catch (e) {
|
||||
return time;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDate(String dateStr) {
|
||||
if (dateStr.isEmpty) return '';
|
||||
try {
|
||||
final date = DateTime.parse(dateStr);
|
||||
return DateFormat('MMMM d').format(date);
|
||||
} catch (e) {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
String _getTimeAgo(String dateStr) {
|
||||
if (dateStr.isEmpty) return '';
|
||||
try {
|
||||
final date = DateTime.parse(dateStr);
|
||||
final diff = DateTime.now().difference(date);
|
||||
if (diff.inHours < 1) return t.staff_shifts.card.just_now;
|
||||
if (diff.inHours < 24)
|
||||
return t.staff_shifts.details.pending_time(time: '${diff.inHours}h');
|
||||
return t.staff_shifts.details.pending_time(time: '${diff.inDays}d');
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.compact) {
|
||||
return GestureDetector(
|
||||
onTap: widget.disableTapNavigation
|
||||
? null
|
||||
: () {
|
||||
setState(() => isExpanded = !isExpanded);
|
||||
Modular.to.toShiftDetails(widget.shift);
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: UiConstants.space12,
|
||||
height: UiConstants.space12,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: widget.shift.logoUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase,
|
||||
),
|
||||
child: Image.network(
|
||||
widget.shift.logoUrl!,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
)
|
||||
: Icon(UiIcons.building, color: UiColors.mutedForeground),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.shift.title,
|
||||
style: UiTypography.body1m.textPrimary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
text: '\$${widget.shift.hourlyRate % 1 == 0 ? widget.shift.hourlyRate.toInt() : widget.shift.hourlyRate.toStringAsFixed(2)}',
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
children: [
|
||||
TextSpan(text: '/h', style: UiTypography.body3r),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
widget.shift.clientName,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
'${_formatTime(widget.shift.startTime)} • ${widget.shift.location}',
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: UiColors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
width: UiConstants.space14,
|
||||
height: UiConstants.space14,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase,
|
||||
),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: widget.shift.logoUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase,
|
||||
),
|
||||
child: Image.network(
|
||||
widget.shift.logoUrl!,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
UiIcons.building,
|
||||
size: UiConstants.iconXl - 4, // 28px
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary,
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
child: Text(
|
||||
t.staff_shifts.card.assigned(
|
||||
time: _getTimeAgo(widget.shift.createdDate)
|
||||
.replaceAll('Pending ', '')
|
||||
.replaceAll('Just now', 'just now'),
|
||||
),
|
||||
style: UiTypography.body3m.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Title and Rate
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.shift.title,
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
),
|
||||
Text(
|
||||
widget.shift.clientName,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
text: '\$${widget.shift.hourlyRate % 1 == 0 ? widget.shift.hourlyRate.toInt() : widget.shift.hourlyRate.toStringAsFixed(2)}',
|
||||
style: UiTypography.headline3m.textPrimary,
|
||||
children: [
|
||||
TextSpan(text: '/h', style: UiTypography.body1r),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Location and Date
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
UiIcons.mapPin,
|
||||
size: UiConstants.iconSm,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.shift.location,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space4),
|
||||
Icon(
|
||||
UiIcons.calendar,
|
||||
size: UiConstants.iconSm,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${_formatDate(widget.shift.date)}, ${_formatTime(widget.shift.startTime)}',
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Tags
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildTag(
|
||||
UiIcons.zap,
|
||||
t.staff_shifts.tags.immediate_start,
|
||||
UiColors.accent.withValues(alpha: 0.3),
|
||||
UiColors.foreground,
|
||||
),
|
||||
_buildTag(
|
||||
UiIcons.timer,
|
||||
t.staff_shifts.tags.no_experience,
|
||||
UiColors.tagError,
|
||||
UiColors.textError,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Actions
|
||||
if (!widget.compact)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space5,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: UiConstants.space12,
|
||||
child: ElevatedButton(
|
||||
onPressed: widget.onApply,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: UiColors.primary,
|
||||
foregroundColor: UiColors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(t.staff_shifts.card.accept_shift),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: UiConstants.space12,
|
||||
child: OutlinedButton(
|
||||
onPressed: widget.onDecline,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: UiColors.destructive,
|
||||
side: BorderSide(
|
||||
color: UiColors.destructive.withValues(alpha: 0.3),
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(t.staff_shifts.card.decline_shift),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTag(IconData icon, String label, Color bg, Color text) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: UiConstants.iconSm - 2, color: text),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: UiTypography.body3m.copyWith(color: text),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,17 @@ import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/worker/worker_benefits/benefit_item.dart';
|
||||
|
||||
/// Widget for displaying staff benefits, using design system tokens.
|
||||
///
|
||||
/// Shows a list of benefits with circular progress indicators.
|
||||
/// Shows a list of V2 [Benefit] entities with circular progress indicators.
|
||||
class BenefitsWidget extends StatelessWidget {
|
||||
/// The list of benefits to display.
|
||||
final List<Benefit> benefits;
|
||||
|
||||
/// Creates a [BenefitsWidget].
|
||||
const BenefitsWidget({required this.benefits, super.key});
|
||||
|
||||
/// The list of benefits to display.
|
||||
final List<Benefit> benefits;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (benefits.isEmpty) {
|
||||
@@ -26,9 +27,9 @@ class BenefitsWidget extends StatelessWidget {
|
||||
return Expanded(
|
||||
child: BenefitItem(
|
||||
label: benefit.title,
|
||||
remaining: benefit.remainingHours,
|
||||
total: benefit.entitlementHours,
|
||||
used: benefit.usedHours,
|
||||
remaining: benefit.remainingHours.toDouble(),
|
||||
total: benefit.targetHours.toDouble(),
|
||||
used: benefit.trackedHours.toDouble(),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:staff_home/src/data/repositories/home_repository_impl.dart';
|
||||
import 'package:staff_home/src/domain/repositories/home_repository.dart';
|
||||
import 'package:staff_home/src/domain/usecases/get_home_shifts.dart';
|
||||
import 'package:staff_home/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart';
|
||||
import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart';
|
||||
import 'package:staff_home/src/presentation/pages/benefits_overview_page.dart';
|
||||
@@ -12,36 +13,37 @@ import 'package:staff_home/src/presentation/pages/worker_home_page.dart';
|
||||
/// The module for the staff home feature.
|
||||
///
|
||||
/// This module provides dependency injection bindings for the home feature
|
||||
/// following Clean Architecture principles. It injects the repository
|
||||
/// implementation and state management components.
|
||||
/// following Clean Architecture principles. It uses the V2 REST API via
|
||||
/// [BaseApiService] for all backend access.
|
||||
class StaffHomeModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repository - provides home data (shifts, staff name)
|
||||
i.addLazySingleton<HomeRepository>(() => HomeRepositoryImpl());
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
// StaffConnectorRepository for profile completion queries
|
||||
i.addLazySingleton<StaffConnectorRepository>(
|
||||
() => StaffConnectorRepositoryImpl(),
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repository - uses V2 API for dashboard data
|
||||
i.addLazySingleton<HomeRepository>(
|
||||
() => HomeRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||
);
|
||||
|
||||
// Use case for checking profile completion
|
||||
// Use cases
|
||||
i.addLazySingleton<GetDashboardUseCase>(
|
||||
() => GetDashboardUseCase(i.get<HomeRepository>()),
|
||||
);
|
||||
i.addLazySingleton<GetProfileCompletionUseCase>(
|
||||
() => GetProfileCompletionUseCase(
|
||||
repository: i.get<StaffConnectorRepository>(),
|
||||
),
|
||||
() => GetProfileCompletionUseCase(i.get<HomeRepository>()),
|
||||
);
|
||||
|
||||
// Presentation layer - Cubits
|
||||
i.addLazySingleton(
|
||||
i.addLazySingleton<HomeCubit>(
|
||||
() => HomeCubit(
|
||||
repository: i.get<HomeRepository>(),
|
||||
getDashboard: i.get<GetDashboardUseCase>(),
|
||||
getProfileCompletion: i.get<GetProfileCompletionUseCase>(),
|
||||
),
|
||||
);
|
||||
|
||||
// Cubit for benefits overview page
|
||||
i.addLazySingleton(
|
||||
i.addLazySingleton<BenefitsOverviewCubit>(
|
||||
() => BenefitsOverviewCubit(repository: i.get<HomeRepository>()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,11 +18,7 @@ dependencies:
|
||||
path: ../../../core
|
||||
krow_domain:
|
||||
path: ../../../domain
|
||||
staff_shifts:
|
||||
path: ../shifts
|
||||
krow_data_connect:
|
||||
path: ../../../data_connect
|
||||
|
||||
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_bloc: ^8.1.0
|
||||
@@ -30,8 +26,6 @@ dependencies:
|
||||
flutter_modular: ^6.3.0
|
||||
equatable: ^2.0.5
|
||||
intl: ^0.20.0
|
||||
google_fonts: ^7.0.0
|
||||
firebase_data_connect:
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -1,98 +1,76 @@
|
||||
import 'package:firebase_data_connect/src/core/ref.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../domain/repositories/payments_repository.dart';
|
||||
import 'package:staff_payments/src/domain/repositories/payments_repository.dart';
|
||||
|
||||
class PaymentsRepositoryImpl
|
||||
implements PaymentsRepository {
|
||||
/// V2 REST API implementation of [PaymentsRepository].
|
||||
///
|
||||
/// Calls the staff payments endpoints via [BaseApiService].
|
||||
class PaymentsRepositoryImpl implements PaymentsRepository {
|
||||
/// Creates a [PaymentsRepositoryImpl] with the given [apiService].
|
||||
PaymentsRepositoryImpl({required BaseApiService apiService})
|
||||
: _apiService = apiService;
|
||||
|
||||
PaymentsRepositoryImpl() : _service = DataConnectService.instance;
|
||||
final DataConnectService _service;
|
||||
/// The API service used for HTTP requests.
|
||||
final BaseApiService _apiService;
|
||||
|
||||
@override
|
||||
Future<PaymentSummary> getPaymentSummary() async {
|
||||
return _service.run(() async {
|
||||
final String currentStaffId = await _service.getStaffId();
|
||||
|
||||
// Fetch recent payments with a limit
|
||||
final QueryResult<dc.ListRecentPaymentsByStaffIdData, dc.ListRecentPaymentsByStaffIdVariables> response = await _service.connector.listRecentPaymentsByStaffId(
|
||||
staffId: currentStaffId,
|
||||
).limit(100).execute();
|
||||
|
||||
final List<dc.ListRecentPaymentsByStaffIdRecentPayments> payments = response.data.recentPayments;
|
||||
|
||||
double weekly = 0;
|
||||
double monthly = 0;
|
||||
double pending = 0;
|
||||
double total = 0;
|
||||
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime startOfWeek = now.subtract(const Duration(days: 7));
|
||||
final DateTime startOfMonth = DateTime(now.year, now.month, 1);
|
||||
|
||||
for (final dc.ListRecentPaymentsByStaffIdRecentPayments p in payments) {
|
||||
final DateTime? date = _service.toDateTime(p.invoice.issueDate) ?? _service.toDateTime(p.createdAt);
|
||||
final double amount = p.invoice.amount;
|
||||
final String? status = p.status?.stringValue;
|
||||
|
||||
if (status == 'PENDING') {
|
||||
pending += amount;
|
||||
} else if (status == 'PAID') {
|
||||
total += amount;
|
||||
if (date != null) {
|
||||
if (date.isAfter(startOfWeek)) weekly += amount;
|
||||
if (date.isAfter(startOfMonth)) monthly += amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return PaymentSummary(
|
||||
weeklyEarnings: weekly,
|
||||
monthlyEarnings: monthly,
|
||||
pendingEarnings: pending,
|
||||
totalEarnings: total,
|
||||
);
|
||||
});
|
||||
Future<PaymentSummary> getPaymentSummary({
|
||||
String? startDate,
|
||||
String? endDate,
|
||||
}) async {
|
||||
final Map<String, dynamic> params = <String, dynamic>{
|
||||
if (startDate != null) 'startDate': startDate,
|
||||
if (endDate != null) 'endDate': endDate,
|
||||
};
|
||||
final ApiResponse response = await _apiService.get(
|
||||
V2ApiEndpoints.staffPaymentsSummary,
|
||||
params: params.isEmpty ? null : params,
|
||||
);
|
||||
return PaymentSummary.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<StaffPayment>> getPaymentHistory(String period) async {
|
||||
return _service.run(() async {
|
||||
final String currentStaffId = await _service.getStaffId();
|
||||
|
||||
final QueryResult<dc.ListRecentPaymentsByStaffIdData, dc.ListRecentPaymentsByStaffIdVariables> response = await _service.connector
|
||||
.listRecentPaymentsByStaffId(staffId: currentStaffId)
|
||||
.execute();
|
||||
Future<List<PaymentRecord>> getPaymentHistory({
|
||||
String? startDate,
|
||||
String? endDate,
|
||||
}) async {
|
||||
final Map<String, dynamic> params = <String, dynamic>{
|
||||
if (startDate != null) 'startDate': startDate,
|
||||
if (endDate != null) 'endDate': endDate,
|
||||
};
|
||||
final ApiResponse response = await _apiService.get(
|
||||
V2ApiEndpoints.staffPaymentsHistory,
|
||||
params: params.isEmpty ? null : params,
|
||||
);
|
||||
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) =>
|
||||
PaymentRecord.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
return response.data.recentPayments.map((dc.ListRecentPaymentsByStaffIdRecentPayments payment) {
|
||||
// Extract shift details from nested application structure
|
||||
final String? shiftTitle = payment.application.shiftRole.shift.title;
|
||||
final String? locationAddress = payment.application.shiftRole.shift.locationAddress;
|
||||
final double? hoursWorked = payment.application.shiftRole.hours;
|
||||
final double? hourlyRate = payment.application.shiftRole.role.costPerHour;
|
||||
// Extract hub details from order
|
||||
final String? locationHub = payment.invoice.order.teamHub.hubName;
|
||||
final String? hubAddress = payment.invoice.order.teamHub.address;
|
||||
final String? shiftLocation = locationAddress ?? hubAddress;
|
||||
|
||||
return StaffPayment(
|
||||
id: payment.id,
|
||||
staffId: payment.staffId,
|
||||
assignmentId: payment.applicationId,
|
||||
amount: payment.invoice.amount,
|
||||
status: PaymentAdapter.toPaymentStatus(payment.status?.stringValue ?? 'UNKNOWN'),
|
||||
paidAt: _service.toDateTime(payment.invoice.issueDate),
|
||||
shiftTitle: shiftTitle,
|
||||
shiftLocation: locationHub,
|
||||
locationAddress: shiftLocation,
|
||||
hoursWorked: hoursWorked,
|
||||
hourlyRate: hourlyRate,
|
||||
workedTime: payment.workedTime,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
@override
|
||||
Future<List<PaymentChartPoint>> getPaymentChart({
|
||||
String? startDate,
|
||||
String? endDate,
|
||||
String bucket = 'day',
|
||||
}) async {
|
||||
final Map<String, dynamic> params = <String, dynamic>{
|
||||
'bucket': bucket,
|
||||
if (startDate != null) 'startDate': startDate,
|
||||
if (endDate != null) 'endDate': endDate,
|
||||
};
|
||||
final ApiResponse response = await _apiService.get(
|
||||
V2ApiEndpoints.staffPaymentsChart,
|
||||
params: params,
|
||||
);
|
||||
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) =>
|
||||
PaymentChartPoint.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Arguments for retrieving payment chart data.
|
||||
class GetPaymentChartArguments extends UseCaseArgument {
|
||||
/// Creates [GetPaymentChartArguments] with the [bucket] granularity.
|
||||
const GetPaymentChartArguments({
|
||||
this.bucket = 'day',
|
||||
this.startDate,
|
||||
this.endDate,
|
||||
});
|
||||
|
||||
/// Time bucket granularity: `day`, `week`, or `month`.
|
||||
final String bucket;
|
||||
|
||||
/// ISO-8601 start date for the range filter.
|
||||
final String? startDate;
|
||||
|
||||
/// ISO-8601 end date for the range filter.
|
||||
final String? endDate;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[bucket, startDate, endDate];
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Arguments for getting payment history.
|
||||
/// Arguments for retrieving payment history.
|
||||
class GetPaymentHistoryArguments extends UseCaseArgument {
|
||||
/// Creates [GetPaymentHistoryArguments] with optional date range.
|
||||
const GetPaymentHistoryArguments({
|
||||
this.startDate,
|
||||
this.endDate,
|
||||
});
|
||||
|
||||
const GetPaymentHistoryArguments(this.period);
|
||||
/// The period to filter by (e.g., "monthly", "weekly").
|
||||
final String period;
|
||||
/// ISO-8601 start date for the range filter.
|
||||
final String? startDate;
|
||||
|
||||
/// ISO-8601 end date for the range filter.
|
||||
final String? endDate;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[period];
|
||||
List<Object?> get props => <Object?>[startDate, endDate];
|
||||
}
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for Payments feature.
|
||||
/// Repository interface for the staff payments feature.
|
||||
///
|
||||
/// Defines the contract for data access related to staff payments.
|
||||
/// Implementations of this interface should reside in the data layer.
|
||||
/// Implementations live in the data layer and call the V2 REST API.
|
||||
abstract class PaymentsRepository {
|
||||
/// Fetches the payment summary (earnings).
|
||||
Future<PaymentSummary> getPaymentSummary();
|
||||
/// Fetches the aggregated payment summary for the given date range.
|
||||
Future<PaymentSummary> getPaymentSummary({
|
||||
String? startDate,
|
||||
String? endDate,
|
||||
});
|
||||
|
||||
/// Fetches the payment history for a specific period.
|
||||
Future<List<StaffPayment>> getPaymentHistory(String period);
|
||||
/// Fetches payment history records for the given date range.
|
||||
Future<List<PaymentRecord>> getPaymentHistory({
|
||||
String? startDate,
|
||||
String? endDate,
|
||||
});
|
||||
|
||||
/// Fetches aggregated chart data points for the given date range and bucket.
|
||||
Future<List<PaymentChartPoint>> getPaymentChart({
|
||||
String? startDate,
|
||||
String? endDate,
|
||||
String bucket,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_payments/src/domain/arguments/get_payment_chart_arguments.dart';
|
||||
import 'package:staff_payments/src/domain/repositories/payments_repository.dart';
|
||||
|
||||
/// Retrieves aggregated chart data for the current staff member's payments.
|
||||
class GetPaymentChartUseCase
|
||||
extends UseCase<GetPaymentChartArguments, List<PaymentChartPoint>> {
|
||||
/// Creates a [GetPaymentChartUseCase].
|
||||
GetPaymentChartUseCase(this._repository);
|
||||
|
||||
/// The payments repository.
|
||||
final PaymentsRepository _repository;
|
||||
|
||||
@override
|
||||
Future<List<PaymentChartPoint>> call(
|
||||
GetPaymentChartArguments arguments,
|
||||
) async {
|
||||
return _repository.getPaymentChart(
|
||||
startDate: arguments.startDate,
|
||||
endDate: arguments.endDate,
|
||||
bucket: arguments.bucket,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,25 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../arguments/get_payment_history_arguments.dart';
|
||||
import '../repositories/payments_repository.dart';
|
||||
|
||||
/// Use case to retrieve payment history filtered by a period.
|
||||
///
|
||||
/// This use case delegates the data retrieval to [PaymentsRepository].
|
||||
class GetPaymentHistoryUseCase extends UseCase<GetPaymentHistoryArguments, List<StaffPayment>> {
|
||||
import 'package:staff_payments/src/domain/arguments/get_payment_history_arguments.dart';
|
||||
import 'package:staff_payments/src/domain/repositories/payments_repository.dart';
|
||||
|
||||
/// Retrieves payment history records for the current staff member.
|
||||
class GetPaymentHistoryUseCase
|
||||
extends UseCase<GetPaymentHistoryArguments, List<PaymentRecord>> {
|
||||
/// Creates a [GetPaymentHistoryUseCase].
|
||||
GetPaymentHistoryUseCase(this.repository);
|
||||
final PaymentsRepository repository;
|
||||
GetPaymentHistoryUseCase(this._repository);
|
||||
|
||||
/// The payments repository.
|
||||
final PaymentsRepository _repository;
|
||||
|
||||
@override
|
||||
Future<List<StaffPayment>> call(GetPaymentHistoryArguments arguments) async {
|
||||
return await repository.getPaymentHistory(arguments.period);
|
||||
Future<List<PaymentRecord>> call(
|
||||
GetPaymentHistoryArguments arguments,
|
||||
) async {
|
||||
return _repository.getPaymentHistory(
|
||||
startDate: arguments.startDate,
|
||||
endDate: arguments.endDate,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/payments_repository.dart';
|
||||
|
||||
/// Use case to retrieve payment summary information.
|
||||
import 'package:staff_payments/src/domain/repositories/payments_repository.dart';
|
||||
|
||||
/// Retrieves the aggregated payment summary for the current staff member.
|
||||
class GetPaymentSummaryUseCase extends NoInputUseCase<PaymentSummary> {
|
||||
|
||||
/// Creates a [GetPaymentSummaryUseCase].
|
||||
GetPaymentSummaryUseCase(this.repository);
|
||||
final PaymentsRepository repository;
|
||||
GetPaymentSummaryUseCase(this._repository);
|
||||
|
||||
/// The payments repository.
|
||||
final PaymentsRepository _repository;
|
||||
|
||||
@override
|
||||
Future<PaymentSummary> call() async {
|
||||
return await repository.getPaymentSummary();
|
||||
return _repository.getPaymentSummary();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,50 @@
|
||||
import 'package:flutter/src/widgets/framework.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'domain/repositories/payments_repository.dart';
|
||||
import 'domain/usecases/get_payment_summary_usecase.dart';
|
||||
import 'domain/usecases/get_payment_history_usecase.dart';
|
||||
import 'data/repositories/payments_repository_impl.dart';
|
||||
import 'presentation/blocs/payments/payments_bloc.dart';
|
||||
import 'presentation/pages/payments_page.dart';
|
||||
import 'presentation/pages/early_pay_page.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_payments/src/data/repositories/payments_repository_impl.dart';
|
||||
import 'package:staff_payments/src/domain/repositories/payments_repository.dart';
|
||||
import 'package:staff_payments/src/domain/usecases/get_payment_chart_usecase.dart';
|
||||
import 'package:staff_payments/src/domain/usecases/get_payment_history_usecase.dart';
|
||||
import 'package:staff_payments/src/domain/usecases/get_payment_summary_usecase.dart';
|
||||
import 'package:staff_payments/src/presentation/blocs/payments/payments_bloc.dart';
|
||||
import 'package:staff_payments/src/presentation/pages/early_pay_page.dart';
|
||||
import 'package:staff_payments/src/presentation/pages/payments_page.dart';
|
||||
|
||||
/// Module for the staff payments feature.
|
||||
class StaffPaymentsModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.add<PaymentsRepository>(PaymentsRepositoryImpl.new);
|
||||
i.add<PaymentsRepository>(
|
||||
() => PaymentsRepositoryImpl(
|
||||
apiService: i.get<BaseApiService>(),
|
||||
),
|
||||
);
|
||||
|
||||
// Use Cases
|
||||
i.add(GetPaymentSummaryUseCase.new);
|
||||
i.add(GetPaymentHistoryUseCase.new);
|
||||
i.add<GetPaymentSummaryUseCase>(
|
||||
() => GetPaymentSummaryUseCase(i.get<PaymentsRepository>()),
|
||||
);
|
||||
i.add<GetPaymentHistoryUseCase>(
|
||||
() => GetPaymentHistoryUseCase(i.get<PaymentsRepository>()),
|
||||
);
|
||||
i.add<GetPaymentChartUseCase>(
|
||||
() => GetPaymentChartUseCase(i.get<PaymentsRepository>()),
|
||||
);
|
||||
|
||||
// Blocs
|
||||
i.add(PaymentsBloc.new);
|
||||
i.add<PaymentsBloc>(
|
||||
() => PaymentsBloc(
|
||||
getPaymentSummary: i.get<GetPaymentSummaryUseCase>(),
|
||||
getPaymentHistory: i.get<GetPaymentHistoryUseCase>(),
|
||||
getPaymentChart: i.get<GetPaymentChartUseCase>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../../domain/arguments/get_payment_history_arguments.dart';
|
||||
import '../../../domain/usecases/get_payment_history_usecase.dart';
|
||||
import '../../../domain/usecases/get_payment_summary_usecase.dart';
|
||||
import 'payments_event.dart';
|
||||
import 'payments_state.dart';
|
||||
|
||||
import 'package:staff_payments/src/domain/arguments/get_payment_chart_arguments.dart';
|
||||
import 'package:staff_payments/src/domain/arguments/get_payment_history_arguments.dart';
|
||||
import 'package:staff_payments/src/domain/usecases/get_payment_chart_usecase.dart';
|
||||
import 'package:staff_payments/src/domain/usecases/get_payment_history_usecase.dart';
|
||||
import 'package:staff_payments/src/domain/usecases/get_payment_summary_usecase.dart';
|
||||
import 'package:staff_payments/src/presentation/blocs/payments/payments_event.dart';
|
||||
import 'package:staff_payments/src/presentation/blocs/payments/payments_state.dart';
|
||||
|
||||
/// BLoC that manages loading and displaying staff payment data.
|
||||
class PaymentsBloc extends Bloc<PaymentsEvent, PaymentsState>
|
||||
with BlocErrorHandler<PaymentsState> {
|
||||
/// Creates a [PaymentsBloc] injecting the required use cases.
|
||||
PaymentsBloc({
|
||||
required this.getPaymentSummary,
|
||||
required this.getPaymentHistory,
|
||||
required this.getPaymentChart,
|
||||
}) : super(PaymentsInitial()) {
|
||||
on<LoadPaymentsEvent>(_onLoadPayments);
|
||||
on<ChangePeriodEvent>(_onChangePeriod);
|
||||
}
|
||||
|
||||
/// Use case for fetching the earnings summary.
|
||||
final GetPaymentSummaryUseCase getPaymentSummary;
|
||||
|
||||
/// Use case for fetching payment history records.
|
||||
final GetPaymentHistoryUseCase getPaymentHistory;
|
||||
|
||||
/// Use case for fetching chart data points.
|
||||
final GetPaymentChartUseCase getPaymentChart;
|
||||
|
||||
/// Handles the initial load of all payment data.
|
||||
Future<void> _onLoadPayments(
|
||||
LoadPaymentsEvent event,
|
||||
Emitter<PaymentsState> emit,
|
||||
@@ -27,15 +41,28 @@ class PaymentsBloc extends Bloc<PaymentsEvent, PaymentsState>
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final PaymentSummary currentSummary = await getPaymentSummary();
|
||||
|
||||
final List<StaffPayment> history = await getPaymentHistory(
|
||||
const GetPaymentHistoryArguments('week'),
|
||||
);
|
||||
final _DateRange range = _dateRangeFor('week');
|
||||
final List<Object> results = await Future.wait(<Future<Object>>[
|
||||
getPaymentSummary(),
|
||||
getPaymentHistory(
|
||||
GetPaymentHistoryArguments(
|
||||
startDate: range.start,
|
||||
endDate: range.end,
|
||||
),
|
||||
),
|
||||
getPaymentChart(
|
||||
GetPaymentChartArguments(
|
||||
startDate: range.start,
|
||||
endDate: range.end,
|
||||
bucket: 'day',
|
||||
),
|
||||
),
|
||||
]);
|
||||
emit(
|
||||
PaymentsLoaded(
|
||||
summary: currentSummary,
|
||||
history: history,
|
||||
summary: results[0] as PaymentSummary,
|
||||
history: results[1] as List<PaymentRecord>,
|
||||
chartPoints: results[2] as List<PaymentChartPoint>,
|
||||
activePeriod: 'week',
|
||||
),
|
||||
);
|
||||
@@ -44,6 +71,7 @@ class PaymentsBloc extends Bloc<PaymentsEvent, PaymentsState>
|
||||
);
|
||||
}
|
||||
|
||||
/// Handles switching the active period tab.
|
||||
Future<void> _onChangePeriod(
|
||||
ChangePeriodEvent event,
|
||||
Emitter<PaymentsState> emit,
|
||||
@@ -53,12 +81,27 @@ class PaymentsBloc extends Bloc<PaymentsEvent, PaymentsState>
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final List<StaffPayment> newHistory = await getPaymentHistory(
|
||||
GetPaymentHistoryArguments(event.period),
|
||||
);
|
||||
final _DateRange range = _dateRangeFor(event.period);
|
||||
final String bucket = _bucketFor(event.period);
|
||||
final List<Object> results = await Future.wait(<Future<Object>>[
|
||||
getPaymentHistory(
|
||||
GetPaymentHistoryArguments(
|
||||
startDate: range.start,
|
||||
endDate: range.end,
|
||||
),
|
||||
),
|
||||
getPaymentChart(
|
||||
GetPaymentChartArguments(
|
||||
startDate: range.start,
|
||||
endDate: range.end,
|
||||
bucket: bucket,
|
||||
),
|
||||
),
|
||||
]);
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
history: newHistory,
|
||||
history: results[0] as List<PaymentRecord>,
|
||||
chartPoints: results[1] as List<PaymentChartPoint>,
|
||||
activePeriod: event.period,
|
||||
),
|
||||
);
|
||||
@@ -67,5 +110,46 @@ class PaymentsBloc extends Bloc<PaymentsEvent, PaymentsState>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes start and end ISO-8601 date strings for a given period.
|
||||
static _DateRange _dateRangeFor(String period) {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime end = now;
|
||||
late final DateTime start;
|
||||
switch (period) {
|
||||
case 'week':
|
||||
start = now.subtract(const Duration(days: 7));
|
||||
case 'month':
|
||||
start = DateTime(now.year, now.month - 1, now.day);
|
||||
case 'year':
|
||||
start = DateTime(now.year - 1, now.month, now.day);
|
||||
default:
|
||||
start = now.subtract(const Duration(days: 7));
|
||||
}
|
||||
return _DateRange(
|
||||
start: start.toIso8601String(),
|
||||
end: end.toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Maps a period identifier to the chart bucket granularity.
|
||||
static String _bucketFor(String period) {
|
||||
switch (period) {
|
||||
case 'week':
|
||||
return 'day';
|
||||
case 'month':
|
||||
return 'week';
|
||||
case 'year':
|
||||
return 'month';
|
||||
default:
|
||||
return 'day';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal helper for holding a date range pair.
|
||||
class _DateRange {
|
||||
const _DateRange({required this.start, required this.end});
|
||||
final String start;
|
||||
final String end;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Base event for the payments feature.
|
||||
abstract class PaymentsEvent extends Equatable {
|
||||
/// Creates a [PaymentsEvent].
|
||||
const PaymentsEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
/// Triggered on initial load to fetch summary, history, and chart data.
|
||||
class LoadPaymentsEvent extends PaymentsEvent {}
|
||||
|
||||
/// Triggered when the user switches the period tab (week, month, year).
|
||||
class ChangePeriodEvent extends PaymentsEvent {
|
||||
|
||||
/// Creates a [ChangePeriodEvent] for the given [period].
|
||||
const ChangePeriodEvent(this.period);
|
||||
|
||||
/// The selected period identifier.
|
||||
final String period;
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,47 +1,69 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Base state for the payments feature.
|
||||
abstract class PaymentsState extends Equatable {
|
||||
/// Creates a [PaymentsState].
|
||||
const PaymentsState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[];
|
||||
}
|
||||
|
||||
/// Initial state before any data has been requested.
|
||||
class PaymentsInitial extends PaymentsState {}
|
||||
|
||||
/// Data is being loaded from the backend.
|
||||
class PaymentsLoading extends PaymentsState {}
|
||||
|
||||
/// Data loaded successfully.
|
||||
class PaymentsLoaded extends PaymentsState {
|
||||
|
||||
/// Creates a [PaymentsLoaded] state.
|
||||
const PaymentsLoaded({
|
||||
required this.summary,
|
||||
required this.history,
|
||||
required this.chartPoints,
|
||||
this.activePeriod = 'week',
|
||||
});
|
||||
|
||||
/// Aggregated payment summary.
|
||||
final PaymentSummary summary;
|
||||
final List<StaffPayment> history;
|
||||
|
||||
/// List of individual payment records.
|
||||
final List<PaymentRecord> history;
|
||||
|
||||
/// Chart data points for the earnings trend graph.
|
||||
final List<PaymentChartPoint> chartPoints;
|
||||
|
||||
/// Currently selected period tab (week, month, year).
|
||||
final String activePeriod;
|
||||
|
||||
/// Creates a copy with optional overrides.
|
||||
PaymentsLoaded copyWith({
|
||||
PaymentSummary? summary,
|
||||
List<StaffPayment>? history,
|
||||
List<PaymentRecord>? history,
|
||||
List<PaymentChartPoint>? chartPoints,
|
||||
String? activePeriod,
|
||||
}) {
|
||||
return PaymentsLoaded(
|
||||
summary: summary ?? this.summary,
|
||||
history: history ?? this.history,
|
||||
chartPoints: chartPoints ?? this.chartPoints,
|
||||
activePeriod: activePeriod ?? this.activePeriod,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[summary, history, activePeriod];
|
||||
List<Object?> get props =>
|
||||
<Object?>[summary, history, chartPoints, activePeriod];
|
||||
}
|
||||
|
||||
/// An error occurred while loading payments data.
|
||||
class PaymentsError extends PaymentsState {
|
||||
|
||||
/// Creates a [PaymentsError] with the given [message].
|
||||
const PaymentsError(this.message);
|
||||
|
||||
/// The error key or message.
|
||||
final String message;
|
||||
|
||||
@override
|
||||
|
||||
@@ -5,15 +5,18 @@ import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import '../blocs/payments/payments_bloc.dart';
|
||||
import '../blocs/payments/payments_event.dart';
|
||||
import '../blocs/payments/payments_state.dart';
|
||||
import '../widgets/payments_page_skeleton.dart';
|
||||
import '../widgets/payment_stats_card.dart';
|
||||
import '../widgets/payment_history_item.dart';
|
||||
import '../widgets/earnings_graph.dart';
|
||||
|
||||
import 'package:staff_payments/src/presentation/blocs/payments/payments_bloc.dart';
|
||||
import 'package:staff_payments/src/presentation/blocs/payments/payments_event.dart';
|
||||
import 'package:staff_payments/src/presentation/blocs/payments/payments_state.dart';
|
||||
import 'package:staff_payments/src/presentation/widgets/payments_page_skeleton.dart';
|
||||
import 'package:staff_payments/src/presentation/widgets/payment_stats_card.dart';
|
||||
import 'package:staff_payments/src/presentation/widgets/payment_history_item.dart';
|
||||
import 'package:staff_payments/src/presentation/widgets/earnings_graph.dart';
|
||||
|
||||
/// Main page for the staff payments feature.
|
||||
class PaymentsPage extends StatefulWidget {
|
||||
/// Creates a [PaymentsPage].
|
||||
const PaymentsPage({super.key});
|
||||
|
||||
@override
|
||||
@@ -38,12 +41,11 @@ class _PaymentsPageState extends State<PaymentsPage> {
|
||||
backgroundColor: UiColors.background,
|
||||
body: BlocConsumer<PaymentsBloc, PaymentsState>(
|
||||
listener: (BuildContext context, PaymentsState state) {
|
||||
// Error is already shown on the page itself (lines 53-63), no need for snackbar
|
||||
// Error is rendered inline, no snackbar needed.
|
||||
},
|
||||
builder: (BuildContext context, PaymentsState state) {
|
||||
if (state is PaymentsLoading) {
|
||||
return const PaymentsPageSkeleton();
|
||||
|
||||
} else if (state is PaymentsError) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
@@ -51,7 +53,8 @@ class _PaymentsPageState extends State<PaymentsPage> {
|
||||
child: Text(
|
||||
translateErrorKey(state.message),
|
||||
textAlign: TextAlign.center,
|
||||
style: UiTypography.body2r.copyWith(color: UiColors.textSecondary),
|
||||
style: UiTypography.body2r
|
||||
.copyWith(color: UiColors.textSecondary),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -65,7 +68,10 @@ class _PaymentsPageState extends State<PaymentsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the loaded content layout.
|
||||
Widget _buildContent(BuildContext context, PaymentsLoaded state) {
|
||||
final String totalFormatted =
|
||||
_formatCents(state.summary.totalEarningsCents);
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
@@ -91,7 +97,7 @@ class _PaymentsPageState extends State<PaymentsPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
"Earnings",
|
||||
'Earnings',
|
||||
style: UiTypography.displayMb.white,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
@@ -101,14 +107,14 @@ class _PaymentsPageState extends State<PaymentsPage> {
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
"Total Earnings",
|
||||
'Total Earnings',
|
||||
style: UiTypography.body2r.copyWith(
|
||||
color: UiColors.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
"\$${state.summary.totalEarnings.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]},')}",
|
||||
totalFormatted,
|
||||
style: UiTypography.displayL.white,
|
||||
),
|
||||
],
|
||||
@@ -121,13 +127,14 @@ class _PaymentsPageState extends State<PaymentsPage> {
|
||||
padding: const EdgeInsets.all(UiConstants.space1),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
borderRadius:
|
||||
BorderRadius.circular(UiConstants.radiusBase),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
_buildTab("Week", 'week', state.activePeriod),
|
||||
_buildTab("Month", 'month', state.activePeriod),
|
||||
_buildTab("Year", 'year', state.activePeriod),
|
||||
_buildTab('Week', 'week', state.activePeriod),
|
||||
_buildTab('Month', 'month', state.activePeriod),
|
||||
_buildTab('Year', 'year', state.activePeriod),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -139,16 +146,18 @@ class _PaymentsPageState extends State<PaymentsPage> {
|
||||
Transform.translate(
|
||||
offset: const Offset(0, -UiConstants.space4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// Earnings Graph
|
||||
EarningsGraph(
|
||||
payments: state.history,
|
||||
chartPoints: state.chartPoints,
|
||||
period: state.activePeriod,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Quick Stats
|
||||
Row(
|
||||
children: <Widget>[
|
||||
@@ -156,8 +165,8 @@ class _PaymentsPageState extends State<PaymentsPage> {
|
||||
child: PaymentStatsCard(
|
||||
icon: UiIcons.chart,
|
||||
iconColor: UiColors.success,
|
||||
label: "This Week",
|
||||
amount: "\$${state.summary.weeklyEarnings}",
|
||||
label: 'Total Earnings',
|
||||
amount: totalFormatted,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
@@ -165,8 +174,14 @@ class _PaymentsPageState extends State<PaymentsPage> {
|
||||
child: PaymentStatsCard(
|
||||
icon: UiIcons.calendar,
|
||||
iconColor: UiColors.primary,
|
||||
label: "This Month",
|
||||
amount: "\$${state.summary.monthlyEarnings.toStringAsFixed(0)}",
|
||||
label: '${state.history.length} Payments',
|
||||
amount: _formatCents(
|
||||
state.history.fold<int>(
|
||||
0,
|
||||
(int sum, PaymentRecord r) =>
|
||||
sum + r.amountCents,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -179,28 +194,26 @@ class _PaymentsPageState extends State<PaymentsPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
"Recent Payments",
|
||||
'Recent Payments',
|
||||
style: UiTypography.body1b,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Column(
|
||||
children: state.history.map((StaffPayment payment) {
|
||||
children:
|
||||
state.history.map((PaymentRecord payment) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: UiConstants.space2),
|
||||
child: PaymentHistoryItem(
|
||||
amount: payment.amount,
|
||||
title: payment.shiftTitle ?? "Shift Payment",
|
||||
location: payment.shiftLocation ?? "Varies",
|
||||
address: payment.locationAddress ?? payment.id,
|
||||
date: payment.paidAt != null
|
||||
? DateFormat('E, MMM d')
|
||||
.format(payment.paidAt!)
|
||||
: 'Pending',
|
||||
workedTime: payment.workedTime ?? "Completed",
|
||||
hours: (payment.hoursWorked ?? 0).toInt(),
|
||||
rate: payment.hourlyRate ?? 0.0,
|
||||
status: payment.status.name.toUpperCase(),
|
||||
amountCents: payment.amountCents,
|
||||
title: payment.shiftName ?? 'Shift Payment',
|
||||
location: payment.location ?? 'Varies',
|
||||
date:
|
||||
DateFormat('E, MMM d').format(payment.date),
|
||||
minutesWorked: payment.minutesWorked ?? 0,
|
||||
hourlyRateCents:
|
||||
payment.hourlyRateCents ?? 0,
|
||||
status: payment.status,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
@@ -218,16 +231,19 @@ class _PaymentsPageState extends State<PaymentsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a period tab widget.
|
||||
Widget _buildTab(String label, String value, String activePeriod) {
|
||||
final bool isSelected = activePeriod == value;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => _bloc.add(ChangePeriodEvent(value)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: UiConstants.space2),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: UiConstants.space2),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? UiColors.white : UiColors.transparent,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||
borderRadius:
|
||||
BorderRadius.circular(UiConstants.radiusMdValue),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
@@ -241,5 +257,14 @@ class _PaymentsPageState extends State<PaymentsPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats an amount in cents to a dollar string (e.g. `$1,234.56`).
|
||||
static String _formatCents(int cents) {
|
||||
final double dollars = cents / 100;
|
||||
final NumberFormat formatter = NumberFormat.currency(
|
||||
symbol: r'$',
|
||||
decimalDigits: 2,
|
||||
);
|
||||
return formatter.format(dollars);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,25 +4,24 @@ import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Displays an earnings trend line chart from backend chart data.
|
||||
class EarningsGraph extends StatelessWidget {
|
||||
|
||||
/// Creates an [EarningsGraph].
|
||||
const EarningsGraph({
|
||||
super.key,
|
||||
required this.payments,
|
||||
required this.chartPoints,
|
||||
required this.period,
|
||||
});
|
||||
final List<StaffPayment> payments;
|
||||
|
||||
/// Pre-aggregated chart data points from the V2 API.
|
||||
final List<PaymentChartPoint> chartPoints;
|
||||
|
||||
/// The currently selected period (week, month, year).
|
||||
final String period;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Basic data processing for the graph
|
||||
// We'll aggregate payments by date
|
||||
final List<StaffPayment> validPayments = payments.where((StaffPayment p) => p.paidAt != null).toList()
|
||||
..sort((StaffPayment a, StaffPayment b) => a.paidAt!.compareTo(b.paidAt!));
|
||||
|
||||
// If no data, show empty state or simple placeholder
|
||||
if (validPayments.isEmpty) {
|
||||
if (chartPoints.isEmpty) {
|
||||
return Container(
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
@@ -31,15 +30,23 @@ class EarningsGraph extends StatelessWidget {
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
"No sufficient data for graph",
|
||||
'No sufficient data for graph',
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final List<FlSpot> spots = _generateSpots(validPayments);
|
||||
final double maxY = spots.isNotEmpty ? spots.map((FlSpot s) => s.y).reduce((double a, double b) => a > b ? a : b) : 0.0;
|
||||
final List<PaymentChartPoint> sorted = List<PaymentChartPoint>.of(chartPoints)
|
||||
..sort((PaymentChartPoint a, PaymentChartPoint b) =>
|
||||
a.bucket.compareTo(b.bucket));
|
||||
|
||||
final List<FlSpot> spots = _generateSpots(sorted);
|
||||
final double maxY = spots.isNotEmpty
|
||||
? spots
|
||||
.map((FlSpot s) => s.y)
|
||||
.reduce((double a, double b) => a > b ? a : b)
|
||||
: 0.0;
|
||||
|
||||
return Container(
|
||||
height: 220,
|
||||
@@ -59,7 +66,7 @@ class EarningsGraph extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
"Earnings Trend",
|
||||
'Earnings Trend',
|
||||
style: UiTypography.body2b.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
@@ -71,26 +78,31 @@ class EarningsGraph extends StatelessWidget {
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (double value, TitleMeta meta) {
|
||||
// Simple logic to show a few dates
|
||||
if (value % 2 != 0) return const SizedBox();
|
||||
final int index = value.toInt();
|
||||
if (index >= 0 && index < validPayments.length) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
DateFormat('d').format(validPayments[index].paidAt!),
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox();
|
||||
getTitlesWidget:
|
||||
(double value, TitleMeta meta) {
|
||||
if (value % 2 != 0) return const SizedBox();
|
||||
final int index = value.toInt();
|
||||
if (index >= 0 && index < sorted.length) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
_formatBucketLabel(
|
||||
sorted[index].bucket, period),
|
||||
style:
|
||||
UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
leftTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false)),
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false)),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
lineBarsData: <LineChartBarData>[
|
||||
@@ -119,20 +131,32 @@ class EarningsGraph extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
List<FlSpot> _generateSpots(List<StaffPayment> data) {
|
||||
if (data.isEmpty) return [];
|
||||
|
||||
// If only one data point, add a dummy point at the start to create a horizontal line
|
||||
/// Converts chart points to [FlSpot] values (dollars).
|
||||
List<FlSpot> _generateSpots(List<PaymentChartPoint> data) {
|
||||
if (data.isEmpty) return <FlSpot>[];
|
||||
|
||||
if (data.length == 1) {
|
||||
return [
|
||||
FlSpot(0, data[0].amount),
|
||||
FlSpot(1, data[0].amount),
|
||||
final double dollars = data[0].amountCents / 100;
|
||||
return <FlSpot>[
|
||||
FlSpot(0, dollars),
|
||||
FlSpot(1, dollars),
|
||||
];
|
||||
}
|
||||
|
||||
// Generate spots based on index in the list for simplicity in this demo
|
||||
return List<FlSpot>.generate(data.length, (int index) {
|
||||
return FlSpot(index.toDouble(), data[index].amount);
|
||||
return FlSpot(index.toDouble(), data[index].amountCents / 100);
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns a short label for a chart bucket date.
|
||||
String _formatBucketLabel(DateTime bucket, String period) {
|
||||
switch (period) {
|
||||
case 'year':
|
||||
return DateFormat('MMM').format(bucket);
|
||||
case 'month':
|
||||
return DateFormat('d').format(bucket);
|
||||
default:
|
||||
return DateFormat('d').format(bucket);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,53 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Displays a single payment record in the history list.
|
||||
class PaymentHistoryItem extends StatelessWidget {
|
||||
|
||||
/// Creates a [PaymentHistoryItem].
|
||||
const PaymentHistoryItem({
|
||||
super.key,
|
||||
required this.amount,
|
||||
required this.amountCents,
|
||||
required this.title,
|
||||
required this.location,
|
||||
required this.address,
|
||||
required this.date,
|
||||
required this.workedTime,
|
||||
required this.hours,
|
||||
required this.rate,
|
||||
required this.minutesWorked,
|
||||
required this.hourlyRateCents,
|
||||
required this.status,
|
||||
});
|
||||
final double amount;
|
||||
|
||||
/// Payment amount in cents.
|
||||
final int amountCents;
|
||||
|
||||
/// Shift or payment title.
|
||||
final String title;
|
||||
|
||||
/// Location / hub name.
|
||||
final String location;
|
||||
final String address;
|
||||
|
||||
/// Formatted date string.
|
||||
final String date;
|
||||
final String workedTime;
|
||||
final int hours;
|
||||
final double rate;
|
||||
final String status;
|
||||
|
||||
/// Total minutes worked.
|
||||
final int minutesWorked;
|
||||
|
||||
/// Hourly rate in cents.
|
||||
final int hourlyRateCents;
|
||||
|
||||
/// Payment processing status.
|
||||
final PaymentStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String dollarAmount = _centsToDollars(amountCents);
|
||||
final String rateDisplay = _centsToDollars(hourlyRateCents);
|
||||
final int hours = minutesWorked ~/ 60;
|
||||
final int mins = minutesWorked % 60;
|
||||
final String timeDisplay =
|
||||
mins > 0 ? '${hours}h ${mins}m' : '${hours}h';
|
||||
final Color statusColor = _statusColor(status);
|
||||
final String statusLabel = status.value;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
@@ -43,16 +64,16 @@ class PaymentHistoryItem extends StatelessWidget {
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.primary,
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
"PAID",
|
||||
statusLabel,
|
||||
style: UiTypography.titleUppercase4b.copyWith(
|
||||
color: UiColors.primary,
|
||||
color: statusColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -68,7 +89,8 @@ class PaymentHistoryItem extends StatelessWidget {
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.secondary,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
borderRadius:
|
||||
BorderRadius.circular(UiConstants.radiusBase),
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.dollar,
|
||||
@@ -90,10 +112,7 @@ class PaymentHistoryItem extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.body2m,
|
||||
),
|
||||
Text(title, style: UiTypography.body2m),
|
||||
Text(
|
||||
location,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
@@ -105,12 +124,13 @@ class PaymentHistoryItem extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
"\$${amount.toStringAsFixed(0)}",
|
||||
dollarAmount,
|
||||
style: UiTypography.headline4b,
|
||||
),
|
||||
Text(
|
||||
"\$${rate.toStringAsFixed(0)}/hr · ${hours}h",
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
'$rateDisplay/hr \u00B7 $timeDisplay',
|
||||
style:
|
||||
UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -118,7 +138,7 @@ class PaymentHistoryItem extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
|
||||
// Date and Time
|
||||
// Date
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
@@ -139,32 +159,11 @@ class PaymentHistoryItem extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
workedTime,
|
||||
timeDisplay,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
|
||||
// Address
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: 12,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: Text(
|
||||
address,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -174,4 +173,26 @@ class PaymentHistoryItem extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Converts cents to a formatted dollar string.
|
||||
static String _centsToDollars(int cents) {
|
||||
final double dollars = cents / 100;
|
||||
return '\$${dollars.toStringAsFixed(2)}';
|
||||
}
|
||||
|
||||
/// Returns a colour for the given payment status.
|
||||
static Color _statusColor(PaymentStatus status) {
|
||||
switch (status) {
|
||||
case PaymentStatus.paid:
|
||||
return UiColors.primary;
|
||||
case PaymentStatus.pending:
|
||||
return UiColors.textWarning;
|
||||
case PaymentStatus.processing:
|
||||
return UiColors.primary;
|
||||
case PaymentStatus.failed:
|
||||
return UiColors.error;
|
||||
case PaymentStatus.unknown:
|
||||
return UiColors.mutedForeground;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,9 @@ dependencies:
|
||||
path: ../../../domain
|
||||
krow_core:
|
||||
path: ../../../core
|
||||
krow_data_connect:
|
||||
path: ../../../data_connect
|
||||
|
||||
flutter:
|
||||
sdk: flutter
|
||||
firebase_data_connect: ^0.2.2+2
|
||||
firebase_auth: ^6.1.4
|
||||
flutter_modular: ^6.3.2
|
||||
intl: ^0.20.0
|
||||
fl_chart: ^0.66.0
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository implementation for the main profile page.
|
||||
///
|
||||
/// Uses the V2 API to fetch staff profile, section statuses, and completion.
|
||||
class ProfileRepositoryImpl {
|
||||
/// Creates a [ProfileRepositoryImpl].
|
||||
ProfileRepositoryImpl({required BaseApiService apiService})
|
||||
: _api = apiService;
|
||||
|
||||
final BaseApiService _api;
|
||||
|
||||
/// Fetches the staff profile from the V2 session endpoint.
|
||||
Future<Staff> getStaffProfile() async {
|
||||
final ApiResponse response =
|
||||
await _api.get(V2ApiEndpoints.staffSession);
|
||||
final Map<String, dynamic> json =
|
||||
response.data['staff'] as Map<String, dynamic>;
|
||||
return Staff.fromJson(json);
|
||||
}
|
||||
|
||||
/// Fetches the profile section completion statuses.
|
||||
Future<ProfileSectionStatus> getProfileSections() async {
|
||||
final ApiResponse response =
|
||||
await _api.get(V2ApiEndpoints.staffProfileSections);
|
||||
final Map<String, dynamic> json =
|
||||
response.data as Map<String, dynamic>;
|
||||
return ProfileSectionStatus.fromJson(json);
|
||||
}
|
||||
|
||||
/// Signs out the current user.
|
||||
Future<void> signOut() async {
|
||||
await _api.post(V2ApiEndpoints.signOut);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +1,57 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'profile_state.dart';
|
||||
import 'package:staff_profile/src/data/repositories/profile_repository_impl.dart';
|
||||
import 'package:staff_profile/src/presentation/blocs/profile_state.dart';
|
||||
|
||||
/// Cubit for managing the Profile feature state.
|
||||
///
|
||||
/// Handles loading profile data and user sign-out actions.
|
||||
/// Uses the V2 API via [ProfileRepositoryImpl] for all data fetching.
|
||||
/// Loads the staff profile and section completion statuses in a single flow.
|
||||
class ProfileCubit extends Cubit<ProfileState>
|
||||
with BlocErrorHandler<ProfileState> {
|
||||
/// Creates a [ProfileCubit] with the required repository.
|
||||
ProfileCubit(this._repository) : super(const ProfileState());
|
||||
|
||||
/// Creates a [ProfileCubit] with the required use cases.
|
||||
ProfileCubit(
|
||||
this._getProfileUseCase,
|
||||
this._signOutUseCase,
|
||||
this._getPersonalInfoCompletionUseCase,
|
||||
this._getEmergencyContactsCompletionUseCase,
|
||||
this._getExperienceCompletionUseCase,
|
||||
this._getTaxFormsCompletionUseCase,
|
||||
this._getAttireOptionsCompletionUseCase,
|
||||
this._getStaffDocumentsCompletionUseCase,
|
||||
this._getStaffCertificatesCompletionUseCase,
|
||||
) : super(const ProfileState());
|
||||
final GetStaffProfileUseCase _getProfileUseCase;
|
||||
final SignOutStaffUseCase _signOutUseCase;
|
||||
final GetPersonalInfoCompletionUseCase _getPersonalInfoCompletionUseCase;
|
||||
final GetEmergencyContactsCompletionUseCase _getEmergencyContactsCompletionUseCase;
|
||||
final GetExperienceCompletionUseCase _getExperienceCompletionUseCase;
|
||||
final GetTaxFormsCompletionUseCase _getTaxFormsCompletionUseCase;
|
||||
final GetAttireOptionsCompletionUseCase _getAttireOptionsCompletionUseCase;
|
||||
final GetStaffDocumentsCompletionUseCase _getStaffDocumentsCompletionUseCase;
|
||||
final GetStaffCertificatesCompletionUseCase _getStaffCertificatesCompletionUseCase;
|
||||
final ProfileRepositoryImpl _repository;
|
||||
|
||||
/// Loads the staff member's profile.
|
||||
///
|
||||
/// Emits [ProfileStatus.loading] while fetching data,
|
||||
/// then [ProfileStatus.loaded] with the profile data on success,
|
||||
/// or [ProfileStatus.error] if an error occurs.
|
||||
Future<void> loadProfile() async {
|
||||
emit(state.copyWith(status: ProfileStatus.loading));
|
||||
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final Staff profile = await _getProfileUseCase();
|
||||
final Staff profile = await _repository.getStaffProfile();
|
||||
emit(state.copyWith(status: ProfileStatus.loaded, profile: profile));
|
||||
},
|
||||
onError:
|
||||
(String errorKey) =>
|
||||
state.copyWith(status: ProfileStatus.error, errorMessage: errorKey),
|
||||
onError: (String errorKey) =>
|
||||
state.copyWith(status: ProfileStatus.error, errorMessage: errorKey),
|
||||
);
|
||||
}
|
||||
|
||||
/// Loads all profile section completion statuses in a single V2 API call.
|
||||
Future<void> loadSectionStatuses() async {
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final ProfileSectionStatus sections =
|
||||
await _repository.getProfileSections();
|
||||
emit(state.copyWith(
|
||||
personalInfoComplete: sections.personalInfoCompleted,
|
||||
emergencyContactsComplete: sections.emergencyContactCompleted,
|
||||
experienceComplete: sections.experienceCompleted,
|
||||
taxFormsComplete: sections.taxFormsCompleted,
|
||||
attireComplete: sections.attireCompleted,
|
||||
certificatesComplete: sections.certificateCount > 0,
|
||||
));
|
||||
},
|
||||
onError: (String _) => state,
|
||||
);
|
||||
}
|
||||
|
||||
/// Signs out the current user.
|
||||
///
|
||||
/// Delegates to the sign-out use case which handles session cleanup
|
||||
/// and navigation.
|
||||
Future<void> signOut() async {
|
||||
if (state.status == ProfileStatus.loading) {
|
||||
return;
|
||||
@@ -67,116 +62,11 @@ class ProfileCubit extends Cubit<ProfileState>
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
await _signOutUseCase();
|
||||
await _repository.signOut();
|
||||
emit(state.copyWith(status: ProfileStatus.signedOut));
|
||||
},
|
||||
onError: (String _) {
|
||||
// For sign out errors, we might want to just proceed or show error
|
||||
// Current implementation was silent catch, let's keep it robust but consistent
|
||||
// If we want to force navigation even on error, we would do it here
|
||||
// But usually handleError emits the error state.
|
||||
// Let's stick to standard error reporting for now.
|
||||
return state.copyWith(status: ProfileStatus.error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Loads personal information completion status.
|
||||
Future<void> loadPersonalInfoCompletion() async {
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final bool isComplete = await _getPersonalInfoCompletionUseCase();
|
||||
emit(state.copyWith(personalInfoComplete: isComplete));
|
||||
},
|
||||
onError: (String _) {
|
||||
return state.copyWith(personalInfoComplete: false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Loads emergency contacts completion status.
|
||||
Future<void> loadEmergencyContactsCompletion() async {
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final bool isComplete = await _getEmergencyContactsCompletionUseCase();
|
||||
emit(state.copyWith(emergencyContactsComplete: isComplete));
|
||||
},
|
||||
onError: (String _) {
|
||||
return state.copyWith(emergencyContactsComplete: false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Loads experience completion status.
|
||||
Future<void> loadExperienceCompletion() async {
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final bool isComplete = await _getExperienceCompletionUseCase();
|
||||
emit(state.copyWith(experienceComplete: isComplete));
|
||||
},
|
||||
onError: (String _) {
|
||||
return state.copyWith(experienceComplete: false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Loads tax forms completion status.
|
||||
Future<void> loadTaxFormsCompletion() async {
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final bool isComplete = await _getTaxFormsCompletionUseCase();
|
||||
emit(state.copyWith(taxFormsComplete: isComplete));
|
||||
},
|
||||
onError: (String _) {
|
||||
return state.copyWith(taxFormsComplete: false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Loads attire options completion status.
|
||||
Future<void> loadAttireCompletion() async {
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final bool? isComplete = await _getAttireOptionsCompletionUseCase();
|
||||
emit(state.copyWith(attireComplete: isComplete));
|
||||
},
|
||||
onError: (String _) {
|
||||
return state.copyWith(attireComplete: false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Loads documents completion status.
|
||||
Future<void> loadDocumentsCompletion() async {
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final bool? isComplete = await _getStaffDocumentsCompletionUseCase();
|
||||
emit(state.copyWith(documentsComplete: isComplete));
|
||||
},
|
||||
onError: (String _) {
|
||||
return state.copyWith(documentsComplete: false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Loads certificates completion status.
|
||||
Future<void> loadCertificatesCompletion() async {
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final bool? isComplete = await _getStaffCertificatesCompletionUseCase();
|
||||
emit(state.copyWith(certificatesComplete: isComplete));
|
||||
},
|
||||
onError: (String _) {
|
||||
return state.copyWith(certificatesComplete: false);
|
||||
},
|
||||
onError: (String _) =>
|
||||
state.copyWith(status: ProfileStatus.error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,22 +6,19 @@ import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../blocs/profile_cubit.dart';
|
||||
import '../blocs/profile_state.dart';
|
||||
import '../widgets/logout_button.dart';
|
||||
import '../widgets/header/profile_header.dart';
|
||||
import '../widgets/profile_page_skeleton/profile_page_skeleton.dart';
|
||||
import '../widgets/reliability_score_bar.dart';
|
||||
import '../widgets/reliability_stats_card.dart';
|
||||
import '../widgets/sections/index.dart';
|
||||
import 'package:staff_profile/src/presentation/blocs/profile_cubit.dart';
|
||||
import 'package:staff_profile/src/presentation/blocs/profile_state.dart';
|
||||
import 'package:staff_profile/src/presentation/widgets/logout_button.dart';
|
||||
import 'package:staff_profile/src/presentation/widgets/header/profile_header.dart';
|
||||
import 'package:staff_profile/src/presentation/widgets/profile_page_skeleton/profile_page_skeleton.dart';
|
||||
import 'package:staff_profile/src/presentation/widgets/reliability_score_bar.dart';
|
||||
import 'package:staff_profile/src/presentation/widgets/reliability_stats_card.dart';
|
||||
import 'package:staff_profile/src/presentation/widgets/sections/index.dart';
|
||||
|
||||
/// The main Staff Profile page.
|
||||
///
|
||||
/// This page displays the staff member's profile including their stats,
|
||||
/// reliability score, and various menu sections for onboarding, compliance,
|
||||
/// learning, finance, and support.
|
||||
///
|
||||
/// It follows Clean Architecture with BLoC for state management.
|
||||
/// Displays the staff member's profile, reliability stats, and
|
||||
/// various menu sections. Uses V2 API via [ProfileCubit].
|
||||
class StaffProfilePage extends StatelessWidget {
|
||||
/// Creates a [StaffProfilePage].
|
||||
const StaffProfilePage({super.key});
|
||||
@@ -40,16 +37,10 @@ class StaffProfilePage extends StatelessWidget {
|
||||
value: cubit,
|
||||
child: BlocConsumer<ProfileCubit, ProfileState>(
|
||||
listener: (BuildContext context, ProfileState state) {
|
||||
// Load completion statuses when profile loads successfully
|
||||
// Load section statuses when profile loads successfully
|
||||
if (state.status == ProfileStatus.loaded &&
|
||||
state.personalInfoComplete == null) {
|
||||
cubit.loadPersonalInfoCompletion();
|
||||
cubit.loadEmergencyContactsCompletion();
|
||||
cubit.loadExperienceCompletion();
|
||||
cubit.loadTaxFormsCompletion();
|
||||
cubit.loadAttireCompletion();
|
||||
cubit.loadDocumentsCompletion();
|
||||
cubit.loadCertificatesCompletion();
|
||||
cubit.loadSectionStatuses();
|
||||
}
|
||||
|
||||
if (state.status == ProfileStatus.signedOut) {
|
||||
@@ -64,7 +55,6 @@ class StaffProfilePage extends StatelessWidget {
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, ProfileState state) {
|
||||
// Show shimmer skeleton while profile data loads
|
||||
if (state.status == ProfileStatus.loading) {
|
||||
return const ProfilePageSkeleton();
|
||||
}
|
||||
@@ -96,8 +86,8 @@ class StaffProfilePage extends StatelessWidget {
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
ProfileHeader(
|
||||
fullName: profile.name,
|
||||
photoUrl: profile.avatar,
|
||||
fullName: profile.fullName,
|
||||
photoUrl: null,
|
||||
),
|
||||
Transform.translate(
|
||||
offset: const Offset(0, -UiConstants.space6),
|
||||
@@ -108,33 +98,27 @@ class StaffProfilePage extends StatelessWidget {
|
||||
child: Column(
|
||||
spacing: UiConstants.space6,
|
||||
children: <Widget>[
|
||||
// Reliability Stats and Score
|
||||
// Reliability Stats
|
||||
ReliabilityStatsCard(
|
||||
totalShifts: profile.totalShifts,
|
||||
totalShifts: 0,
|
||||
averageRating: profile.averageRating,
|
||||
onTimeRate: profile.onTimeRate,
|
||||
noShowCount: profile.noShowCount,
|
||||
cancellationCount: profile.cancellationCount,
|
||||
onTimeRate: 0,
|
||||
noShowCount: 0,
|
||||
cancellationCount: 0,
|
||||
),
|
||||
|
||||
// Reliability Score Bar
|
||||
ReliabilityScoreBar(
|
||||
reliabilityScore: profile.reliabilityScore,
|
||||
const ReliabilityScoreBar(
|
||||
reliabilityScore: 0,
|
||||
),
|
||||
|
||||
// Ordered sections
|
||||
const OnboardingSection(),
|
||||
|
||||
// Compliance section
|
||||
const ComplianceSection(),
|
||||
|
||||
// Finance section
|
||||
const FinanceSection(),
|
||||
|
||||
// Support section
|
||||
const SupportSection(),
|
||||
|
||||
// Logout button at the bottom
|
||||
// Logout button
|
||||
const LogoutButton(),
|
||||
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
@@ -18,12 +18,12 @@ class ProfileLevelBadge extends StatelessWidget {
|
||||
String _mapStatusToLevel(StaffStatus status) {
|
||||
switch (status) {
|
||||
case StaffStatus.active:
|
||||
case StaffStatus.verified:
|
||||
return 'KROWER I';
|
||||
case StaffStatus.pending:
|
||||
case StaffStatus.completedProfile:
|
||||
case StaffStatus.invited:
|
||||
return 'Pending';
|
||||
default:
|
||||
case StaffStatus.inactive:
|
||||
case StaffStatus.blocked:
|
||||
case StaffStatus.unknown:
|
||||
return 'New';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +1,32 @@
|
||||
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 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'presentation/blocs/profile_cubit.dart';
|
||||
import 'presentation/pages/staff_profile_page.dart';
|
||||
import 'package:staff_profile/src/data/repositories/profile_repository_impl.dart';
|
||||
import 'package:staff_profile/src/presentation/blocs/profile_cubit.dart';
|
||||
import 'package:staff_profile/src/presentation/pages/staff_profile_page.dart';
|
||||
|
||||
/// The entry module for the Staff Profile feature.
|
||||
///
|
||||
/// This module provides dependency injection bindings for the profile feature
|
||||
/// following Clean Architecture principles.
|
||||
///
|
||||
/// Dependency flow:
|
||||
/// - Use cases from data_connect layer (StaffConnectorRepository)
|
||||
/// - Cubit depends on use cases
|
||||
/// Uses the V2 REST API via [BaseApiService] for all backend access.
|
||||
/// Section completion statuses are fetched in a single API call.
|
||||
class StaffProfileModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// StaffConnectorRepository intialization
|
||||
i.addLazySingleton<StaffConnectorRepository>(
|
||||
() => StaffConnectorRepositoryImpl(),
|
||||
);
|
||||
|
||||
// Use cases from data_connect - depend on StaffConnectorRepository
|
||||
i.addLazySingleton<GetStaffProfileUseCase>(
|
||||
() =>
|
||||
GetStaffProfileUseCase(repository: i.get<StaffConnectorRepository>()),
|
||||
);
|
||||
i.addLazySingleton<SignOutStaffUseCase>(
|
||||
() => SignOutStaffUseCase(repository: i.get<StaffConnectorRepository>()),
|
||||
);
|
||||
i.addLazySingleton<GetPersonalInfoCompletionUseCase>(
|
||||
() => GetPersonalInfoCompletionUseCase(
|
||||
repository: i.get<StaffConnectorRepository>(),
|
||||
),
|
||||
);
|
||||
i.addLazySingleton<GetEmergencyContactsCompletionUseCase>(
|
||||
() => GetEmergencyContactsCompletionUseCase(
|
||||
repository: i.get<StaffConnectorRepository>(),
|
||||
),
|
||||
);
|
||||
i.addLazySingleton<GetExperienceCompletionUseCase>(
|
||||
() => GetExperienceCompletionUseCase(
|
||||
repository: i.get<StaffConnectorRepository>(),
|
||||
),
|
||||
);
|
||||
i.addLazySingleton<GetTaxFormsCompletionUseCase>(
|
||||
() => GetTaxFormsCompletionUseCase(
|
||||
repository: i.get<StaffConnectorRepository>(),
|
||||
),
|
||||
);
|
||||
i.addLazySingleton<GetAttireOptionsCompletionUseCase>(
|
||||
() => GetAttireOptionsCompletionUseCase(
|
||||
repository: i.get<StaffConnectorRepository>(),
|
||||
),
|
||||
);
|
||||
i.addLazySingleton<GetStaffDocumentsCompletionUseCase>(
|
||||
() => GetStaffDocumentsCompletionUseCase(
|
||||
repository: i.get<StaffConnectorRepository>(),
|
||||
),
|
||||
);
|
||||
i.addLazySingleton<GetStaffCertificatesCompletionUseCase>(
|
||||
() => GetStaffCertificatesCompletionUseCase(
|
||||
repository: i.get<StaffConnectorRepository>(),
|
||||
// Repository
|
||||
i.addLazySingleton<ProfileRepositoryImpl>(
|
||||
() => ProfileRepositoryImpl(
|
||||
apiService: i.get<BaseApiService>(),
|
||||
),
|
||||
);
|
||||
|
||||
// Presentation layer - Cubit as singleton to avoid recreation
|
||||
// BlocProvider will use this same instance, preventing state emission after close
|
||||
// Cubit
|
||||
i.addLazySingleton<ProfileCubit>(
|
||||
() => ProfileCubit(
|
||||
i.get<GetStaffProfileUseCase>(),
|
||||
i.get<SignOutStaffUseCase>(),
|
||||
i.get<GetPersonalInfoCompletionUseCase>(),
|
||||
i.get<GetEmergencyContactsCompletionUseCase>(),
|
||||
i.get<GetExperienceCompletionUseCase>(),
|
||||
i.get<GetTaxFormsCompletionUseCase>(),
|
||||
i.get<GetAttireOptionsCompletionUseCase>(),
|
||||
i.get<GetStaffDocumentsCompletionUseCase>(),
|
||||
i.get<GetStaffCertificatesCompletionUseCase>(),
|
||||
),
|
||||
() => ProfileCubit(i.get<ProfileRepositoryImpl>()),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ dependencies:
|
||||
bloc: ^8.1.0
|
||||
flutter_modular: ^6.3.0
|
||||
equatable: ^2.0.5
|
||||
|
||||
|
||||
# Architecture Packages
|
||||
design_system:
|
||||
path: ../../../design_system
|
||||
@@ -25,17 +25,6 @@ dependencies:
|
||||
path: ../../../core
|
||||
krow_domain:
|
||||
path: ../../../domain
|
||||
krow_data_connect:
|
||||
path: ../../../data_connect
|
||||
|
||||
# Feature Packages
|
||||
staff_profile_info:
|
||||
path: ../profile_sections/onboarding/profile_info
|
||||
staff_emergency_contact:
|
||||
path: ../profile_sections/onboarding/emergency_contact
|
||||
staff_profile_experience:
|
||||
path: ../profile_sections/onboarding/experience
|
||||
firebase_auth: ^6.1.4
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -1,137 +1,101 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../domain/repositories/certificates_repository.dart';
|
||||
import 'package:staff_certificates/src/domain/repositories/certificates_repository.dart';
|
||||
|
||||
/// Implementation of [CertificatesRepository] using Data Connect.
|
||||
/// Implementation of [CertificatesRepository] using the V2 API for reads
|
||||
/// and core services for uploads/verification.
|
||||
///
|
||||
/// Replaces the previous Firebase Data Connect implementation.
|
||||
class CertificatesRepositoryImpl implements CertificatesRepository {
|
||||
/// Creates a [CertificatesRepositoryImpl].
|
||||
CertificatesRepositoryImpl({
|
||||
required BaseApiService apiService,
|
||||
required FileUploadService uploadService,
|
||||
required SignedUrlService signedUrlService,
|
||||
required VerificationService verificationService,
|
||||
}) : _service = DataConnectService.instance,
|
||||
_uploadService = uploadService,
|
||||
_signedUrlService = signedUrlService,
|
||||
_verificationService = verificationService;
|
||||
}) : _api = apiService,
|
||||
_uploadService = uploadService,
|
||||
_signedUrlService = signedUrlService,
|
||||
_verificationService = verificationService;
|
||||
|
||||
final DataConnectService _service;
|
||||
final BaseApiService _api;
|
||||
final FileUploadService _uploadService;
|
||||
final SignedUrlService _signedUrlService;
|
||||
final VerificationService _verificationService;
|
||||
|
||||
@override
|
||||
Future<List<domain.StaffCertificate>> getCertificates() async {
|
||||
return _service.getStaffRepository().getStaffCertificates();
|
||||
Future<List<StaffCertificate>> getCertificates() async {
|
||||
final ApiResponse response =
|
||||
await _api.get(V2ApiEndpoints.staffCertificates);
|
||||
final List<dynamic> items =
|
||||
response.data['certificates'] as List<dynamic>;
|
||||
return items
|
||||
.map((dynamic json) =>
|
||||
StaffCertificate.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.StaffCertificate> uploadCertificate({
|
||||
required domain.ComplianceType certificationType,
|
||||
Future<StaffCertificate> uploadCertificate({
|
||||
required String certificateType,
|
||||
required String name,
|
||||
required String filePath,
|
||||
DateTime? expiryDate,
|
||||
String? issuer,
|
||||
String? certificateNumber,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
// Get existing certificate to check if file has changed
|
||||
final List<domain.StaffCertificate> existingCerts = await getCertificates();
|
||||
domain.StaffCertificate? existingCert;
|
||||
try {
|
||||
existingCert = existingCerts.firstWhere(
|
||||
(domain.StaffCertificate c) => c.certificationType == certificationType,
|
||||
);
|
||||
} catch (e) {
|
||||
// Certificate doesn't exist yet
|
||||
}
|
||||
// 1. Upload the file to cloud storage
|
||||
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
|
||||
filePath: filePath,
|
||||
fileName:
|
||||
'staff_cert_${certificateType}_${DateTime.now().millisecondsSinceEpoch}.pdf',
|
||||
visibility: FileVisibility.private,
|
||||
);
|
||||
|
||||
String? signedUrl = existingCert?.certificateUrl;
|
||||
String? verificationId = existingCert?.verificationId;
|
||||
final bool fileChanged = existingCert == null || existingCert.certificateUrl != filePath;
|
||||
// 2. Generate a signed URL
|
||||
final SignedUrlResponse signedUrlRes =
|
||||
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
|
||||
|
||||
// Only upload and verify if file path has changed
|
||||
if (fileChanged) {
|
||||
// 1. Upload the file to cloud storage
|
||||
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
|
||||
filePath: filePath,
|
||||
fileName:
|
||||
'staff_cert_${certificationType.name}_${DateTime.now().millisecondsSinceEpoch}.pdf',
|
||||
visibility: domain.FileVisibility.private,
|
||||
);
|
||||
// 3. Initiate verification
|
||||
final VerificationResponse verificationRes =
|
||||
await _verificationService.createVerification(
|
||||
fileUri: uploadRes.fileUri,
|
||||
type: 'certification',
|
||||
subjectType: 'worker',
|
||||
subjectId: certificateType,
|
||||
rules: <String, dynamic>{
|
||||
'certificateName': name,
|
||||
'certificateIssuer': issuer,
|
||||
'certificateNumber': certificateNumber,
|
||||
},
|
||||
);
|
||||
|
||||
// 2. Generate a signed URL for verification service to access the file
|
||||
final SignedUrlResponse signedUrlRes = await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
|
||||
signedUrl = signedUrlRes.signedUrl;
|
||||
// 4. Save certificate via V2 API
|
||||
await _api.post(
|
||||
V2ApiEndpoints.staffCertificates,
|
||||
data: <String, dynamic>{
|
||||
'certificateType': certificateType,
|
||||
'name': name,
|
||||
'fileUri': signedUrlRes.signedUrl,
|
||||
'expiresAt': expiryDate?.toIso8601String(),
|
||||
'issuer': issuer,
|
||||
'certificateNumber': certificateNumber,
|
||||
'verificationId': verificationRes.verificationId,
|
||||
},
|
||||
);
|
||||
|
||||
// 3. Initiate verification
|
||||
final String staffId = await _service.getStaffId();
|
||||
final VerificationResponse verificationRes = await _verificationService
|
||||
.createVerification(
|
||||
fileUri: uploadRes.fileUri,
|
||||
type: 'certification',
|
||||
subjectType: 'worker',
|
||||
subjectId: staffId,
|
||||
rules: <String, dynamic>{
|
||||
'certificateName': name,
|
||||
'certificateIssuer': issuer,
|
||||
'certificateNumber': certificateNumber,
|
||||
},
|
||||
);
|
||||
verificationId = verificationRes.verificationId;
|
||||
}
|
||||
|
||||
// 4. Update/Create Certificate in Data Connect
|
||||
await _service.getStaffRepository().upsertStaffCertificate(
|
||||
certificationType: certificationType,
|
||||
name: name,
|
||||
status: existingCert?.status ?? domain.StaffCertificateStatus.pending,
|
||||
fileUrl: signedUrl,
|
||||
expiry: expiryDate,
|
||||
issuer: issuer,
|
||||
certificateNumber: certificateNumber,
|
||||
validationStatus: existingCert?.validationStatus ?? domain.StaffCertificateValidationStatus.pendingExpertReview,
|
||||
verificationId: verificationId,
|
||||
);
|
||||
|
||||
// 5. Return updated list or the specific certificate
|
||||
final List<domain.StaffCertificate> certificates =
|
||||
await getCertificates();
|
||||
return certificates.firstWhere(
|
||||
(domain.StaffCertificate c) => c.certificationType == certificationType,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> upsertCertificate({
|
||||
required domain.ComplianceType certificationType,
|
||||
required String name,
|
||||
required domain.StaffCertificateStatus status,
|
||||
String? fileUrl,
|
||||
DateTime? expiry,
|
||||
String? issuer,
|
||||
String? certificateNumber,
|
||||
domain.StaffCertificateValidationStatus? validationStatus,
|
||||
}) async {
|
||||
await _service.getStaffRepository().upsertStaffCertificate(
|
||||
certificationType: certificationType,
|
||||
name: name,
|
||||
status: status,
|
||||
fileUrl: fileUrl,
|
||||
expiry: expiry,
|
||||
issuer: issuer,
|
||||
certificateNumber: certificateNumber,
|
||||
validationStatus: validationStatus,
|
||||
// 5. Return updated list
|
||||
final List<StaffCertificate> certificates = await getCertificates();
|
||||
return certificates.firstWhere(
|
||||
(StaffCertificate c) => c.certificateType == certificateType,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteCertificate({
|
||||
required domain.ComplianceType certificationType,
|
||||
}) async {
|
||||
return _service.getStaffRepository().deleteStaffCertificate(
|
||||
certificationType: certificationType,
|
||||
Future<void> deleteCertificate({required String certificateId}) async {
|
||||
await _api.delete(
|
||||
V2ApiEndpoints.staffCertificateDelete(certificateId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,15 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Interface for the certificates repository.
|
||||
///
|
||||
/// Responsible for fetching staff compliance certificates.
|
||||
/// Implementations must reside in the data layer.
|
||||
/// Responsible for fetching, uploading, and deleting staff certificates
|
||||
/// via the V2 API. Uses [StaffCertificate] from the V2 domain.
|
||||
abstract interface class CertificatesRepository {
|
||||
/// Fetches the list of compliance certificates for the current staff member.
|
||||
///
|
||||
/// Returns a list of [StaffCertificate] entities.
|
||||
/// Fetches the list of certificates for the current staff member.
|
||||
Future<List<StaffCertificate>> getCertificates();
|
||||
|
||||
/// Uploads a certificate file and saves the record.
|
||||
Future<StaffCertificate> uploadCertificate({
|
||||
required ComplianceType certificationType,
|
||||
required String certificateType,
|
||||
required String name,
|
||||
required String filePath,
|
||||
DateTime? expiryDate,
|
||||
@@ -20,18 +18,6 @@ abstract interface class CertificatesRepository {
|
||||
String? certificateNumber,
|
||||
});
|
||||
|
||||
/// Deletes a staff certificate.
|
||||
Future<void> deleteCertificate({required ComplianceType certificationType});
|
||||
|
||||
/// Upserts a certificate record (metadata only).
|
||||
Future<void> upsertCertificate({
|
||||
required ComplianceType certificationType,
|
||||
required String name,
|
||||
required StaffCertificateStatus status,
|
||||
String? fileUrl,
|
||||
DateTime? expiry,
|
||||
String? issuer,
|
||||
String? certificateNumber,
|
||||
StaffCertificateValidationStatus? validationStatus,
|
||||
});
|
||||
/// Deletes a staff certificate by its [certificateId].
|
||||
Future<void> deleteCertificate({required String certificateId});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/certificates_repository.dart';
|
||||
|
||||
/// Use case for deleting a staff compliance certificate.
|
||||
class DeleteCertificateUseCase extends UseCase<ComplianceType, void> {
|
||||
class DeleteCertificateUseCase extends UseCase<String, void> {
|
||||
/// Creates a [DeleteCertificateUseCase].
|
||||
DeleteCertificateUseCase(this._repository);
|
||||
final CertificatesRepository _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(ComplianceType certificationType) {
|
||||
return _repository.deleteCertificate(certificationType: certificationType);
|
||||
Future<void> call(String certificateId) {
|
||||
return _repository.deleteCertificate(certificateId: certificateId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ class UploadCertificateUseCase
|
||||
@override
|
||||
Future<StaffCertificate> call(UploadCertificateParams params) {
|
||||
return _repository.uploadCertificate(
|
||||
certificationType: params.certificationType,
|
||||
certificateType: params.certificateType,
|
||||
name: params.name,
|
||||
filePath: params.filePath,
|
||||
expiryDate: params.expiryDate,
|
||||
@@ -26,7 +26,7 @@ class UploadCertificateUseCase
|
||||
class UploadCertificateParams {
|
||||
/// Creates [UploadCertificateParams].
|
||||
UploadCertificateParams({
|
||||
required this.certificationType,
|
||||
required this.certificateType,
|
||||
required this.name,
|
||||
required this.filePath,
|
||||
this.expiryDate,
|
||||
@@ -34,8 +34,8 @@ class UploadCertificateParams {
|
||||
this.certificateNumber,
|
||||
});
|
||||
|
||||
/// The type of certification.
|
||||
final ComplianceType certificationType;
|
||||
/// The type of certification (e.g. "FOOD_HYGIENE", "SIA_BADGE").
|
||||
final String certificateType;
|
||||
|
||||
/// The name of the certificate.
|
||||
final String name;
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/certificates_repository.dart';
|
||||
|
||||
/// Use case for upserting a staff compliance certificate.
|
||||
class UpsertCertificateUseCase extends UseCase<UpsertCertificateParams, void> {
|
||||
/// Creates an [UpsertCertificateUseCase].
|
||||
UpsertCertificateUseCase(this._repository);
|
||||
final CertificatesRepository _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(UpsertCertificateParams params) {
|
||||
return _repository.upsertCertificate(
|
||||
certificationType: params.certificationType,
|
||||
name: params.name,
|
||||
status: params.status,
|
||||
fileUrl: params.fileUrl,
|
||||
expiry: params.expiry,
|
||||
issuer: params.issuer,
|
||||
certificateNumber: params.certificateNumber,
|
||||
validationStatus: params.validationStatus,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters for [UpsertCertificateUseCase].
|
||||
class UpsertCertificateParams {
|
||||
/// Creates [UpsertCertificateParams].
|
||||
UpsertCertificateParams({
|
||||
required this.certificationType,
|
||||
required this.name,
|
||||
required this.status,
|
||||
this.fileUrl,
|
||||
this.expiry,
|
||||
this.issuer,
|
||||
this.certificateNumber,
|
||||
this.validationStatus,
|
||||
});
|
||||
|
||||
/// The type of certification.
|
||||
final ComplianceType certificationType;
|
||||
|
||||
/// The name of the certificate.
|
||||
final String name;
|
||||
|
||||
/// The status of the certificate.
|
||||
final StaffCertificateStatus status;
|
||||
|
||||
/// The URL of the certificate file.
|
||||
final String? fileUrl;
|
||||
|
||||
/// The expiry date of the certificate.
|
||||
final DateTime? expiry;
|
||||
|
||||
/// The issuer of the certificate.
|
||||
final String? issuer;
|
||||
|
||||
/// The certificate number.
|
||||
final String? certificateNumber;
|
||||
|
||||
/// The validation status of the certificate.
|
||||
final StaffCertificateValidationStatus? validationStatus;
|
||||
}
|
||||
@@ -23,12 +23,12 @@ class CertificateUploadCubit extends Cubit<CertificateUploadState>
|
||||
emit(state.copyWith(selectedFilePath: filePath));
|
||||
}
|
||||
|
||||
Future<void> deleteCertificate(ComplianceType type) async {
|
||||
Future<void> deleteCertificate(String certificateId) async {
|
||||
emit(state.copyWith(status: CertificateUploadStatus.uploading));
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
await _deleteCertificateUseCase(type);
|
||||
await _deleteCertificateUseCase(certificateId);
|
||||
emit(state.copyWith(status: CertificateUploadStatus.success));
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
|
||||
@@ -38,12 +38,12 @@ class CertificatesCubit extends Cubit<CertificatesState>
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteCertificate(ComplianceType type) async {
|
||||
Future<void> deleteCertificate(String certificateId) async {
|
||||
emit(state.copyWith(status: CertificatesStatus.loading));
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
await _deleteCertificateUseCase(type);
|
||||
await _deleteCertificateUseCase(certificateId);
|
||||
await loadCertificates();
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
|
||||
@@ -33,7 +33,7 @@ class CertificatesState extends Equatable {
|
||||
int get completedCount => certificates
|
||||
.where(
|
||||
(StaffCertificate cert) =>
|
||||
cert.validationStatus == StaffCertificateValidationStatus.approved,
|
||||
cert.status == CertificateStatus.verified,
|
||||
)
|
||||
.length;
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
||||
final TextEditingController _numberController = TextEditingController();
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
|
||||
ComplianceType? _selectedType;
|
||||
String _selectedType = '';
|
||||
|
||||
final FilePickerService _filePicker = Modular.get<FilePickerService>();
|
||||
|
||||
@@ -44,13 +44,13 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
||||
_cubit = Modular.get<CertificateUploadCubit>();
|
||||
|
||||
if (widget.certificate != null) {
|
||||
_selectedExpiryDate = widget.certificate!.expiryDate;
|
||||
_selectedExpiryDate = widget.certificate!.expiresAt;
|
||||
_issuerController.text = widget.certificate!.issuer ?? '';
|
||||
_numberController.text = widget.certificate!.certificateNumber ?? '';
|
||||
_nameController.text = widget.certificate!.name;
|
||||
_selectedType = widget.certificate!.certificationType;
|
||||
_selectedType = widget.certificate!.certificateType;
|
||||
} else {
|
||||
_selectedType = ComplianceType.other;
|
||||
_selectedType = 'OTHER';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
await cubit.deleteCertificate(widget.certificate!.certificationType);
|
||||
await cubit.deleteCertificate(widget.certificate!.certificateId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<CertificateUploadCubit>.value(
|
||||
value: _cubit..setSelectedFilePath(
|
||||
widget.certificate?.certificateUrl,
|
||||
widget.certificate?.fileUri,
|
||||
),
|
||||
child: BlocConsumer<CertificateUploadCubit, CertificateUploadState>(
|
||||
listener: (BuildContext context, CertificateUploadState state) {
|
||||
@@ -231,7 +231,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
||||
BlocProvider.of<CertificateUploadCubit>(context)
|
||||
.uploadCertificate(
|
||||
UploadCertificateParams(
|
||||
certificationType: _selectedType!,
|
||||
certificateType: _selectedType,
|
||||
name: _nameController.text,
|
||||
filePath: state.selectedFilePath!,
|
||||
expiryDate: _selectedExpiryDate,
|
||||
|
||||
@@ -19,28 +19,19 @@ class CertificateCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Determine UI state from certificate
|
||||
final bool isComplete =
|
||||
certificate.validationStatus ==
|
||||
StaffCertificateValidationStatus.approved;
|
||||
final bool isExpiring =
|
||||
certificate.status == StaffCertificateStatus.expiring ||
|
||||
certificate.status == StaffCertificateStatus.expiringSoon;
|
||||
final bool isExpired = certificate.status == StaffCertificateStatus.expired;
|
||||
final bool isVerified = certificate.status == CertificateStatus.verified;
|
||||
final bool isExpired = certificate.status == CertificateStatus.expired ||
|
||||
certificate.isExpired;
|
||||
final bool isPending = certificate.status == CertificateStatus.pending;
|
||||
final bool isNotStarted = certificate.fileUri == null ||
|
||||
certificate.status == CertificateStatus.rejected;
|
||||
|
||||
// Override isComplete if expiring or expired
|
||||
final bool showComplete = isComplete && !isExpired && !isExpiring;
|
||||
|
||||
final bool isPending =
|
||||
certificate.validationStatus ==
|
||||
StaffCertificateValidationStatus.pendingExpertReview;
|
||||
final bool isNotStarted =
|
||||
certificate.status == StaffCertificateStatus.notStarted ||
|
||||
certificate.validationStatus ==
|
||||
StaffCertificateValidationStatus.rejected;
|
||||
// Show verified badge only if not expired
|
||||
final bool showComplete = isVerified && !isExpired;
|
||||
|
||||
// UI Properties helper
|
||||
final _CertificateUiProps uiProps = _getUiProps(
|
||||
certificate.certificationType,
|
||||
certificate.certificateType,
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
@@ -55,7 +46,7 @@ class CertificateCard extends StatelessWidget {
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
if (isExpiring || isExpired)
|
||||
if (isExpired)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
@@ -78,11 +69,7 @@ class CertificateCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
isExpired
|
||||
? t.staff_certificates.card.expired
|
||||
: t.staff_certificates.card.expires_in_days(
|
||||
days: _daysUntilExpiry(certificate.expiryDate),
|
||||
),
|
||||
t.staff_certificates.card.expired,
|
||||
style: UiTypography.body3m.textPrimary,
|
||||
),
|
||||
],
|
||||
@@ -151,7 +138,7 @@ class CertificateCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
certificate.description ?? '',
|
||||
certificate.certificateType,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
if (showComplete) ...<Widget>[
|
||||
@@ -159,17 +146,15 @@ class CertificateCard extends StatelessWidget {
|
||||
_buildMiniStatus(
|
||||
t.staff_certificates.card.verified,
|
||||
UiColors.primary,
|
||||
certificate.expiryDate,
|
||||
certificate.expiresAt,
|
||||
),
|
||||
],
|
||||
if (isExpiring || isExpired) ...<Widget>[
|
||||
if (isExpired) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_buildMiniStatus(
|
||||
isExpired
|
||||
? t.staff_certificates.card.expired
|
||||
: t.staff_certificates.card.expiring_soon,
|
||||
isExpired ? UiColors.destructive : UiColors.primary,
|
||||
certificate.expiryDate,
|
||||
t.staff_certificates.card.expired,
|
||||
UiColors.destructive,
|
||||
certificate.expiresAt,
|
||||
),
|
||||
],
|
||||
if (isNotStarted) ...<Widget>[
|
||||
@@ -220,18 +205,14 @@ class CertificateCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
int _daysUntilExpiry(DateTime? expiry) {
|
||||
if (expiry == null) return 0;
|
||||
return expiry.difference(DateTime.now()).inDays;
|
||||
}
|
||||
|
||||
_CertificateUiProps _getUiProps(ComplianceType type) {
|
||||
switch (type) {
|
||||
case ComplianceType.backgroundCheck:
|
||||
_CertificateUiProps _getUiProps(String type) {
|
||||
switch (type.toUpperCase()) {
|
||||
case 'BACKGROUND_CHECK':
|
||||
return _CertificateUiProps(UiIcons.fileCheck, UiColors.primary);
|
||||
case ComplianceType.foodHandler:
|
||||
case 'FOOD_HYGIENE':
|
||||
case 'FOOD_HANDLER':
|
||||
return _CertificateUiProps(UiIcons.utensils, UiColors.primary);
|
||||
case ComplianceType.rbs:
|
||||
case 'RBS':
|
||||
return _CertificateUiProps(UiIcons.wine, UiColors.foreground);
|
||||
default:
|
||||
return _CertificateUiProps(UiIcons.award, UiColors.primary);
|
||||
|
||||
@@ -3,28 +3,38 @@ import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'data/repositories_impl/certificates_repository_impl.dart';
|
||||
import 'domain/repositories/certificates_repository.dart';
|
||||
import 'domain/usecases/get_certificates_usecase.dart';
|
||||
import 'domain/usecases/delete_certificate_usecase.dart';
|
||||
import 'domain/usecases/upsert_certificate_usecase.dart';
|
||||
import 'domain/usecases/upload_certificate_usecase.dart';
|
||||
import 'presentation/blocs/certificates/certificates_cubit.dart';
|
||||
import 'presentation/blocs/certificate_upload/certificate_upload_cubit.dart';
|
||||
import 'presentation/pages/certificate_upload_page.dart';
|
||||
import 'presentation/pages/certificates_page.dart';
|
||||
import 'package:staff_certificates/src/data/repositories_impl/certificates_repository_impl.dart';
|
||||
import 'package:staff_certificates/src/domain/repositories/certificates_repository.dart';
|
||||
import 'package:staff_certificates/src/domain/usecases/get_certificates_usecase.dart';
|
||||
import 'package:staff_certificates/src/domain/usecases/delete_certificate_usecase.dart';
|
||||
import 'package:staff_certificates/src/domain/usecases/upload_certificate_usecase.dart';
|
||||
import 'package:staff_certificates/src/presentation/blocs/certificates/certificates_cubit.dart';
|
||||
import 'package:staff_certificates/src/presentation/blocs/certificate_upload/certificate_upload_cubit.dart';
|
||||
import 'package:staff_certificates/src/presentation/pages/certificate_upload_page.dart';
|
||||
import 'package:staff_certificates/src/presentation/pages/certificates_page.dart';
|
||||
|
||||
/// Module for the Staff Certificates feature.
|
||||
///
|
||||
/// Uses the V2 REST API via [BaseApiService] for backend access.
|
||||
class StaffCertificatesModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
i.addLazySingleton<CertificatesRepository>(CertificatesRepositoryImpl.new);
|
||||
i.addLazySingleton<CertificatesRepository>(
|
||||
() => CertificatesRepositoryImpl(
|
||||
apiService: i.get<BaseApiService>(),
|
||||
uploadService: i.get<FileUploadService>(),
|
||||
signedUrlService: i.get<SignedUrlService>(),
|
||||
verificationService: i.get<VerificationService>(),
|
||||
),
|
||||
);
|
||||
i.addLazySingleton<GetCertificatesUseCase>(GetCertificatesUseCase.new);
|
||||
i.addLazySingleton<DeleteCertificateUseCase>(DeleteCertificateUseCase.new);
|
||||
i.addLazySingleton<UpsertCertificateUseCase>(UpsertCertificateUseCase.new);
|
||||
i.addLazySingleton<UploadCertificateUseCase>(UploadCertificateUseCase.new);
|
||||
i.addLazySingleton<DeleteCertificateUseCase>(
|
||||
DeleteCertificateUseCase.new);
|
||||
i.addLazySingleton<UploadCertificateUseCase>(
|
||||
UploadCertificateUseCase.new);
|
||||
i.addLazySingleton<CertificatesCubit>(CertificatesCubit.new);
|
||||
i.add<CertificateUploadCubit>(CertificateUploadCubit.new);
|
||||
}
|
||||
|
||||
@@ -13,9 +13,8 @@ dependencies:
|
||||
flutter_bloc: ^8.1.0
|
||||
equatable: ^2.0.5
|
||||
intl: ^0.20.0
|
||||
get_it: ^7.6.0
|
||||
flutter_modular: ^6.3.0
|
||||
|
||||
|
||||
# KROW Dependencies
|
||||
design_system:
|
||||
path: ../../../../../design_system
|
||||
@@ -25,10 +24,6 @@ dependencies:
|
||||
path: ../../../../../domain
|
||||
krow_core:
|
||||
path: ../../../../../core
|
||||
krow_data_connect:
|
||||
path: ../../../../../data_connect
|
||||
firebase_auth: ^6.1.2
|
||||
firebase_data_connect: ^0.2.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -1,105 +1,80 @@
|
||||
// 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, implementation_imports, unused_element, unused_field, duplicate_ignore
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../domain/repositories/documents_repository.dart';
|
||||
import 'package:staff_documents/src/domain/repositories/documents_repository.dart';
|
||||
|
||||
/// Implementation of [DocumentsRepository] using Data Connect.
|
||||
/// Implementation of [DocumentsRepository] using the V2 API for reads
|
||||
/// and core services for uploads/verification.
|
||||
///
|
||||
/// Replaces the previous Firebase Data Connect implementation.
|
||||
class DocumentsRepositoryImpl implements DocumentsRepository {
|
||||
/// Creates a [DocumentsRepositoryImpl].
|
||||
DocumentsRepositoryImpl({
|
||||
required BaseApiService apiService,
|
||||
required FileUploadService uploadService,
|
||||
required SignedUrlService signedUrlService,
|
||||
required VerificationService verificationService,
|
||||
}) : _service = DataConnectService.instance,
|
||||
_uploadService = uploadService,
|
||||
_signedUrlService = signedUrlService,
|
||||
_verificationService = verificationService;
|
||||
}) : _api = apiService,
|
||||
_uploadService = uploadService,
|
||||
_signedUrlService = signedUrlService,
|
||||
_verificationService = verificationService;
|
||||
|
||||
final DataConnectService _service;
|
||||
final BaseApiService _api;
|
||||
final FileUploadService _uploadService;
|
||||
final SignedUrlService _signedUrlService;
|
||||
final VerificationService _verificationService;
|
||||
|
||||
@override
|
||||
Future<List<domain.StaffDocument>> getDocuments() async {
|
||||
return _service.getStaffRepository().getStaffDocuments();
|
||||
Future<List<ProfileDocument>> getDocuments() async {
|
||||
final ApiResponse response =
|
||||
await _api.get(V2ApiEndpoints.staffDocuments);
|
||||
final List<dynamic> items = response.data['documents'] as List<dynamic>;
|
||||
return items
|
||||
.map((dynamic json) =>
|
||||
ProfileDocument.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.StaffDocument> uploadDocument(
|
||||
Future<ProfileDocument> uploadDocument(
|
||||
String documentId,
|
||||
String filePath,
|
||||
) async {
|
||||
return _service.run(() async {
|
||||
// 1. Upload the file to cloud storage
|
||||
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
|
||||
filePath: filePath,
|
||||
fileName: 'staff_document_${DateTime.now().millisecondsSinceEpoch}.pdf',
|
||||
visibility: domain.FileVisibility.private,
|
||||
);
|
||||
// 1. Upload the file to cloud storage
|
||||
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
|
||||
filePath: filePath,
|
||||
fileName:
|
||||
'staff_document_${DateTime.now().millisecondsSinceEpoch}.pdf',
|
||||
visibility: FileVisibility.private,
|
||||
);
|
||||
|
||||
// 2. Generate a signed URL for verification service to access the file
|
||||
final SignedUrlResponse signedUrlRes = await _signedUrlService
|
||||
.createSignedUrl(fileUri: uploadRes.fileUri);
|
||||
// 2. Generate a signed URL
|
||||
final SignedUrlResponse signedUrlRes =
|
||||
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
|
||||
|
||||
// 3. Initiate verification
|
||||
final List<domain.StaffDocument> allDocs = await getDocuments();
|
||||
final domain.StaffDocument currentDoc = allDocs.firstWhere(
|
||||
(domain.StaffDocument d) => d.documentId == documentId,
|
||||
);
|
||||
final String description = (currentDoc.description ?? '').toLowerCase();
|
||||
// 3. Initiate verification
|
||||
final VerificationResponse verificationRes =
|
||||
await _verificationService.createVerification(
|
||||
fileUri: uploadRes.fileUri,
|
||||
type: 'government_id',
|
||||
subjectType: 'worker',
|
||||
subjectId: documentId,
|
||||
rules: <String, dynamic>{'documentId': documentId},
|
||||
);
|
||||
|
||||
final String staffId = await _service.getStaffId();
|
||||
final VerificationResponse verificationRes = await _verificationService
|
||||
.createVerification(
|
||||
fileUri: uploadRes.fileUri,
|
||||
type: 'government_id',
|
||||
subjectType: 'worker',
|
||||
subjectId: staffId,
|
||||
rules: <String, dynamic>{
|
||||
'documentDescription': currentDoc.description,
|
||||
},
|
||||
);
|
||||
// 4. Submit upload result to V2 API
|
||||
await _api.put(
|
||||
V2ApiEndpoints.staffDocumentUpload(documentId),
|
||||
data: <String, dynamic>{
|
||||
'fileUri': signedUrlRes.signedUrl,
|
||||
'verificationId': verificationRes.verificationId,
|
||||
},
|
||||
);
|
||||
|
||||
// 4. Update/Create StaffDocument in Data Connect
|
||||
await _service.getStaffRepository().upsertStaffDocument(
|
||||
documentId: documentId,
|
||||
documentUrl: signedUrlRes.signedUrl,
|
||||
status: domain.DocumentStatus.pending,
|
||||
verificationId: verificationRes.verificationId,
|
||||
);
|
||||
|
||||
// 5. Return the updated document state
|
||||
final List<domain.StaffDocument> documents = await getDocuments();
|
||||
return documents.firstWhere(
|
||||
(domain.StaffDocument d) => d.documentId == documentId,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
domain.DocumentStatus _mapStatus(EnumValue<DocumentStatus> status) {
|
||||
if (status is Known<DocumentStatus>) {
|
||||
switch (status.value) {
|
||||
case DocumentStatus.VERIFIED:
|
||||
case DocumentStatus.AUTO_PASS:
|
||||
case DocumentStatus.APPROVED:
|
||||
return domain.DocumentStatus.verified;
|
||||
case DocumentStatus.PENDING:
|
||||
case DocumentStatus.UPLOADED:
|
||||
case DocumentStatus.PROCESSING:
|
||||
case DocumentStatus.NEEDS_REVIEW:
|
||||
case DocumentStatus.EXPIRING:
|
||||
return domain.DocumentStatus.pending;
|
||||
case DocumentStatus.MISSING:
|
||||
return domain.DocumentStatus.missing;
|
||||
case DocumentStatus.AUTO_FAIL:
|
||||
case DocumentStatus.REJECTED:
|
||||
case DocumentStatus.ERROR:
|
||||
return domain.DocumentStatus.rejected;
|
||||
}
|
||||
}
|
||||
// Default to pending for Unknown or unhandled cases
|
||||
return domain.DocumentStatus.pending;
|
||||
// 5. Return the updated document
|
||||
final List<ProfileDocument> documents = await getDocuments();
|
||||
return documents.firstWhere(
|
||||
(ProfileDocument d) => d.documentId == documentId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Interface for the documents repository.
|
||||
///
|
||||
/// Responsible for fetching staff compliance documents.
|
||||
/// Responsible for fetching and uploading staff compliance documents
|
||||
/// via the V2 API. Uses [ProfileDocument] from the V2 domain.
|
||||
abstract interface class DocumentsRepository {
|
||||
/// Fetches the list of compliance documents for the current staff member.
|
||||
Future<List<StaffDocument>> getDocuments();
|
||||
Future<List<ProfileDocument>> getDocuments();
|
||||
|
||||
/// Uploads a document for the current staff member.
|
||||
Future<StaffDocument> uploadDocument(String documentId, String filePath);
|
||||
/// Uploads a document file for the given [documentId].
|
||||
Future<ProfileDocument> uploadDocument(String documentId, String filePath);
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import '../repositories/documents_repository.dart';
|
||||
/// Use case for fetching staff compliance documents.
|
||||
///
|
||||
/// Delegates to [DocumentsRepository].
|
||||
class GetDocumentsUseCase implements NoInputUseCase<List<StaffDocument>> {
|
||||
class GetDocumentsUseCase implements NoInputUseCase<List<ProfileDocument>> {
|
||||
|
||||
GetDocumentsUseCase(this._repository);
|
||||
final DocumentsRepository _repository;
|
||||
|
||||
@override
|
||||
Future<List<StaffDocument>> call() {
|
||||
Future<List<ProfileDocument>> call() {
|
||||
return _repository.getDocuments();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/documents_repository.dart';
|
||||
|
||||
class UploadDocumentUseCase
|
||||
extends UseCase<UploadDocumentArguments, StaffDocument> {
|
||||
extends UseCase<UploadDocumentArguments, ProfileDocument> {
|
||||
UploadDocumentUseCase(this._repository);
|
||||
final DocumentsRepository _repository;
|
||||
|
||||
@override
|
||||
Future<StaffDocument> call(UploadDocumentArguments arguments) {
|
||||
Future<ProfileDocument> call(UploadDocumentArguments arguments) {
|
||||
return _repository.uploadDocument(arguments.documentId, arguments.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class DocumentUploadCubit extends Cubit<DocumentUploadState> {
|
||||
emit(state.copyWith(status: DocumentUploadStatus.uploading));
|
||||
|
||||
try {
|
||||
final StaffDocument updatedDoc = await _uploadDocumentUseCase(
|
||||
final ProfileDocument updatedDoc = await _uploadDocumentUseCase(
|
||||
UploadDocumentArguments(documentId: documentId, filePath: filePath),
|
||||
);
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class DocumentUploadState extends Equatable {
|
||||
final bool isAttested;
|
||||
final String? selectedFilePath;
|
||||
final String? documentUrl;
|
||||
final StaffDocument? updatedDocument;
|
||||
final ProfileDocument? updatedDocument;
|
||||
final String? errorMessage;
|
||||
|
||||
DocumentUploadState copyWith({
|
||||
@@ -25,7 +25,7 @@ class DocumentUploadState extends Equatable {
|
||||
bool? isAttested,
|
||||
String? selectedFilePath,
|
||||
String? documentUrl,
|
||||
StaffDocument? updatedDocument,
|
||||
ProfileDocument? updatedDocument,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return DocumentUploadState(
|
||||
|
||||
@@ -15,7 +15,7 @@ class DocumentsCubit extends Cubit<DocumentsState>
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final List<StaffDocument> documents = await _getDocumentsUseCase();
|
||||
final List<ProfileDocument> documents = await _getDocumentsUseCase();
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: DocumentsStatus.success,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user