diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 311abb8b..3ab3b851 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -453,15 +453,51 @@ "refer": { "title": "Refer a Friend", "page": "/worker-profile" } } } - } - }, - "staff_main": { - "tabs": { - "shifts": "Shifts", - "payments": "Payments", - "home": "Home", - "clock_in": "Clock In", - "profile": "Profile" + }, + "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", + "timecard": "Timecard", + "faqs": "FAQs", + "privacy_security": "Privacy & Security", + "messages": "Messages" + }, + "logout": { + "button": "Sign Out" + } } } } diff --git a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart index a67d149b..01ff1773 100644 --- a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart +++ b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart @@ -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'; diff --git a/apps/mobile/packages/data_connect/lib/src/mocks/profile_repository_mock.dart b/apps/mobile/packages/data_connect/lib/src/mocks/profile_repository_mock.dart new file mode 100644 index 00000000..b4409833 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/mocks/profile_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 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 signOut() async { + // Simulate processing delay + await Future.delayed(const Duration(milliseconds: 300)); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart b/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart new file mode 100644 index 00000000..9f38a448 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart @@ -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 getStaffProfile(String userId) { + // Delegate directly to data_connect - no business logic here + return _dataConnectRepository.getStaffProfile(userId); + } + + @override + Future signOut() { + // Delegate directly to data_connect - no business logic here + return _dataConnectRepository.signOut(); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository.dart new file mode 100644 index 00000000..878933d0 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository.dart @@ -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 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 signOut(); +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/ui_entities/staff_profile_ui.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/ui_entities/staff_profile_ui.dart new file mode 100644 index 00000000..2f306905 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/domain/ui_entities/staff_profile_ui.dart @@ -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 get props => [ + staff, + totalShifts, + averageRating, + onTimeRate, + noShowCount, + cancellationCount, + reliabilityScore, + hasPersonalInfo, + hasEmergencyContact, + hasExperience, + hasAttire, + hasDocuments, + hasCertificates, + hasTaxForms, + ]; +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_profile_usecase.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_profile_usecase.dart new file mode 100644 index 00000000..8834eda5 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_profile_usecase.dart @@ -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 { + final ProfileRepositoryInterface _repository; + + /// Creates a [GetProfileUseCase]. + /// + /// Requires a [ProfileRepositoryInterface] to interact with the profile data source. + const GetProfileUseCase(this._repository); + + @override + Future 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 + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/sign_out_usecase.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/sign_out_usecase.dart new file mode 100644 index 00000000..621d85a8 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/sign_out_usecase.dart @@ -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 { + final ProfileRepositoryInterface _repository; + + /// Creates a [SignOutUseCase]. + /// + /// Requires a [ProfileRepositoryInterface] to perform the sign-out operation. + const SignOutUseCase(this._repository); + + @override + Future call() { + return _repository.signOut(); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart new file mode 100644 index 00000000..9eb307ff --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart @@ -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 { + 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 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 signOut() async { + try { + await _signOutUseCase(); + } catch (e) { + // Error handling can be added here if needed + // For now, we let the navigation happen regardless + } + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart new file mode 100644 index 00000000..645d241b --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart @@ -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 get props => [status, profile, errorMessage]; +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/navigation/profile_navigator.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/navigation/profile_navigator.dart new file mode 100644 index 00000000..e71d7067 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/navigation/profile_navigator.dart @@ -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/'); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart new file mode 100644 index 00000000..562b4621 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart @@ -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()..loadProfile(userId); + }, + child: Scaffold( + backgroundColor: UiColors.background, + body: BlocBuilder( + 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().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().signOut(); + Modular.to.navigateToGetStarted(); + }, + ), + ], + ), + ), + ), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/temp_theme_support.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/temp_theme_support.dart new file mode 100644 index 00000000..26115503 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/temp_theme_support.dart @@ -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); +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/logout_button.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/logout_button.dart new file mode 100644 index 00000000..51269530 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/logout_button.dart @@ -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, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_header.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_header.dart new file mode 100644 index 00000000..45aba2da --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_header.dart @@ -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, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_menu_grid.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_menu_grid.dart new file mode 100644 index 00000000..960eec89 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_menu_grid.dart @@ -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 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(), + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_menu_item.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_menu_item.dart new file mode 100644 index 00000000..e7a4f302 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_menu_item.dart @@ -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, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/reliability_score_bar.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/reliability_score_bar.dart new file mode 100644 index 00000000..ec32de87 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/reliability_score_bar.dart @@ -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, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/reliability_stats_card.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/reliability_stats_card.dart new file mode 100644 index 00000000..b3a68eaf --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/reliability_stats_card.dart @@ -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, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/section_title.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/section_title.dart new file mode 100644 index 00000000..89167c61 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/section_title.dart @@ -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, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart new file mode 100644 index 00000000..73a3ffbf --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart @@ -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.new); + + // Repository implementation - delegates to data_connect + i.addLazySingleton( + () => ProfileRepositoryImpl(i.get()), + ); + + // Use cases - depend on repository interface + i.addLazySingleton( + () => GetProfileUseCase(i.get()), + ); + i.addLazySingleton( + () => SignOutUseCase(i.get()), + ); + + // Presentation layer - Cubit depends on use cases + i.addSingleton( + () => ProfileCubit( + i.get(), + i.get(), + ), + ); + } + + @override + void routes(RouteManager r) { + r.child('/', child: (BuildContext context) => const StaffProfilePage()); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/staff_profile.dart b/apps/mobile/packages/features/staff/profile/lib/staff_profile.dart new file mode 100644 index 00000000..4ca64d73 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/staff_profile.dart @@ -0,0 +1,2 @@ +/// Export the modular feature definition. +export 'src/staff_profile_module.dart'; diff --git a/apps/mobile/packages/features/staff/profile/pubspec.yaml b/apps/mobile/packages/features/staff/profile/pubspec.yaml new file mode 100644 index 00000000..b6366fc6 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/pubspec.yaml @@ -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