feat: Implement Staff Profile feature with BLoC architecture
- Added ProfileRepositoryInterface for staff profile operations. - Created StaffProfileUI to represent extended profile information for the UI. - Developed GetProfileUseCase to fetch staff profiles and map to UI entities. - Implemented SignOutUseCase for user sign-out functionality. - Introduced ProfileCubit to manage profile state and loading logic. - Created ProfileState to represent various states of the profile feature. - Developed ProfileNavigator for type-safe navigation within the profile feature. - Built StaffProfilePage to display staff member's profile and statistics. - Added various widgets for profile display, including reliability stats and menu items. - Established StaffProfileModule for dependency injection and routing. - Configured pubspec.yaml for feature package dependencies.
This commit is contained in:
@@ -453,15 +453,51 @@
|
|||||||
"refer": { "title": "Refer a Friend", "page": "/worker-profile" }
|
"refer": { "title": "Refer a Friend", "page": "/worker-profile" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
"profile": {
|
||||||
"staff_main": {
|
"header": {
|
||||||
"tabs": {
|
"title": "Profile",
|
||||||
"shifts": "Shifts",
|
"sign_out": "SIGN OUT"
|
||||||
"payments": "Payments",
|
},
|
||||||
"home": "Home",
|
"reliability_stats": {
|
||||||
"clock_in": "Clock In",
|
"shifts": "Shifts",
|
||||||
"profile": "Profile"
|
"rating": "Rating",
|
||||||
|
"on_time": "On Time",
|
||||||
|
"no_shows": "No Shows",
|
||||||
|
"cancellations": "Cancel."
|
||||||
|
},
|
||||||
|
"reliability_score": {
|
||||||
|
"title": "Reliability Score",
|
||||||
|
"description": "Keep your score above 45% to continue picking up shifts."
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"onboarding": "ONBOARDING",
|
||||||
|
"compliance": "COMPLIANCE",
|
||||||
|
"level_up": "LEVEL UP",
|
||||||
|
"finance": "FINANCE",
|
||||||
|
"support": "SUPPORT"
|
||||||
|
},
|
||||||
|
"menu_items": {
|
||||||
|
"personal_info": "Personal Info",
|
||||||
|
"emergency_contact": "Emergency Contact",
|
||||||
|
"experience": "Experience",
|
||||||
|
"attire": "Attire",
|
||||||
|
"documents": "Documents",
|
||||||
|
"certificates": "Certificates",
|
||||||
|
"tax_forms": "Tax Forms",
|
||||||
|
"krow_university": "Krow University",
|
||||||
|
"trainings": "Trainings",
|
||||||
|
"leaderboard": "Leaderboard",
|
||||||
|
"bank_account": "Bank Account",
|
||||||
|
"payments": "Payments",
|
||||||
|
"timecard": "Timecard",
|
||||||
|
"faqs": "FAQs",
|
||||||
|
"privacy_security": "Privacy & Security",
|
||||||
|
"messages": "Messages"
|
||||||
|
},
|
||||||
|
"logout": {
|
||||||
|
"button": "Sign Out"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ library;
|
|||||||
|
|
||||||
export 'src/mocks/auth_repository_mock.dart';
|
export 'src/mocks/auth_repository_mock.dart';
|
||||||
export 'src/mocks/staff_repository_mock.dart';
|
export 'src/mocks/staff_repository_mock.dart';
|
||||||
|
export 'src/mocks/profile_repository_mock.dart';
|
||||||
export 'src/mocks/event_repository_mock.dart';
|
export 'src/mocks/event_repository_mock.dart';
|
||||||
export 'src/mocks/skill_repository_mock.dart';
|
export 'src/mocks/skill_repository_mock.dart';
|
||||||
export 'src/mocks/financial_repository_mock.dart';
|
export 'src/mocks/financial_repository_mock.dart';
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
/// Mock implementation of profile-related data operations.
|
||||||
|
///
|
||||||
|
/// This mock provides hardcoded staff profile data for development and testing.
|
||||||
|
/// It simulates the behavior of a real backend service without requiring
|
||||||
|
/// actual API calls or Firebase Data Connect.
|
||||||
|
///
|
||||||
|
/// In production, this will be replaced with a real implementation that
|
||||||
|
/// interacts with Firebase Data Connect.
|
||||||
|
class ProfileRepositoryMock {
|
||||||
|
/// Fetches the staff profile for the given user ID.
|
||||||
|
///
|
||||||
|
/// Returns a [Staff] entity with mock data.
|
||||||
|
/// Simulates a network delay to mirror real API behavior.
|
||||||
|
///
|
||||||
|
/// Throws an exception if the profile cannot be retrieved.
|
||||||
|
Future<Staff> getStaffProfile(String userId) async {
|
||||||
|
// Simulate network delay
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
// Return mock staff profile data
|
||||||
|
return const Staff(
|
||||||
|
id: '93673c8f-91aa-405d-8647-f1aac29cc19b',
|
||||||
|
authProviderId: 't8P3fYh4y1cPoZbbVPXUhfQCsDo3',
|
||||||
|
name: 'Krower',
|
||||||
|
email: 'worker@krow.com',
|
||||||
|
phone: '555-123-4567',
|
||||||
|
status: StaffStatus.active,
|
||||||
|
avatar: null,
|
||||||
|
livePhoto: null,
|
||||||
|
address: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signs out the current user.
|
||||||
|
///
|
||||||
|
/// Simulates the sign-out process with a delay.
|
||||||
|
/// In a real implementation, this would clear session tokens,
|
||||||
|
/// update Firebase auth state, etc.
|
||||||
|
Future<void> signOut() async {
|
||||||
|
// Simulate processing delay
|
||||||
|
await Future.delayed(const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import '../../domain/repositories/profile_repository.dart';
|
||||||
|
|
||||||
|
/// Implementation of [ProfileRepositoryInterface] that delegates to data_connect.
|
||||||
|
///
|
||||||
|
/// This implementation follows Clean Architecture by:
|
||||||
|
/// - Implementing the domain layer's repository interface
|
||||||
|
/// - Delegating all data access to the data_connect package
|
||||||
|
/// - Not containing any business logic
|
||||||
|
/// - Only performing data transformation/mapping if needed
|
||||||
|
///
|
||||||
|
/// Currently uses [ProfileRepositoryMock] from data_connect.
|
||||||
|
/// When Firebase Data Connect is ready, this will be swapped with a real implementation.
|
||||||
|
class ProfileRepositoryImpl implements ProfileRepositoryInterface {
|
||||||
|
final ProfileRepositoryMock _dataConnectRepository;
|
||||||
|
|
||||||
|
/// Creates a [ProfileRepositoryImpl].
|
||||||
|
///
|
||||||
|
/// Requires a [ProfileRepositoryMock] from the data_connect package.
|
||||||
|
const ProfileRepositoryImpl(this._dataConnectRepository);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Staff> getStaffProfile(String userId) {
|
||||||
|
// Delegate directly to data_connect - no business logic here
|
||||||
|
return _dataConnectRepository.getStaffProfile(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> signOut() {
|
||||||
|
// Delegate directly to data_connect - no business logic here
|
||||||
|
return _dataConnectRepository.signOut();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
/// Repository interface for staff profile operations.
|
||||||
|
///
|
||||||
|
/// Defines the contract for accessing and managing staff profile data.
|
||||||
|
/// This interface lives in the domain layer and is implemented by the data layer.
|
||||||
|
///
|
||||||
|
/// Following Clean Architecture principles, this interface:
|
||||||
|
/// - Returns domain entities (Staff from shared domain package)
|
||||||
|
/// - Defines business requirements without implementation details
|
||||||
|
/// - Allows the domain layer to be independent of data sources
|
||||||
|
abstract interface class ProfileRepositoryInterface {
|
||||||
|
/// Fetches the staff profile for the given user ID.
|
||||||
|
///
|
||||||
|
/// Returns a [Staff] entity from the shared domain package containing
|
||||||
|
/// all profile information.
|
||||||
|
///
|
||||||
|
/// Throws an exception if the profile cannot be retrieved.
|
||||||
|
Future<Staff> getStaffProfile(String userId);
|
||||||
|
|
||||||
|
/// Signs out the current user.
|
||||||
|
///
|
||||||
|
/// Clears the user's session and authentication state.
|
||||||
|
/// Should be followed by navigation to the authentication flow.
|
||||||
|
Future<void> signOut();
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
/// UI entity representing extended profile information for a staff member.
|
||||||
|
///
|
||||||
|
/// This UI entity wraps the shared [Staff] domain entity and adds
|
||||||
|
/// presentation-layer specific data such as:
|
||||||
|
/// - Reliability statistics (shifts, ratings, etc.)
|
||||||
|
/// - Profile completion status
|
||||||
|
/// - Performance metrics
|
||||||
|
///
|
||||||
|
/// Following Clean Architecture principles, this entity:
|
||||||
|
/// - Lives in the feature's domain/ui_entities layer
|
||||||
|
/// - Is used only by the presentation layer
|
||||||
|
/// - Extends the core [Staff] entity with UI-specific data
|
||||||
|
/// - Does NOT replace domain entities in repositories or use cases
|
||||||
|
class StaffProfileUI extends Equatable {
|
||||||
|
/// The underlying staff entity from the shared domain
|
||||||
|
final Staff staff;
|
||||||
|
|
||||||
|
/// Total number of shifts worked
|
||||||
|
final int totalShifts;
|
||||||
|
|
||||||
|
/// Average rating received from clients (0.0 - 5.0)
|
||||||
|
final double averageRating;
|
||||||
|
|
||||||
|
/// Percentage of shifts where staff arrived on time
|
||||||
|
final int onTimeRate;
|
||||||
|
|
||||||
|
/// Number of times the staff failed to show up for a shift
|
||||||
|
final int noShowCount;
|
||||||
|
|
||||||
|
/// Number of shifts the staff has cancelled
|
||||||
|
final int cancellationCount;
|
||||||
|
|
||||||
|
/// Overall reliability score (0-100)
|
||||||
|
final int reliabilityScore;
|
||||||
|
|
||||||
|
/// Whether personal information section is complete
|
||||||
|
final bool hasPersonalInfo;
|
||||||
|
|
||||||
|
/// Whether emergency contact section is complete
|
||||||
|
final bool hasEmergencyContact;
|
||||||
|
|
||||||
|
/// Whether work experience section is complete
|
||||||
|
final bool hasExperience;
|
||||||
|
|
||||||
|
/// Whether attire photo has been uploaded
|
||||||
|
final bool hasAttire;
|
||||||
|
|
||||||
|
/// Whether required documents have been uploaded
|
||||||
|
final bool hasDocuments;
|
||||||
|
|
||||||
|
/// Whether certificates have been uploaded
|
||||||
|
final bool hasCertificates;
|
||||||
|
|
||||||
|
/// Whether tax forms have been submitted
|
||||||
|
final bool hasTaxForms;
|
||||||
|
|
||||||
|
const StaffProfileUI({
|
||||||
|
required this.staff,
|
||||||
|
required this.totalShifts,
|
||||||
|
required this.averageRating,
|
||||||
|
required this.onTimeRate,
|
||||||
|
required this.noShowCount,
|
||||||
|
required this.cancellationCount,
|
||||||
|
required this.reliabilityScore,
|
||||||
|
required this.hasPersonalInfo,
|
||||||
|
required this.hasEmergencyContact,
|
||||||
|
required this.hasExperience,
|
||||||
|
required this.hasAttire,
|
||||||
|
required this.hasDocuments,
|
||||||
|
required this.hasCertificates,
|
||||||
|
required this.hasTaxForms,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Convenience getters that delegate to the underlying Staff entity
|
||||||
|
String get fullName => staff.name;
|
||||||
|
String get email => staff.email;
|
||||||
|
String? get photoUrl => staff.avatar;
|
||||||
|
String get userId => staff.authProviderId;
|
||||||
|
String get staffId => staff.id;
|
||||||
|
|
||||||
|
/// Maps staff status to a level string for display
|
||||||
|
/// TODO: Replace with actual level data when available in Staff entity
|
||||||
|
String get level => _mapStatusToLevel(staff.status);
|
||||||
|
|
||||||
|
String _mapStatusToLevel(StaffStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case StaffStatus.active:
|
||||||
|
case StaffStatus.verified:
|
||||||
|
return 'Krower I';
|
||||||
|
case StaffStatus.pending:
|
||||||
|
case StaffStatus.completedProfile:
|
||||||
|
return 'Pending';
|
||||||
|
default:
|
||||||
|
return 'New';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
staff,
|
||||||
|
totalShifts,
|
||||||
|
averageRating,
|
||||||
|
onTimeRate,
|
||||||
|
noShowCount,
|
||||||
|
cancellationCount,
|
||||||
|
reliabilityScore,
|
||||||
|
hasPersonalInfo,
|
||||||
|
hasEmergencyContact,
|
||||||
|
hasExperience,
|
||||||
|
hasAttire,
|
||||||
|
hasDocuments,
|
||||||
|
hasCertificates,
|
||||||
|
hasTaxForms,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import '../repositories/profile_repository.dart';
|
||||||
|
import '../ui_entities/staff_profile_ui.dart';
|
||||||
|
|
||||||
|
/// Use case for fetching a staff member's extended profile information.
|
||||||
|
///
|
||||||
|
/// This use case:
|
||||||
|
/// 1. Fetches the core [Staff] entity from the repository
|
||||||
|
/// 2. Maps it to a [StaffProfileUI] entity with additional UI-specific data
|
||||||
|
///
|
||||||
|
/// Following Clean Architecture principles:
|
||||||
|
/// - Depends only on the repository interface (dependency inversion)
|
||||||
|
/// - Returns a UI entity suitable for the presentation layer
|
||||||
|
/// - Encapsulates the mapping logic from domain to UI entities
|
||||||
|
///
|
||||||
|
/// TODO: When profile statistics API is available, fetch and map real data
|
||||||
|
/// Currently returns mock statistics data.
|
||||||
|
class GetProfileUseCase implements UseCase<String, StaffProfileUI> {
|
||||||
|
final ProfileRepositoryInterface _repository;
|
||||||
|
|
||||||
|
/// Creates a [GetProfileUseCase].
|
||||||
|
///
|
||||||
|
/// Requires a [ProfileRepositoryInterface] to interact with the profile data source.
|
||||||
|
const GetProfileUseCase(this._repository);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<StaffProfileUI> call(String userId) async {
|
||||||
|
// Fetch core Staff entity from repository
|
||||||
|
final staff = await _repository.getStaffProfile(userId);
|
||||||
|
|
||||||
|
// Map to UI entity with additional profile data
|
||||||
|
// TODO: Replace mock data with actual profile statistics from backend
|
||||||
|
return StaffProfileUI(
|
||||||
|
staff: staff,
|
||||||
|
totalShifts: 0,
|
||||||
|
averageRating: 5.0,
|
||||||
|
onTimeRate: 100,
|
||||||
|
noShowCount: 0,
|
||||||
|
cancellationCount: 0,
|
||||||
|
reliabilityScore: 100,
|
||||||
|
hasPersonalInfo: staff.phone != null && staff.phone!.isNotEmpty,
|
||||||
|
hasEmergencyContact: false, // TODO: Fetch from backend
|
||||||
|
hasExperience: false, // TODO: Fetch from backend
|
||||||
|
hasAttire: staff.avatar != null,
|
||||||
|
hasDocuments: false, // TODO: Fetch from backend
|
||||||
|
hasCertificates: false, // TODO: Fetch from backend
|
||||||
|
hasTaxForms: false, // TODO: Fetch from backend
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
|
||||||
|
import '../repositories/profile_repository.dart';
|
||||||
|
|
||||||
|
/// Use case for signing out the current user.
|
||||||
|
///
|
||||||
|
/// This use case delegates the sign-out logic to the [ProfileRepositoryInterface].
|
||||||
|
///
|
||||||
|
/// Following Clean Architecture principles, this use case:
|
||||||
|
/// - Encapsulates the sign-out business rule
|
||||||
|
/// - Depends only on the repository interface
|
||||||
|
/// - Keeps the domain layer independent of external frameworks
|
||||||
|
class SignOutUseCase implements NoInputUseCase<void> {
|
||||||
|
final ProfileRepositoryInterface _repository;
|
||||||
|
|
||||||
|
/// Creates a [SignOutUseCase].
|
||||||
|
///
|
||||||
|
/// Requires a [ProfileRepositoryInterface] to perform the sign-out operation.
|
||||||
|
const SignOutUseCase(this._repository);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> call() {
|
||||||
|
return _repository.signOut();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../domain/usecases/get_profile_usecase.dart';
|
||||||
|
import '../../domain/usecases/sign_out_usecase.dart';
|
||||||
|
import 'profile_state.dart';
|
||||||
|
|
||||||
|
/// Cubit for managing the Profile feature state.
|
||||||
|
///
|
||||||
|
/// Handles loading profile data and user sign-out actions.
|
||||||
|
class ProfileCubit extends Cubit<ProfileState> {
|
||||||
|
final GetProfileUseCase _getProfileUseCase;
|
||||||
|
final SignOutUseCase _signOutUseCase;
|
||||||
|
|
||||||
|
/// Creates a [ProfileCubit] with the required use cases.
|
||||||
|
ProfileCubit(
|
||||||
|
this._getProfileUseCase,
|
||||||
|
this._signOutUseCase,
|
||||||
|
) : super(const ProfileState());
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// Requires [userId] to identify which profile to load.
|
||||||
|
Future<void> loadProfile(String userId) async {
|
||||||
|
emit(state.copyWith(status: ProfileStatus.loading));
|
||||||
|
|
||||||
|
try {
|
||||||
|
final profile = await _getProfileUseCase(userId);
|
||||||
|
emit(state.copyWith(
|
||||||
|
status: ProfileStatus.loaded,
|
||||||
|
profile: profile,
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
emit(state.copyWith(
|
||||||
|
status: ProfileStatus.error,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signs out the current user.
|
||||||
|
///
|
||||||
|
/// Delegates to the sign-out use case which handles session cleanup
|
||||||
|
/// and navigation.
|
||||||
|
Future<void> signOut() async {
|
||||||
|
try {
|
||||||
|
await _signOutUseCase();
|
||||||
|
} catch (e) {
|
||||||
|
// Error handling can be added here if needed
|
||||||
|
// For now, we let the navigation happen regardless
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import '../../domain/ui_entities/staff_profile_ui.dart';
|
||||||
|
|
||||||
|
/// Represents the various states of the profile feature.
|
||||||
|
enum ProfileStatus {
|
||||||
|
/// Initial state before any data is loaded
|
||||||
|
initial,
|
||||||
|
|
||||||
|
/// Profile data is being loaded
|
||||||
|
loading,
|
||||||
|
|
||||||
|
/// Profile data loaded successfully
|
||||||
|
loaded,
|
||||||
|
|
||||||
|
/// An error occurred while loading profile data
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State class for the Profile feature.
|
||||||
|
///
|
||||||
|
/// Contains the current profile data and loading status.
|
||||||
|
/// Uses the [StaffProfileUI] entity which wraps the shared Staff entity
|
||||||
|
/// with presentation-layer specific data.
|
||||||
|
class ProfileState extends Equatable {
|
||||||
|
/// Current status of the profile feature
|
||||||
|
final ProfileStatus status;
|
||||||
|
|
||||||
|
/// The staff member's profile UI entity (null if not loaded)
|
||||||
|
final StaffProfileUI? profile;
|
||||||
|
|
||||||
|
/// Error message if status is error
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
const ProfileState({
|
||||||
|
this.status = ProfileStatus.initial,
|
||||||
|
this.profile,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Creates a copy of this state with updated values.
|
||||||
|
ProfileState copyWith({
|
||||||
|
ProfileStatus? status,
|
||||||
|
StaffProfileUI? profile,
|
||||||
|
String? errorMessage,
|
||||||
|
}) {
|
||||||
|
return ProfileState(
|
||||||
|
status: status ?? this.status,
|
||||||
|
profile: profile ?? this.profile,
|
||||||
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [status, profile, errorMessage];
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
|
||||||
|
/// Extension on [IModularNavigator] providing typed navigation helpers
|
||||||
|
/// for the Staff Profile feature.
|
||||||
|
///
|
||||||
|
/// These methods provide a type-safe way to navigate to various profile-related
|
||||||
|
/// pages without relying on string literals throughout the codebase.
|
||||||
|
extension ProfileNavigator on IModularNavigator {
|
||||||
|
/// Navigates to the personal info page.
|
||||||
|
void pushPersonalInfo() {
|
||||||
|
pushNamed('/personal-info');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the emergency contact page.
|
||||||
|
void pushEmergencyContact() {
|
||||||
|
pushNamed('/emergency-contact');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the experience page.
|
||||||
|
void pushExperience() {
|
||||||
|
pushNamed('/experience');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the attire page.
|
||||||
|
void pushAttire() {
|
||||||
|
pushNamed('/attire');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the documents page.
|
||||||
|
void pushDocuments() {
|
||||||
|
pushNamed('/documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the certificates page.
|
||||||
|
void pushCertificates() {
|
||||||
|
pushNamed('/certificates');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the tax forms page.
|
||||||
|
void pushTaxForms() {
|
||||||
|
pushNamed('/tax-forms');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to Krow University.
|
||||||
|
void pushKrowUniversity() {
|
||||||
|
pushNamed('/krow-university');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the trainings page.
|
||||||
|
void pushTrainings() {
|
||||||
|
pushNamed('/trainings');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the leaderboard page.
|
||||||
|
void pushLeaderboard() {
|
||||||
|
pushNamed('/leaderboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the bank account page.
|
||||||
|
void pushBankAccount() {
|
||||||
|
pushNamed('/bank-account');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the timecard page.
|
||||||
|
void pushTimecard() {
|
||||||
|
pushNamed('/time-card');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the FAQs page.
|
||||||
|
void pushFaqs() {
|
||||||
|
pushNamed('/faqs');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the privacy & security page.
|
||||||
|
void pushPrivacy() {
|
||||||
|
pushNamed('/privacy');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the messages page.
|
||||||
|
void pushMessages() {
|
||||||
|
pushNamed('/messages');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the get started/authentication screen.
|
||||||
|
void navigateToGetStarted() {
|
||||||
|
navigate('/get-started/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart' hide ReadContext;
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
|
||||||
|
import '../blocs/profile_cubit.dart';
|
||||||
|
import '../blocs/profile_state.dart';
|
||||||
|
import '../navigation/profile_navigator.dart';
|
||||||
|
import '../widgets/logout_button.dart';
|
||||||
|
import '../widgets/profile_menu_grid.dart';
|
||||||
|
import '../widgets/profile_menu_item.dart';
|
||||||
|
import '../widgets/profile_header.dart';
|
||||||
|
import '../widgets/reliability_score_bar.dart';
|
||||||
|
import '../widgets/reliability_stats_card.dart';
|
||||||
|
import '../widgets/section_title.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.
|
||||||
|
class StaffProfilePage extends StatelessWidget {
|
||||||
|
/// Creates a [StaffProfilePage].
|
||||||
|
const StaffProfilePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final i18n = t.staff.profile;
|
||||||
|
|
||||||
|
return BlocProvider(
|
||||||
|
create: (_) {
|
||||||
|
// TODO: Get actual userId from auth session
|
||||||
|
// For now, using mock userId that matches ProfileRepositoryMock data
|
||||||
|
const userId = 't8P3fYh4y1cPoZbbVPXUhfQCsDo3';
|
||||||
|
return Modular.get<ProfileCubit>()..loadProfile(userId);
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: UiColors.background,
|
||||||
|
body: BlocBuilder<ProfileCubit, ProfileState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state.status == ProfileStatus.loading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status == ProfileStatus.error) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
state.errorMessage ?? 'An error occurred',
|
||||||
|
style: UiTypography.body1r.copyWith(
|
||||||
|
color: UiColors.destructive,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final profile = state.profile;
|
||||||
|
if (profile == null) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.only(bottom: 100),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ProfileHeader(
|
||||||
|
fullName: profile.fullName,
|
||||||
|
level: profile.level,
|
||||||
|
photoUrl: profile.photoUrl,
|
||||||
|
onSignOutTap: () {
|
||||||
|
context.read<ProfileCubit>().signOut();
|
||||||
|
Modular.to.navigateToGetStarted();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Transform.translate(
|
||||||
|
offset: const Offset(0, -24),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space5,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ReliabilityStatsCard(
|
||||||
|
totalShifts: profile.totalShifts,
|
||||||
|
averageRating: profile.averageRating,
|
||||||
|
onTimeRate: profile.onTimeRate,
|
||||||
|
noShowCount: profile.noShowCount,
|
||||||
|
cancellationCount: profile.cancellationCount,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
ReliabilityScoreBar(
|
||||||
|
reliabilityScore: profile.reliabilityScore,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
SectionTitle(i18n.sections.onboarding),
|
||||||
|
ProfileMenuGrid(
|
||||||
|
children: [
|
||||||
|
ProfileMenuItem(
|
||||||
|
icon: LucideIcons.user,
|
||||||
|
label: i18n.menu_items.personal_info,
|
||||||
|
completed: profile.hasPersonalInfo,
|
||||||
|
onTap: () => Modular.to.pushPersonalInfo(),
|
||||||
|
),
|
||||||
|
ProfileMenuItem(
|
||||||
|
icon: LucideIcons.phone,
|
||||||
|
label: i18n.menu_items.emergency_contact,
|
||||||
|
completed: profile.hasEmergencyContact,
|
||||||
|
onTap: () => Modular.to.pushEmergencyContact(),
|
||||||
|
),
|
||||||
|
ProfileMenuItem(
|
||||||
|
icon: LucideIcons.briefcase,
|
||||||
|
label: i18n.menu_items.experience,
|
||||||
|
completed: profile.hasExperience,
|
||||||
|
onTap: () => Modular.to.pushExperience(),
|
||||||
|
),
|
||||||
|
ProfileMenuItem(
|
||||||
|
icon: LucideIcons.shirt,
|
||||||
|
label: i18n.menu_items.attire,
|
||||||
|
completed: profile.hasAttire,
|
||||||
|
onTap: () => Modular.to.pushAttire(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
SectionTitle(i18n.sections.compliance),
|
||||||
|
ProfileMenuGrid(
|
||||||
|
crossAxisCount: 3,
|
||||||
|
children: [
|
||||||
|
ProfileMenuItem(
|
||||||
|
icon: LucideIcons.fileText,
|
||||||
|
label: i18n.menu_items.documents,
|
||||||
|
completed: profile.hasDocuments,
|
||||||
|
onTap: () => Modular.to.pushDocuments(),
|
||||||
|
),
|
||||||
|
ProfileMenuItem(
|
||||||
|
icon: LucideIcons.award,
|
||||||
|
label: i18n.menu_items.certificates,
|
||||||
|
completed: profile.hasCertificates,
|
||||||
|
onTap: () => Modular.to.pushCertificates(),
|
||||||
|
),
|
||||||
|
ProfileMenuItem(
|
||||||
|
icon: LucideIcons.fileText,
|
||||||
|
label: i18n.menu_items.tax_forms,
|
||||||
|
completed: profile.hasTaxForms,
|
||||||
|
onTap: () => Modular.to.pushTaxForms(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
SectionTitle(i18n.sections.level_up),
|
||||||
|
ProfileMenuGrid(
|
||||||
|
crossAxisCount: 3,
|
||||||
|
children: [
|
||||||
|
ProfileMenuItem(
|
||||||
|
icon: LucideIcons.graduationCap,
|
||||||
|
label: i18n.menu_items.krow_university,
|
||||||
|
onTap: () => Modular.to.pushKrowUniversity(),
|
||||||
|
),
|
||||||
|
ProfileMenuItem(
|
||||||
|
icon: LucideIcons.bookOpen,
|
||||||
|
label: i18n.menu_items.trainings,
|
||||||
|
onTap: () => Modular.to.pushTrainings(),
|
||||||
|
),
|
||||||
|
ProfileMenuItem(
|
||||||
|
icon: LucideIcons.award,
|
||||||
|
label: i18n.menu_items.leaderboard,
|
||||||
|
onTap: () => Modular.to.pushLeaderboard(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
SectionTitle(i18n.sections.finance),
|
||||||
|
ProfileMenuGrid(
|
||||||
|
crossAxisCount: 3,
|
||||||
|
children: [
|
||||||
|
ProfileMenuItem(
|
||||||
|
icon: LucideIcons.building2,
|
||||||
|
label: i18n.menu_items.bank_account,
|
||||||
|
onTap: () => Modular.to.pushBankAccount(),
|
||||||
|
),
|
||||||
|
ProfileMenuItem(
|
||||||
|
icon: LucideIcons.creditCard,
|
||||||
|
label: i18n.menu_items.payments,
|
||||||
|
onTap: () => Modular.to.navigate('/payments'),
|
||||||
|
),
|
||||||
|
ProfileMenuItem(
|
||||||
|
icon: LucideIcons.clock,
|
||||||
|
label: i18n.menu_items.timecard,
|
||||||
|
onTap: () => Modular.to.pushTimecard(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
SectionTitle(i18n.sections.support),
|
||||||
|
ProfileMenuGrid(
|
||||||
|
crossAxisCount: 3,
|
||||||
|
children: [
|
||||||
|
ProfileMenuItem(
|
||||||
|
icon: LucideIcons.helpCircle,
|
||||||
|
label: i18n.menu_items.faqs,
|
||||||
|
onTap: () => Modular.to.pushFaqs(),
|
||||||
|
),
|
||||||
|
ProfileMenuItem(
|
||||||
|
icon: LucideIcons.shield,
|
||||||
|
label: i18n.menu_items.privacy_security,
|
||||||
|
onTap: () => Modular.to.pushPrivacy(),
|
||||||
|
),
|
||||||
|
ProfileMenuItem(
|
||||||
|
icon: LucideIcons.messageCircle,
|
||||||
|
label: i18n.menu_items.messages,
|
||||||
|
onTap: () => Modular.to.pushMessages(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
LogoutButton(
|
||||||
|
onTap: () {
|
||||||
|
context.read<ProfileCubit>().signOut();
|
||||||
|
Modular.to.navigateToGetStarted();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// NOTE: This is a TEMPORARY class to allow the prototype code to compile
|
||||||
|
/// without immediate design system integration.
|
||||||
|
/// It will be replaced by the actual Design System tokens (UiColors) in Step 4.
|
||||||
|
class AppColors {
|
||||||
|
static const Color krowBackground = Color(0xFFF9F9F9);
|
||||||
|
static const Color krowBlue = Color(0xFF0055FF);
|
||||||
|
static const Color krowYellow = Color(0xFFFFCC00);
|
||||||
|
static const Color krowBorder = Color(0xFFE0E0E0);
|
||||||
|
static const Color krowCharcoal = Color(0xFF333333);
|
||||||
|
static const Color krowMuted = Color(0xFF808080);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
|
||||||
|
/// The sign-out button widget.
|
||||||
|
///
|
||||||
|
/// Uses design system tokens for all colors, typography, spacing, and icons.
|
||||||
|
class LogoutButton extends StatelessWidget {
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const LogoutButton({super.key, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final i18n = t.staff.profile.header;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||||
|
border: Border.all(color: UiColors.border),
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: UiConstants.space4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(LucideIcons.logOut, color: UiColors.destructive, size: 20),
|
||||||
|
SizedBox(width: UiConstants.space2),
|
||||||
|
Text(
|
||||||
|
i18n.sign_out,
|
||||||
|
style: UiTypography.body1m.copyWith(
|
||||||
|
color: UiColors.destructive,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
|
||||||
|
/// The header section of the staff profile page, containing avatar, name, level,
|
||||||
|
/// and a sign-out button.
|
||||||
|
///
|
||||||
|
/// Uses design system tokens for all colors, typography, and spacing.
|
||||||
|
class ProfileHeader extends StatelessWidget {
|
||||||
|
/// The staff member's full name
|
||||||
|
final String fullName;
|
||||||
|
|
||||||
|
/// The staff member's level (e.g., "Krower I")
|
||||||
|
final String level;
|
||||||
|
|
||||||
|
/// Optional photo URL for the avatar
|
||||||
|
final String? photoUrl;
|
||||||
|
|
||||||
|
/// Callback when sign out is tapped
|
||||||
|
final VoidCallback onSignOutTap;
|
||||||
|
|
||||||
|
/// Creates a [ProfileHeader].
|
||||||
|
const ProfileHeader({
|
||||||
|
super.key,
|
||||||
|
required this.fullName,
|
||||||
|
required this.level,
|
||||||
|
this.photoUrl,
|
||||||
|
required this.onSignOutTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final i18n = t.staff.profile.header;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
UiConstants.space5,
|
||||||
|
UiConstants.space5,
|
||||||
|
UiConstants.space5,
|
||||||
|
UiConstants.space16,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.primary,
|
||||||
|
borderRadius: BorderRadius.vertical(
|
||||||
|
bottom: Radius.circular(UiConstants.space6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Top Bar
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
i18n.title,
|
||||||
|
style: UiTypography.headline4m.copyWith(
|
||||||
|
color: UiColors.primaryForeground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onSignOutTap,
|
||||||
|
child: Text(
|
||||||
|
i18n.sign_out,
|
||||||
|
style: UiTypography.body2m.copyWith(
|
||||||
|
color: UiColors.primaryForeground.withOpacity(0.8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: UiConstants.space8),
|
||||||
|
// Avatar Section
|
||||||
|
Stack(
|
||||||
|
alignment: Alignment.bottomRight,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 112,
|
||||||
|
height: 112,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
UiColors.accent,
|
||||||
|
UiColors.accent.withOpacity(0.5),
|
||||||
|
UiColors.primaryForeground,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.foreground.withOpacity(0.2),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: UiColors.primaryForeground.withOpacity(0.2),
|
||||||
|
width: 4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: CircleAvatar(
|
||||||
|
backgroundColor: UiColors.background,
|
||||||
|
backgroundImage: photoUrl != null
|
||||||
|
? NetworkImage(photoUrl!)
|
||||||
|
: null,
|
||||||
|
child: photoUrl == null
|
||||||
|
? Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
UiColors.accent,
|
||||||
|
UiColors.accent.withOpacity(0.7),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
fullName.isNotEmpty ? fullName[0].toUpperCase() : 'K',
|
||||||
|
style: UiTypography.displayM.copyWith(
|
||||||
|
color: UiColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.primaryForeground,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: UiColors.primary, width: 2),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.foreground.withOpacity(0.1),
|
||||||
|
blurRadius: 4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
LucideIcons.camera,
|
||||||
|
size: 16,
|
||||||
|
color: UiColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: UiConstants.space4),
|
||||||
|
Text(
|
||||||
|
fullName,
|
||||||
|
style: UiTypography.headline3m.copyWith(
|
||||||
|
color: UiColors.primaryForeground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: UiConstants.space1),
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space3,
|
||||||
|
vertical: UiConstants.space1,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.accent.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.space5),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
level,
|
||||||
|
style: UiTypography.footnote1b.copyWith(
|
||||||
|
color: UiColors.accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
|
||||||
|
/// Lays out a list of widgets (intended for [ProfileMenuItem]s) in a responsive grid.
|
||||||
|
/// It uses [Wrap] and manually calculates item width based on the screen size.
|
||||||
|
class ProfileMenuGrid extends StatelessWidget {
|
||||||
|
final int crossAxisCount;
|
||||||
|
final List<Widget> children;
|
||||||
|
|
||||||
|
const ProfileMenuGrid({
|
||||||
|
super.key,
|
||||||
|
required this.children,
|
||||||
|
this.crossAxisCount = 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Spacing between items
|
||||||
|
final double spacing = UiConstants.space3;
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final totalWidth = constraints.maxWidth;
|
||||||
|
final totalSpacingWidth = spacing * (crossAxisCount - 1);
|
||||||
|
final itemWidth = (totalWidth - totalSpacingWidth) / crossAxisCount;
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: spacing,
|
||||||
|
runSpacing: spacing,
|
||||||
|
children: children.map((child) {
|
||||||
|
return SizedBox(
|
||||||
|
width: itemWidth,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
|
||||||
|
/// An individual item within the profile menu grid.
|
||||||
|
///
|
||||||
|
/// Uses design system tokens for all colors, typography, spacing, and borders.
|
||||||
|
class ProfileMenuItem extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final bool? completed;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const ProfileMenuItem({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
this.completed,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||||
|
border: Border.all(color: UiColors.border),
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.all(UiConstants.space2),
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 1.0,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.primary.withOpacity(0.08),
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(icon, color: UiColors.primary, size: 24),
|
||||||
|
),
|
||||||
|
SizedBox(height: UiConstants.space2),
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: UiConstants.space1),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: UiTypography.footnote1m.copyWith(
|
||||||
|
color: UiColors.foreground,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (completed != null)
|
||||||
|
Positioned(
|
||||||
|
top: UiConstants.space2,
|
||||||
|
right: UiConstants.space2,
|
||||||
|
child: Container(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: completed!
|
||||||
|
? UiColors.primary
|
||||||
|
: UiColors.primary.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: completed!
|
||||||
|
? const Icon(Icons.check, size: 10, color: UiColors.primaryForeground)
|
||||||
|
: Text(
|
||||||
|
"!",
|
||||||
|
style: UiTypography.footnote2b.copyWith(
|
||||||
|
color: UiColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
|
||||||
|
/// Displays the staff member's reliability score as a progress bar.
|
||||||
|
///
|
||||||
|
/// Uses design system tokens for all colors, typography, and spacing.
|
||||||
|
class ReliabilityScoreBar extends StatelessWidget {
|
||||||
|
final int reliabilityScore;
|
||||||
|
|
||||||
|
const ReliabilityScoreBar({
|
||||||
|
super.key,
|
||||||
|
required this.reliabilityScore,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final i18n = t.staff.profile.reliability_score;
|
||||||
|
final score = reliabilityScore / 100;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.all(UiConstants.space4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.primary.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
i18n.title,
|
||||||
|
style: UiTypography.body2m.copyWith(
|
||||||
|
color: UiColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
"$reliabilityScore%",
|
||||||
|
style: UiTypography.headline4m.copyWith(
|
||||||
|
color: UiColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: UiConstants.space2),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.space1),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: score,
|
||||||
|
backgroundColor: UiColors.background,
|
||||||
|
color: UiColors.primary,
|
||||||
|
minHeight: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(top: UiConstants.space2),
|
||||||
|
child: Text(
|
||||||
|
i18n.description,
|
||||||
|
style: UiTypography.footnote2r.copyWith(
|
||||||
|
color: UiColors.mutedForeground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons/lucide_icons.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
|
||||||
|
/// Displays the staff member's reliability statistics (Shifts, Rating, On Time, etc.).
|
||||||
|
///
|
||||||
|
/// Uses design system tokens for all colors, typography, spacing, and icons.
|
||||||
|
class ReliabilityStatsCard extends StatelessWidget {
|
||||||
|
final int totalShifts;
|
||||||
|
final double averageRating;
|
||||||
|
final int onTimeRate;
|
||||||
|
final int noShowCount;
|
||||||
|
final int cancellationCount;
|
||||||
|
|
||||||
|
const ReliabilityStatsCard({
|
||||||
|
super.key,
|
||||||
|
required this.totalShifts,
|
||||||
|
required this.averageRating,
|
||||||
|
required this.onTimeRate,
|
||||||
|
required this.noShowCount,
|
||||||
|
required this.cancellationCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.all(UiConstants.space4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||||
|
border: Border.all(color: UiColors.border),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.foreground.withOpacity(0.05),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
_buildStatItem(
|
||||||
|
context,
|
||||||
|
LucideIcons.briefcase,
|
||||||
|
"$totalShifts",
|
||||||
|
"Shifts",
|
||||||
|
),
|
||||||
|
_buildStatItem(
|
||||||
|
context,
|
||||||
|
LucideIcons.star,
|
||||||
|
averageRating.toStringAsFixed(1),
|
||||||
|
"Rating",
|
||||||
|
),
|
||||||
|
_buildStatItem(
|
||||||
|
context,
|
||||||
|
LucideIcons.clock,
|
||||||
|
"$onTimeRate%",
|
||||||
|
"On Time",
|
||||||
|
),
|
||||||
|
_buildStatItem(
|
||||||
|
context,
|
||||||
|
LucideIcons.xCircle,
|
||||||
|
"$noShowCount",
|
||||||
|
"No Shows",
|
||||||
|
),
|
||||||
|
_buildStatItem(
|
||||||
|
context,
|
||||||
|
LucideIcons.ban,
|
||||||
|
"$cancellationCount",
|
||||||
|
"Cancel.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatItem(
|
||||||
|
BuildContext context,
|
||||||
|
IconData icon,
|
||||||
|
String value,
|
||||||
|
String label,
|
||||||
|
) {
|
||||||
|
return Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.primary.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(icon, size: 20, color: UiColors.primary),
|
||||||
|
),
|
||||||
|
SizedBox(height: UiConstants.space1),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: UiTypography.body1b.copyWith(
|
||||||
|
color: UiColors.foreground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: UiTypography.footnote2r.copyWith(
|
||||||
|
color: UiColors.mutedForeground,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
|
||||||
|
/// Displays a capitalized, muted section title.
|
||||||
|
///
|
||||||
|
/// Uses design system tokens for typography, colors, and spacing.
|
||||||
|
class SectionTitle extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
const SectionTitle(this.title, {super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: EdgeInsets.only(left: UiConstants.space1),
|
||||||
|
margin: EdgeInsets.only(bottom: UiConstants.space3),
|
||||||
|
child: Text(
|
||||||
|
title.toUpperCase(),
|
||||||
|
style: UiTypography.footnote1b.copyWith(
|
||||||
|
color: UiColors.mutedForeground,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
|
|
||||||
|
import 'data/repositories/profile_repository_impl.dart';
|
||||||
|
import 'domain/repositories/profile_repository.dart';
|
||||||
|
import 'domain/usecases/get_profile_usecase.dart';
|
||||||
|
import 'domain/usecases/sign_out_usecase.dart';
|
||||||
|
import 'presentation/blocs/profile_cubit.dart';
|
||||||
|
import '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:
|
||||||
|
/// - Data source (ProfileRepositoryMock) from data_connect package
|
||||||
|
/// - Repository implementation (ProfileRepositoryImpl) delegates to data_connect
|
||||||
|
/// - Use cases depend on repository interface
|
||||||
|
/// - Cubit depends on use cases
|
||||||
|
class StaffProfileModule extends Module {
|
||||||
|
@override
|
||||||
|
void binds(Injector i) {
|
||||||
|
// Data layer - Get mock from data_connect package
|
||||||
|
i.addLazySingleton<ProfileRepositoryMock>(ProfileRepositoryMock.new);
|
||||||
|
|
||||||
|
// Repository implementation - delegates to data_connect
|
||||||
|
i.addLazySingleton<ProfileRepositoryInterface>(
|
||||||
|
() => ProfileRepositoryImpl(i.get<ProfileRepositoryMock>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use cases - depend on repository interface
|
||||||
|
i.addLazySingleton<GetProfileUseCase>(
|
||||||
|
() => GetProfileUseCase(i.get<ProfileRepositoryInterface>()),
|
||||||
|
);
|
||||||
|
i.addLazySingleton<SignOutUseCase>(
|
||||||
|
() => SignOutUseCase(i.get<ProfileRepositoryInterface>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Presentation layer - Cubit depends on use cases
|
||||||
|
i.addSingleton(
|
||||||
|
() => ProfileCubit(
|
||||||
|
i.get<GetProfileUseCase>(),
|
||||||
|
i.get<SignOutUseCase>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void routes(RouteManager r) {
|
||||||
|
r.child('/', child: (BuildContext context) => const StaffProfilePage());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/// Export the modular feature definition.
|
||||||
|
export 'src/staff_profile_module.dart';
|
||||||
41
apps/mobile/packages/features/staff/profile/pubspec.yaml
Normal file
41
apps/mobile/packages/features/staff/profile/pubspec.yaml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: staff_profile
|
||||||
|
description: Staff Profile feature package.
|
||||||
|
version: 0.0.1
|
||||||
|
publish_to: 'none'
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
flutter: ">=1.17.0"
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# Architecture
|
||||||
|
flutter_modular: ^5.0.0
|
||||||
|
flutter_bloc: ^8.1.3
|
||||||
|
|
||||||
|
# Utility/DI
|
||||||
|
injectable: ^2.3.0
|
||||||
|
get_it: ^7.6.4
|
||||||
|
|
||||||
|
# Project-specific packages
|
||||||
|
domain:
|
||||||
|
path: ../../../domain
|
||||||
|
data_connect:
|
||||||
|
path: ../../../data_connect
|
||||||
|
core_localization:
|
||||||
|
path: ../../../core_localization
|
||||||
|
design_system:
|
||||||
|
path: ../../../design_system # Assuming this path
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_lints: ^2.0.0
|
||||||
|
injectable_generator: ^2.4.1
|
||||||
|
build_runner: ^2.4.6
|
||||||
|
|
||||||
|
# Flutter modular configuration
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
Reference in New Issue
Block a user