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

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

View File

@@ -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();
}
}

View File

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

View File

@@ -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),
);
}
});
}
}
}

View File

@@ -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].
///

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

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

View File

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

View File

@@ -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();
}
}

View File

@@ -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>[]));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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());

View File

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

View File

@@ -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();
}
}

View File

@@ -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,
});
}

View File

@@ -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];
}

View File

@@ -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];
}

View File

@@ -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];
}

View File

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

View File

@@ -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));
}
}

View File

@@ -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];
}

View File

@@ -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];
}

View File

@@ -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,
),
],

View File

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

View File

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

View File

@@ -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,
);
}
}

View File

@@ -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];
}

View File

@@ -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,
});
}

View File

@@ -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,
);
}
}

View File

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

View File

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

View File

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

View File

@@ -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),
],

View File

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

View File

@@ -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.
],
);
}

View File

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

View File

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

View File

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

View File

@@ -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];
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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,
),
);
},

View File

@@ -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));
}

View File

@@ -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,
];
}
}

View File

@@ -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),
// ],
],
),
);

View File

@@ -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')) {

View File

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

View File

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

View File

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

View File

@@ -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,
),
],
),
),
],
),
),
);
}
}

View File

@@ -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,
),
),
],
),
);
}
}

View File

@@ -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(),

View File

@@ -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>()),
);
}

View File

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

View File

@@ -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();
}
}

View File

@@ -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];
}

View File

@@ -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];
}

View File

@@ -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,
});
}

View File

@@ -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,
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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();
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

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

View File

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

View File

@@ -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);
}
}

View File

@@ -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),
);
}
}

View File

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

View File

@@ -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';
}
}

View File

@@ -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>()),
);
}

View File

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

View File

@@ -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),
);
}
}

View File

@@ -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});
}

View File

@@ -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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,7 +33,7 @@ class CertificatesState extends Equatable {
int get completedCount => certificates
.where(
(StaffCertificate cert) =>
cert.validationStatus == StaffCertificateValidationStatus.approved,
cert.status == CertificateStatus.verified,
)
.length;

View File

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

View File

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

View File

@@ -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);
}

View File

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

View File

@@ -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,
);
}
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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),
);

View File

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

View File

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