From f81e1949d1bc8a2b1e8acfca682b47ec55af19eb Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Sat, 24 Jan 2026 21:04:59 -0500 Subject: [PATCH] feat: Add experience management feature with UI, BLoC integration, and repository implementation --- .../lib/src/l10n/en.i18n.json | 36 +++ .../lib/src/data_connect_module.dart | 2 + .../src/mocks/profile_repository_mock.dart | 22 ++ .../navigation/profile_navigator.dart | 2 +- .../profile/lib/src/staff_profile_module.dart | 2 + .../features/staff/profile/pubspec.yaml | 2 + .../experience/analysis_options.yaml | 1 + .../experience_repository_impl.dart | 29 +++ .../arguments/get_experience_arguments.dart | 10 + .../arguments/save_experience_arguments.dart | 16 ++ .../experience_repository_interface.dart | 15 ++ .../get_staff_industries_usecase.dart | 15 ++ .../usecases/get_staff_skills_usecase.dart | 15 ++ .../usecases/save_experience_usecase.dart | 22 ++ .../presentation/blocs/experience_bloc.dart | 227 +++++++++++++++++ .../navigation/experience_navigator.dart | 6 + .../presentation/pages/experience_page.dart | 240 ++++++++++++++++++ .../widgets/experience_badge.dart | 43 ++++ .../widgets/experience_custom_input.dart | 83 ++++++ .../widgets/experience_section_title.dart | 20 ++ .../lib/staff_profile_experience.dart | 58 +++++ .../onboarding/experience/pubspec.yaml | 35 +++ apps/mobile/pubspec.yaml | 1 + 23 files changed, 901 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/experience/analysis_options.yaml create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/data/repositories/experience_repository_impl.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/arguments/get_experience_arguments.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/arguments/save_experience_arguments.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/repositories/experience_repository_interface.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/get_staff_industries_usecase.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/get_staff_skills_usecase.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/save_experience_usecase.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_bloc.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/navigation/experience_navigator.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/pages/experience_page.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/widgets/experience_badge.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/widgets/experience_custom_input.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/widgets/experience_section_title.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/staff_profile_experience.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/experience/pubspec.yaml 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 5abb403e..f3390399 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 @@ -524,6 +524,42 @@ "locations_hint": "Downtown, Midtown, Brooklyn...", "save_button": "Save Changes", "save_success": "Personal info saved successfully" + }, + "experience": { + "title": "Experience & Skills", + "industries_title": "Industries", + "industries_subtitle": "Select the industries you have experience in", + "skills_title": "Skills", + "skills_subtitle": "Select your skills or add custom ones", + "custom_skills_title": "Custom Skills:", + "custom_skill_hint": "Add custom skill...", + "save_button": "Save & Continue", + "industries": { + "hospitality": "Hospitality", + "food_service": "Food Service", + "warehouse": "Warehouse", + "events": "Events", + "retail": "Retail", + "healthcare": "Healthcare", + "other": "Other" + }, + "skills": { + "food_service": "Food Service", + "bartending": "Bartending", + "event_setup": "Event Setup", + "hospitality": "Hospitality", + "warehouse": "Warehouse", + "customer_service": "Customer Service", + "cleaning": "Cleaning", + "security": "Security", + "retail": "Retail", + "cooking": "Cooking", + "cashier": "Cashier", + "server": "Server", + "barista": "Barista", + "host_hostess": "Host/Hostess", + "busser": "Busser" + } } } } diff --git a/apps/mobile/packages/data_connect/lib/src/data_connect_module.dart b/apps/mobile/packages/data_connect/lib/src/data_connect_module.dart index 5d1036f1..984db50d 100644 --- a/apps/mobile/packages/data_connect/lib/src/data_connect_module.dart +++ b/apps/mobile/packages/data_connect/lib/src/data_connect_module.dart @@ -3,6 +3,7 @@ import 'mocks/auth_repository_mock.dart'; import 'mocks/business_repository_mock.dart'; import 'mocks/home_repository_mock.dart'; import 'mocks/order_repository_mock.dart'; +import 'mocks/profile_repository_mock.dart'; /// A module that provides Data Connect dependencies, including mocks. class DataConnectModule extends Module { @@ -10,6 +11,7 @@ class DataConnectModule extends Module { void exportedBinds(Injector i) { // Make these mocks available to any module that imports this one. i.addLazySingleton(AuthRepositoryMock.new); + i.addLazySingleton(ProfileRepositoryMock.new); i.addLazySingleton(HomeRepositoryMock.new); i.addLazySingleton(BusinessRepositoryMock.new); i.addLazySingleton(OrderRepositoryMock.new); 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 index 444e5363..e5688f29 100644 --- 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 @@ -65,4 +65,26 @@ class ProfileRepositoryMock { await Future.delayed(const Duration(seconds: 1)); // Simulate save } + + /// Fetches selected industries for the given staff ID. + Future> getStaffIndustries(String staffId) async { + await Future.delayed(const Duration(milliseconds: 500)); + return ['hospitality', 'events']; + } + + /// Fetches selected skills for the given staff ID. + Future> getStaffSkills(String staffId) async { + await Future.delayed(const Duration(milliseconds: 500)); + return ['Bartending', 'Server']; + } + + /// Saves experience (industries and skills) for the given staff ID. + Future saveExperience( + String staffId, + List industries, + List skills, + ) async { + await Future.delayed(const Duration(seconds: 1)); + // Simulate save + } } 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 index 30b71042..286486a6 100644 --- 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 @@ -18,7 +18,7 @@ extension ProfileNavigator on IModularNavigator { /// Navigates to the experience page. void pushExperience() { - pushNamed('/profile/onboarding/experience'); + pushNamed('/profile/experience'); } /// Navigates to the attire page. 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 index 2cdbc0af..58076e24 100644 --- 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 @@ -3,6 +3,7 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:staff_profile_info/staff_profile_info.dart'; import 'package:staff_emergency_contact/staff_emergency_contact.dart'; +import 'package:staff_profile_experience/staff_profile_experience.dart'; import 'data/repositories/profile_repository_impl.dart'; import 'domain/repositories/profile_repository.dart'; @@ -55,5 +56,6 @@ class StaffProfileModule extends Module { r.child('/', child: (BuildContext context) => const StaffProfilePage()); r.module('/onboarding', module: StaffProfileInfoModule()); r.module('/emergency-contact', module: StaffEmergencyContactModule()); + r.module('/experience', module: StaffProfileExperienceModule()); } } diff --git a/apps/mobile/packages/features/staff/profile/pubspec.yaml b/apps/mobile/packages/features/staff/profile/pubspec.yaml index 5f494249..76c872f6 100644 --- a/apps/mobile/packages/features/staff/profile/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile/pubspec.yaml @@ -34,6 +34,8 @@ dependencies: path: ../profile_sections/onboarding/profile_info staff_emergency_contact: path: ../profile_sections/onboarding/emergency_contact + staff_profile_experience: + path: ../profile_sections/onboarding/experience dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/analysis_options.yaml b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/analysis_options.yaml new file mode 100644 index 00000000..0c1670dc --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/analysis_options.yaml @@ -0,0 +1 @@ +# include: package:flutter_lints/flutter.yaml diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/data/repositories/experience_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/data/repositories/experience_repository_impl.dart new file mode 100644 index 00000000..3e451137 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/data/repositories/experience_repository_impl.dart @@ -0,0 +1,29 @@ +import 'package:krow_data_connect/krow_data_connect.dart'; +import '../../domain/repositories/experience_repository_interface.dart'; + +/// Implementation of [ExperienceRepositoryInterface] that delegates to Data Connect. +class ExperienceRepositoryImpl implements ExperienceRepositoryInterface { + final ProfileRepositoryMock _mockRepository; + + /// Creates a [ExperienceRepositoryImpl] with the given [ProfileRepositoryMock]. + ExperienceRepositoryImpl(this._mockRepository); + + @override + Future> getIndustries(String staffId) { + return _mockRepository.getStaffIndustries(staffId); + } + + @override + Future> getSkills(String staffId) { + return _mockRepository.getStaffSkills(staffId); + } + + @override + Future saveExperience( + String staffId, + List industries, + List skills, + ) { + return _mockRepository.saveExperience(staffId, industries, skills); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/arguments/get_experience_arguments.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/arguments/get_experience_arguments.dart new file mode 100644 index 00000000..20ca4cc3 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/arguments/get_experience_arguments.dart @@ -0,0 +1,10 @@ +import 'package:krow_core/core.dart'; + +class GetExperienceArguments extends UseCaseArgument { + final String staffId; + + GetExperienceArguments({required this.staffId}); + + @override + List get props => [staffId]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/arguments/save_experience_arguments.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/arguments/save_experience_arguments.dart new file mode 100644 index 00000000..6e911989 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/arguments/save_experience_arguments.dart @@ -0,0 +1,16 @@ +import 'package:krow_core/core.dart'; + +class SaveExperienceArguments extends UseCaseArgument { + final String staffId; + final List industries; + final List skills; + + SaveExperienceArguments({ + required this.staffId, + required this.industries, + required this.skills, + }); + + @override + List get props => [staffId, industries, skills]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/repositories/experience_repository_interface.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/repositories/experience_repository_interface.dart new file mode 100644 index 00000000..6f2e7d5f --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/repositories/experience_repository_interface.dart @@ -0,0 +1,15 @@ +/// Interface for accessing staff experience data. +abstract class ExperienceRepositoryInterface { + /// Fetches the list of industries associated with the staff member. + Future> getIndustries(String staffId); + + /// Fetches the list of skills associated with the staff member. + Future> getSkills(String staffId); + + /// Saves the staff member's experience (industries and skills). + Future saveExperience( + String staffId, + List industries, + List skills, + ); +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/get_staff_industries_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/get_staff_industries_usecase.dart new file mode 100644 index 00000000..d23671a1 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/get_staff_industries_usecase.dart @@ -0,0 +1,15 @@ +import 'package:krow_core/core.dart'; +import '../arguments/get_experience_arguments.dart'; +import '../repositories/experience_repository_interface.dart'; + +/// Use case for fetching staff industries. +class GetStaffIndustriesUseCase implements UseCase> { + final ExperienceRepositoryInterface _repository; + + GetStaffIndustriesUseCase(this._repository); + + @override + Future> call(GetExperienceArguments input) { + return _repository.getIndustries(input.staffId); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/get_staff_skills_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/get_staff_skills_usecase.dart new file mode 100644 index 00000000..7d7915e6 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/get_staff_skills_usecase.dart @@ -0,0 +1,15 @@ +import 'package:krow_core/core.dart'; +import '../arguments/get_experience_arguments.dart'; +import '../repositories/experience_repository_interface.dart'; + +/// Use case for fetching staff skills. +class GetStaffSkillsUseCase implements UseCase> { + final ExperienceRepositoryInterface _repository; + + GetStaffSkillsUseCase(this._repository); + + @override + Future> call(GetExperienceArguments input) { + return _repository.getSkills(input.staffId); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/save_experience_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/save_experience_usecase.dart new file mode 100644 index 00000000..08fc7ada --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/domain/usecases/save_experience_usecase.dart @@ -0,0 +1,22 @@ +import 'package:krow_core/core.dart'; +import '../arguments/save_experience_arguments.dart'; +import '../repositories/experience_repository_interface.dart'; + +/// Use case for saving staff experience details. +/// +/// Delegates the saving logic to [ExperienceRepositoryInterface]. +class SaveExperienceUseCase extends UseCase { + final ExperienceRepositoryInterface repository; + + /// Creates a [SaveExperienceUseCase]. + SaveExperienceUseCase(this.repository); + + @override + Future call(SaveExperienceArguments params) { + return repository.saveExperience( + params.staffId, + params.industries, + params.skills, + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_bloc.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_bloc.dart new file mode 100644 index 00000000..4ef70d5a --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_bloc.dart @@ -0,0 +1,227 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../domain/arguments/get_experience_arguments.dart'; +import '../../domain/arguments/save_experience_arguments.dart'; +import '../../domain/usecases/get_staff_industries_usecase.dart'; +import '../../domain/usecases/get_staff_skills_usecase.dart'; +import '../../domain/usecases/save_experience_usecase.dart'; + +// Events +abstract class ExperienceEvent extends Equatable { + const ExperienceEvent(); + + @override + List get props => []; +} + +class ExperienceLoaded extends ExperienceEvent {} + +class ExperienceIndustryToggled extends ExperienceEvent { + final String industry; + const ExperienceIndustryToggled(this.industry); + + @override + List get props => [industry]; +} + +class ExperienceSkillToggled extends ExperienceEvent { + final String skill; + const ExperienceSkillToggled(this.skill); + + @override + List get props => [skill]; +} + +class ExperienceCustomSkillAdded extends ExperienceEvent { + final String skill; + const ExperienceCustomSkillAdded(this.skill); + + @override + List get props => [skill]; +} + +class ExperienceSubmitted extends ExperienceEvent {} + +// State +enum ExperienceStatus { initial, loading, success, failure } + +class ExperienceState extends Equatable { + final ExperienceStatus status; + final List selectedIndustries; + final List selectedSkills; + final List availableIndustries; + final List availableSkills; + final String? errorMessage; + + const ExperienceState({ + this.status = ExperienceStatus.initial, + this.selectedIndustries = const [], + this.selectedSkills = const [], + this.availableIndustries = const [], + this.availableSkills = const [], + this.errorMessage, + }); + + ExperienceState copyWith({ + ExperienceStatus? status, + List? selectedIndustries, + List? selectedSkills, + List? availableIndustries, + List? availableSkills, + String? errorMessage, + }) { + return ExperienceState( + status: status ?? this.status, + selectedIndustries: selectedIndustries ?? this.selectedIndustries, + selectedSkills: selectedSkills ?? this.selectedSkills, + availableIndustries: availableIndustries ?? this.availableIndustries, + availableSkills: availableSkills ?? this.availableSkills, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [ + status, + selectedIndustries, + selectedSkills, + availableIndustries, + availableSkills, + errorMessage, + ]; +} + +// BLoC +class ExperienceBloc extends Bloc { + static const List _kAllIndustries = [ + 'hospitality', + 'food_service', + 'warehouse', + 'events', + 'retail', + 'healthcare', + 'other', + ]; + + static const List _kAllSkills = [ + 'food_service', + 'bartending', + 'event_setup', + 'hospitality', + 'warehouse', + 'customer_service', + 'cleaning', + 'security', + 'retail', + 'cooking', + 'cashier', + 'server', + 'barista', + 'host_hostess', + 'busser', + ]; + + final GetStaffIndustriesUseCase getIndustries; + final GetStaffSkillsUseCase getSkills; + final SaveExperienceUseCase saveExperience; + final String staffId; + + ExperienceBloc({ + required this.getIndustries, + required this.getSkills, + required this.saveExperience, + required this.staffId, + }) : super(const ExperienceState( + availableIndustries: _kAllIndustries, + availableSkills: _kAllSkills, + )) { + on(_onLoaded); + on(_onIndustryToggled); + on(_onSkillToggled); + on(_onCustomSkillAdded); + on(_onSubmitted); + } + + Future _onLoaded( + ExperienceLoaded event, + Emitter emit, + ) async { + emit(state.copyWith(status: ExperienceStatus.loading)); + try { + final arguments = GetExperienceArguments(staffId: staffId); + final results = await Future.wait([ + getIndustries(arguments), + getSkills(arguments), + ]); + + emit(state.copyWith( + status: ExperienceStatus.initial, + selectedIndustries: results[0], + selectedSkills: results[1], + )); + } catch (e) { + emit(state.copyWith( + status: ExperienceStatus.failure, + errorMessage: e.toString(), + )); + } + } + + void _onIndustryToggled( + ExperienceIndustryToggled event, + Emitter emit, + ) { + final industries = List.from(state.selectedIndustries); + if (industries.contains(event.industry)) { + industries.remove(event.industry); + } else { + industries.add(event.industry); + } + emit(state.copyWith(selectedIndustries: industries)); + } + + void _onSkillToggled( + ExperienceSkillToggled event, + Emitter emit, + ) { + final skills = List.from(state.selectedSkills); + if (skills.contains(event.skill)) { + skills.remove(event.skill); + } else { + skills.add(event.skill); + } + emit(state.copyWith(selectedSkills: skills)); + } + + void _onCustomSkillAdded( + ExperienceCustomSkillAdded event, + Emitter emit, + ) { + if (!state.selectedSkills.contains(event.skill)) { + final skills = List.from(state.selectedSkills)..add(event.skill); + emit(state.copyWith(selectedSkills: skills)); + } + } + + Future _onSubmitted( + ExperienceSubmitted event, + Emitter emit, + ) async { + emit(state.copyWith(status: ExperienceStatus.loading)); + try { + await saveExperience( + SaveExperienceArguments( + staffId: staffId, + industries: state.selectedIndustries, + skills: state.selectedSkills, + ), + ); + emit(state.copyWith(status: ExperienceStatus.success)); + } catch (e) { + emit(state.copyWith( + status: ExperienceStatus.failure, + errorMessage: e.toString(), + )); + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/navigation/experience_navigator.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/navigation/experience_navigator.dart new file mode 100644 index 00000000..bee0f20f --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/navigation/experience_navigator.dart @@ -0,0 +1,6 @@ +import 'package:flutter_modular/flutter_modular.dart'; + +extension ExperienceNavigator on IModularNavigator { + // Add navigation methods here if the page navigates deeper. + // Currently ExperiencePage is a leaf, but might need to navigate back or to success screen. +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/pages/experience_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/pages/experience_page.dart new file mode 100644 index 00000000..719e3122 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/pages/experience_page.dart @@ -0,0 +1,240 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import '../blocs/experience_bloc.dart'; +import '../widgets/experience_badge.dart'; +import '../widgets/experience_custom_input.dart'; +import '../widgets/experience_section_title.dart'; + +class ExperiencePage extends StatelessWidget { + const ExperiencePage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => Modular.get()..add(ExperienceLoaded()), + child: const _ExperienceView(), + ); + } +} + +class _ExperienceView extends StatelessWidget { + const _ExperienceView(); + + String _getIndustryLabel(dynamic node, String key) { + switch (key) { + case 'hospitality': return node.hospitality; + case 'food_service': return node.food_service; + case 'warehouse': return node.warehouse; + case 'events': return node.events; + case 'retail': return node.retail; + case 'healthcare': return node.healthcare; + case 'other': return node.other; + default: return key; + } + } + + String _getSkillLabel(dynamic node, String key) { + switch (key) { + case 'food_service': return node.food_service; + case 'bartending': return node.bartending; + case 'event_setup': return node.event_setup; + case 'hospitality': return node.hospitality; + case 'warehouse': return node.warehouse; + case 'customer_service': return node.customer_service; + case 'cleaning': return node.cleaning; + case 'security': return node.security; + case 'retail': return node.retail; + case 'cooking': return node.cooking; + case 'cashier': return node.cashier; + case 'server': return node.server; + case 'barista': return node.barista; + case 'host_hostess': return node.host_hostess; + case 'busser': return node.busser; + default: return key; + } + } + + @override + Widget build(BuildContext context) { + final i18n = t.staff.onboarding.experience; + + return Scaffold( + backgroundColor: UiColors.background, + appBar: AppBar( + backgroundColor: UiColors.bgPopup, + elevation: 0, + leading: IconButton( + icon: Icon(UiIcons.chevronLeft, color: UiColors.textSecondary), + onPressed: () => Modular.to.pop(), + ), + title: Text( + i18n.title, + style: UiTypography.title1m.copyWith(color: UiColors.textPrimary), + ), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1.0), + child: Container(color: UiColors.border, height: 1.0), + ), + ), + body: BlocConsumer( + listener: (context, state) { + if (state.status == ExperienceStatus.success) { + Modular.to.pop(); + } + }, + builder: (context, state) { + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ExperienceSectionTitle(title: i18n.industries_title), + Text( + i18n.industries_subtitle, + style: UiTypography.body2m.copyWith(color: UiColors.textSecondary), + ), + SizedBox(height: UiConstants.space3), + Wrap( + spacing: UiConstants.space2, + runSpacing: UiConstants.space2, + children: state.availableIndustries + .map( + (i) => ExperienceBadge( + label: _getIndustryLabel(i18n.industries, i), + isSelected: state.selectedIndustries.contains(i), + onTap: () => BlocProvider.of(context) + .add(ExperienceIndustryToggled(i)), + isIndustry: true, + ), + ) + .toList(), + ), + SizedBox(height: UiConstants.space6), + ExperienceSectionTitle(title: i18n.skills_title), + Text( + i18n.skills_subtitle, + style: UiTypography.body2m.copyWith(color: UiColors.textSecondary), + ), + SizedBox(height: UiConstants.space3), + Wrap( + spacing: UiConstants.space2, + runSpacing: UiConstants.space2, + children: state.availableSkills + .map( + (s) => ExperienceBadge( + label: _getSkillLabel(i18n.skills, s), + isSelected: state.selectedSkills.contains(s), + onTap: () => BlocProvider.of(context) + .add(ExperienceSkillToggled(s)), + ), + ) + .toList(), + ), + SizedBox(height: UiConstants.space4), + const ExperienceCustomInput(), + SizedBox(height: UiConstants.space4), + _buildCustomSkillsList(state, i18n), + SizedBox(height: UiConstants.space10), + ], + ), + ), + ), + _buildSaveButton(context, state, i18n), + ], + ); + }, + ), + ); + } + + Widget _buildCustomSkillsList(ExperienceState state, dynamic i18n) { + final customSkills = state.selectedSkills + .where((s) => !state.availableSkills.contains(s)) + .toList(); + if (customSkills.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + i18n.custom_skills_title, + style: UiTypography.body2m.copyWith(color: UiColors.textSecondary), + ), + SizedBox(height: UiConstants.space2), + Wrap( + spacing: UiConstants.space2, + runSpacing: UiConstants.space2, + children: customSkills.map((skill) { + return Container( + padding: EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: UiConstants.space2, + ), + decoration: BoxDecoration( + color: UiColors.accent, + borderRadius: UiConstants.radiusFull, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + skill, + style: UiTypography.body2m.copyWith(color: UiColors.textPrimary), + ), + ], + ), + ); + }).toList(), + ), + ], + ); + } + + Widget _buildSaveButton(BuildContext context, ExperienceState state, dynamic i18n) { + return Container( + padding: EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.bgPopup, + border: Border(top: BorderSide(color: UiColors.border)), + ), + child: SafeArea( + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: state.status == ExperienceStatus.loading + ? null + : () => BlocProvider.of(context).add(ExperienceSubmitted()), + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.primary, + foregroundColor: UiColors.primaryForeground, + padding: EdgeInsets.symmetric(vertical: UiConstants.space4), + shape: RoundedRectangleBorder( + borderRadius: UiConstants.radiusFull, + ), + elevation: 0, + ), + child: state.status == ExperienceStatus.loading + ? SizedBox( + height: 20.0, + width: 20.0, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(UiColors.primaryForeground), + ), + ) + : Text( + i18n.save_button, + style: UiTypography.title2b, + ), + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/widgets/experience_badge.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/widgets/experience_badge.dart new file mode 100644 index 00000000..2daf994c --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/widgets/experience_badge.dart @@ -0,0 +1,43 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +class ExperienceBadge extends StatelessWidget { + final String label; + final bool isSelected; + final VoidCallback onTap; + final bool isIndustry; + + const ExperienceBadge({ + super.key, + required this.label, + required this.isSelected, + required this.onTap, + this.isIndustry = false, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: UiConstants.space2, + ), + decoration: BoxDecoration( + color: isSelected ? UiColors.primary : Colors.transparent, + borderRadius: UiConstants.radiusFull, + border: Border.all( + color: isSelected ? UiColors.primary : UiColors.border, + ), + ), + child: Text( + label, + style: UiTypography.body2m.copyWith( + color: isSelected ? UiColors.primaryForeground : UiColors.textSecondary, + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/widgets/experience_custom_input.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/widgets/experience_custom_input.dart new file mode 100644 index 00000000..7d00c299 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/widgets/experience_custom_input.dart @@ -0,0 +1,83 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../blocs/experience_bloc.dart'; + +class ExperienceCustomInput extends StatefulWidget { + const ExperienceCustomInput({super.key}); + + @override + State createState() => _ExperienceCustomInputState(); +} + +class _ExperienceCustomInputState extends State { + final TextEditingController _controller = TextEditingController(); + + void _addSkill() { + final skill = _controller.text.trim(); + if (skill.isNotEmpty) { + BlocProvider.of(context).add(ExperienceCustomSkillAdded(skill)); + _controller.clear(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + onSubmitted: (_) => _addSkill(), + style: UiTypography.body1r.copyWith(color: UiColors.textPrimary), + decoration: InputDecoration( + hintText: t.staff.onboarding.experience.custom_skill_hint, + hintStyle: UiTypography.body1r.copyWith(color: UiColors.textPlaceholder), + contentPadding: EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: UiConstants.space3, + ), + border: OutlineInputBorder( + borderRadius: UiConstants.radiusMd, + borderSide: BorderSide(color: UiColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: UiConstants.radiusMd, + borderSide: BorderSide(color: UiColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: UiConstants.radiusMd, + borderSide: BorderSide(color: UiColors.primary), + ), + ), + fillColor: UiColors.bgPopup, + filled: true, + ), + ), + ), + SizedBox(width: UiConstants.space2), + InkWell( + onTap: _addSkill, + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: UiColors.primary, + borderRadius: BorderRadius.circular(UiConstants.radiusMd), + ), + child: Center( + child: Icon(UiIcons.add, color: UiColors.primaryForeground), + ), + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/widgets/experience_section_title.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/widgets/experience_section_title.dart new file mode 100644 index 00000000..7a588933 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/widgets/experience_section_title.dart @@ -0,0 +1,20 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +class ExperienceSectionTitle extends StatelessWidget { + final String title; + const ExperienceSectionTitle({super.key, required this.title}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(bottom: UiConstants.space2), + child: Text( + title, + style: UiTypography.title2m.copyWith( + color: UiColors.textPrimary, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/staff_profile_experience.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/staff_profile_experience.dart new file mode 100644 index 00000000..99a602d7 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/staff_profile_experience.dart @@ -0,0 +1,58 @@ +library staff_profile_experience; + +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; + +import 'src/data/repositories/experience_repository_impl.dart'; +import 'src/domain/repositories/experience_repository_interface.dart'; +import 'src/domain/usecases/get_staff_industries_usecase.dart'; +import 'src/domain/usecases/get_staff_skills_usecase.dart'; +import 'src/domain/usecases/save_experience_usecase.dart'; +import 'src/presentation/blocs/experience_bloc.dart'; +import 'src/presentation/pages/experience_page.dart'; + +export 'src/presentation/pages/experience_page.dart'; + +class StaffProfileExperienceModule extends Module { + @override + List get imports => [DataConnectModule()]; + + @override + void binds(Injector i) { + // Repository + i.addLazySingleton( + () => ExperienceRepositoryImpl(i.get()), + ); + + // UseCases + i.addLazySingleton( + () => GetStaffIndustriesUseCase(i.get()), + ); + i.addLazySingleton( + () => GetStaffSkillsUseCase(i.get()), + ); + i.addLazySingleton( + () => SaveExperienceUseCase(i.get()), + ); + + // BLoC + i.addLazySingleton( + () => ExperienceBloc( + getIndustries: i.get(), + getSkills: i.get(), + saveExperience: i.get(), + // TODO: Get actual logged in staff ID + staffId: 'current-staff-id', + ), + ); + } + + @override + void routes(RouteManager r) { + r.child( + '/', + child: (_) => const ExperiencePage(), + transition: TransitionType.rightToLeft, + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/pubspec.yaml new file mode 100644 index 00000000..5d63ba6c --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/pubspec.yaml @@ -0,0 +1,35 @@ +name: staff_profile_experience +description: Staff Profile Experience feature. +version: 0.0.1 +publish_to: none +resolution: workspace + +environment: + sdk: '>=3.10.0 <4.0.0' + flutter: ">=3.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_bloc: ^8.1.0 + flutter_modular: ^6.3.0 + equatable: ^2.0.5 + + # Architecture Packages + krow_domain: + path: ../../../../../../domain + krow_core: + path: ../../../../../../core + krow_data_connect: + path: ../../../../../../data_connect + design_system: + path: ../../../../../../design_system + core_localization: + path: ../../../../../../core_localization + +dev_dependencies: + flutter_test: + sdk: flutter + bloc_test: ^9.1.0 + mocktail: ^1.0.0 + flutter_lints: ^6.0.0 diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index 646c5e3d..0d3eba1a 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -14,6 +14,7 @@ workspace: - packages/features/staff/staff_main - packages/features/staff/profile - packages/features/staff/profile_sections/onboarding/emergency_contact + - packages/features/staff/profile_sections/onboarding/experience - packages/features/staff/profile_sections/onboarding/profile_info - packages/features/client/authentication - packages/features/client/home