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:
Achintha Isuru
2026-01-24 14:25:17 -05:00
parent 633af6fab0
commit 6c72c2d9fd
23 changed files with 1489 additions and 9 deletions

View File

@@ -453,15 +453,51 @@
"refer": { "title": "Refer a Friend", "page": "/worker-profile" }
}
}
}
},
"staff_main": {
"tabs": {
"profile": {
"header": {
"title": "Profile",
"sign_out": "SIGN OUT"
},
"reliability_stats": {
"shifts": "Shifts",
"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",
"home": "Home",
"clock_in": "Clock In",
"profile": "Profile"
"timecard": "Timecard",
"faqs": "FAQs",
"privacy_security": "Privacy & Security",
"messages": "Messages"
},
"logout": {
"button": "Sign Out"
}
}
}
}

View File

@@ -9,6 +9,7 @@ library;
export 'src/mocks/auth_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/skill_repository_mock.dart';
export 'src/mocks/financial_repository_mock.dart';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
/// Export the modular feature definition.
export 'src/staff_profile_module.dart';

View 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