From e1d30c124b73feb8c5462f6afb8e94bb18fecdb9 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 13:44:48 -0400 Subject: [PATCH] feat: migrate experience management to V2 API; add support for industries and skills --- .../endpoints/staff_endpoints.dart | 4 + .../lib/src/l10n/en.i18n.json | 4 + .../lib/src/l10n/es.i18n.json | 4 + .../packages/domain/lib/krow_domain.dart | 2 + .../src/entities/enums/staff_industry.dart | 48 ++++ .../lib/src/entities/enums/staff_skill.dart | 69 +++++ .../experience_repository_impl.dart | 52 +++- .../arguments/save_experience_arguments.dart | 18 +- .../experience_repository_interface.dart | 14 +- .../get_staff_industries_usecase.dart | 12 +- .../usecases/get_staff_skills_usecase.dart | 13 +- .../usecases/save_experience_usecase.dart | 9 +- .../presentation/blocs/experience_bloc.dart | 168 +++-------- .../presentation/blocs/experience_event.dart | 53 ++++ .../presentation/blocs/experience_state.dart | 77 +++++ .../presentation/pages/experience_page.dart | 270 ++++++++---------- 16 files changed, 503 insertions(+), 314 deletions(-) create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/staff_industry.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/staff_skill.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_event.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_state.dart diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart index 878c3708..c98c780e 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart @@ -75,6 +75,10 @@ abstract final class StaffEndpoints { /// Skills. static const ApiEndpoint skills = ApiEndpoint('/staff/profile/skills'); + /// Save/update experience (industries + skills). + static const ApiEndpoint experience = + ApiEndpoint('/staff/profile/experience'); + /// Documents. static const ApiEndpoint documents = ApiEndpoint('/staff/profile/documents'); 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 1b6e1532..a7a83a54 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 @@ -823,6 +823,8 @@ "custom_skills_title": "Custom Skills:", "custom_skill_hint": "Add custom skill...", "save_button": "Save & Continue", + "save_success": "Experience saved successfully", + "save_error": "An error occurred", "industries": { "hospitality": "Hospitality", "food_service": "Food Service", @@ -830,6 +832,8 @@ "events": "Events", "retail": "Retail", "healthcare": "Healthcare", + "catering": "Catering", + "cafe": "Cafe", "other": "Other" }, "skills": { diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index dc509e86..22d14d50 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -818,6 +818,8 @@ "custom_skills_title": "Habilidades personalizadas:", "custom_skill_hint": "A\u00f1adir habilidad...", "save_button": "Guardar y continuar", + "save_success": "Experiencia guardada exitosamente", + "save_error": "Ocurrió un error", "industries": { "hospitality": "Hoteler\u00eda", "food_service": "Servicio de alimentos", @@ -825,6 +827,8 @@ "events": "Eventos", "retail": "Venta al por menor", "healthcare": "Cuidado de la salud", + "catering": "Catering", + "cafe": "Cafetería", "other": "Otro" }, "skills": { diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index d848a73b..37569eec 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -19,6 +19,8 @@ export 'src/entities/enums/onboarding_status.dart'; export 'src/entities/enums/order_type.dart'; export 'src/entities/enums/payment_status.dart'; export 'src/entities/enums/shift_status.dart'; +export 'src/entities/enums/staff_industry.dart'; +export 'src/entities/enums/staff_skill.dart'; export 'src/entities/enums/staff_status.dart'; export 'src/entities/enums/user_role.dart'; diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/staff_industry.dart b/apps/mobile/packages/domain/lib/src/entities/enums/staff_industry.dart new file mode 100644 index 00000000..74f964c1 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/staff_industry.dart @@ -0,0 +1,48 @@ +/// Industry options for staff experience profiles. +/// +/// Values match the V2 API format (UPPER_SNAKE_CASE). +enum StaffIndustry { + /// Hospitality industry. + hospitality('HOSPITALITY'), + + /// Food service industry. + foodService('FOOD_SERVICE'), + + /// Warehouse / logistics industry. + warehouse('WAREHOUSE'), + + /// Events industry. + events('EVENTS'), + + /// Retail industry. + retail('RETAIL'), + + /// Healthcare industry. + healthcare('HEALTHCARE'), + + /// Catering industry. + catering('CATERING'), + + /// Cafe / coffee shop industry. + cafe('CAFE'), + + /// Other / unspecified industry. + other('OTHER'); + + const StaffIndustry(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static StaffIndustry? fromJson(String? value) { + if (value == null) return null; + for (final StaffIndustry industry in StaffIndustry.values) { + if (industry.value == value) return industry; + } + return null; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/staff_skill.dart b/apps/mobile/packages/domain/lib/src/entities/enums/staff_skill.dart new file mode 100644 index 00000000..9f36e276 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/staff_skill.dart @@ -0,0 +1,69 @@ +/// Skill options for staff experience profiles. +/// +/// Values match the V2 API format (UPPER_SNAKE_CASE). +enum StaffSkill { + /// Food service skill. + foodService('FOOD_SERVICE'), + + /// Bartending skill. + bartending('BARTENDING'), + + /// Event setup skill. + eventSetup('EVENT_SETUP'), + + /// Hospitality skill. + hospitality('HOSPITALITY'), + + /// Warehouse skill. + warehouse('WAREHOUSE'), + + /// Customer service skill. + customerService('CUSTOMER_SERVICE'), + + /// Cleaning skill. + cleaning('CLEANING'), + + /// Security skill. + security('SECURITY'), + + /// Retail skill. + retail('RETAIL'), + + /// Driving skill. + driving('DRIVING'), + + /// Cooking skill. + cooking('COOKING'), + + /// Cashier skill. + cashier('CASHIER'), + + /// Server skill. + server('SERVER'), + + /// Barista skill. + barista('BARISTA'), + + /// Host / hostess skill. + hostHostess('HOST_HOSTESS'), + + /// Busser skill. + busser('BUSSER'); + + const StaffSkill(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static StaffSkill? fromJson(String? value) { + if (value == null) return null; + for (final StaffSkill skill in StaffSkill.values) { + if (skill.value == value) return skill; + } + return null; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} 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 index 0684e2a5..a532ad73 100644 --- 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 @@ -4,8 +4,6 @@ import 'package:krow_domain/krow_domain.dart'; import 'package:staff_profile_experience/src/domain/repositories/experience_repository_interface.dart'; /// Implementation of [ExperienceRepositoryInterface] using the V2 API. -/// -/// Replaces the previous Firebase Data Connect implementation. class ExperienceRepositoryImpl implements ExperienceRepositoryInterface { /// Creates an [ExperienceRepositoryImpl]. ExperienceRepositoryImpl({required BaseApiService apiService}) @@ -14,30 +12,54 @@ class ExperienceRepositoryImpl implements ExperienceRepositoryInterface { final BaseApiService _api; @override - Future> getIndustries() async { - final ApiResponse response = - await _api.get(StaffEndpoints.industries); - final List items = response.data['industries'] as List? ?? []; - return items.map((dynamic e) => e.toString()).toList(); + Future> getIndustries() async { + final ApiResponse response = await _api.get(StaffEndpoints.industries); + final List items = + response.data['items'] as List? ?? []; + return items + .map((dynamic e) => StaffIndustry.fromJson(e.toString())) + .whereType() + .toList(); } @override - Future> getSkills() async { + Future<({List skills, List customSkills})> + getSkills() async { final ApiResponse response = await _api.get(StaffEndpoints.skills); - final List items = response.data['skills'] as List? ?? []; - return items.map((dynamic e) => e.toString()).toList(); + final List items = + response.data['items'] as List? ?? []; + + final List skills = []; + final List customSkills = []; + + for (final dynamic item in items) { + final String value = item.toString(); + final StaffSkill? parsed = StaffSkill.fromJson(value); + if (parsed != null) { + skills.add(parsed); + } else { + customSkills.add(value); + } + } + + return (skills: skills, customSkills: customSkills); } @override Future saveExperience( - List industries, - List skills, + List industries, + List skills, + List customSkills, ) async { await _api.put( - StaffEndpoints.personalInfo, + StaffEndpoints.experience, data: { - 'industries': industries, - 'skills': skills, + 'industries': + industries.map((StaffIndustry i) => i.value).toList(), + 'skills': [ + ...skills.map((StaffSkill s) => s.value), + ...customSkills, + ], }, ); } 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 index aa3385b9..1adc1703 100644 --- 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 @@ -1,14 +1,24 @@ import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart' show StaffIndustry, StaffSkill; +/// Arguments for the [SaveExperienceUseCase]. class SaveExperienceArguments extends UseCaseArgument { - final List industries; - final List skills; - + /// Creates a [SaveExperienceArguments]. const SaveExperienceArguments({ required this.industries, required this.skills, + this.customSkills = const [], }); + /// Selected industries. + final List industries; + + /// Selected predefined skills. + final List skills; + + /// Custom skills not in the [StaffSkill] enum. + final List customSkills; + @override - List get props => [industries, skills]; + List get props => [industries, skills, customSkills]; } 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 index 8ddff2ef..3900ec1e 100644 --- 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 @@ -1,14 +1,20 @@ +import 'package:krow_domain/krow_domain.dart' show StaffIndustry, StaffSkill; + /// Interface for accessing staff experience data. abstract class ExperienceRepositoryInterface { /// Fetches the list of industries associated with the staff member. - Future> getIndustries(); + Future> getIndustries(); /// Fetches the list of skills associated with the staff member. - Future> getSkills(); + /// + /// Returns recognised [StaffSkill] values. Unrecognised API values are + /// returned in [customSkills]. + Future<({List skills, List customSkills})> getSkills(); /// Saves the staff member's experience (industries and skills). Future saveExperience( - List industries, - List skills, + List industries, + List skills, + List customSkills, ); } 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 index 6094247c..e28192d9 100644 --- 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 @@ -1,14 +1,18 @@ import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart' show StaffIndustry; + import '../repositories/experience_repository_interface.dart'; /// Use case for fetching staff industries. -class GetStaffIndustriesUseCase implements NoInputUseCase> { - final ExperienceRepositoryInterface _repository; - +class GetStaffIndustriesUseCase + implements NoInputUseCase> { + /// Creates a [GetStaffIndustriesUseCase]. GetStaffIndustriesUseCase(this._repository); + final ExperienceRepositoryInterface _repository; + @override - Future> call() { + Future> call() { return _repository.getIndustries(); } } 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 index d21234d7..9ca3b97d 100644 --- 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 @@ -1,14 +1,19 @@ import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart' show StaffSkill; + import '../repositories/experience_repository_interface.dart'; /// Use case for fetching staff skills. -class GetStaffSkillsUseCase implements NoInputUseCase> { - final ExperienceRepositoryInterface _repository; - +class GetStaffSkillsUseCase + implements + NoInputUseCase<({List skills, List customSkills})> { + /// Creates a [GetStaffSkillsUseCase]. GetStaffSkillsUseCase(this._repository); + final ExperienceRepositoryInterface _repository; + @override - Future> call() { + Future<({List skills, List customSkills})> call() { return _repository.getSkills(); } } 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 index 117ec4d2..ad0c0cbf 100644 --- 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 @@ -1,21 +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); + /// The experience repository. + final ExperienceRepositoryInterface repository; + @override Future call(SaveExperienceArguments params) { return repository.saveExperience( params.industries, params.skills, + params.customSkills, ); } } 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 index 562ffb6a..28b9838a 100644 --- 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 @@ -1,144 +1,26 @@ -import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart' show StaffIndustry, StaffSkill; + 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'; +import 'experience_event.dart'; +import 'experience_state.dart'; -// Events -abstract class ExperienceEvent extends Equatable { - const ExperienceEvent(); +export 'experience_event.dart'; +export 'experience_state.dart'; - @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, - ]; -} - -/// Available industry option values. -const List _kAvailableIndustries = [ - 'hospitality', - 'food_service', - 'warehouse', - 'events', - 'retail', - 'healthcare', - 'other', -]; - -/// Available skill option values. -const List _kAvailableSkills = [ - 'food_service', - 'bartending', - 'event_setup', - 'hospitality', - 'warehouse', - 'customer_service', - 'cleaning', - 'security', - 'retail', - 'driving', - 'cooking', - 'cashier', - 'server', - 'barista', - 'host_hostess', - 'busser', -]; - -// BLoC +/// BLoC that manages the staff experience (industries & skills) selection. class ExperienceBloc extends Bloc with BlocErrorHandler { - final GetStaffIndustriesUseCase getIndustries; - final GetStaffSkillsUseCase getSkills; - final SaveExperienceUseCase saveExperience; - + /// Creates an [ExperienceBloc]. ExperienceBloc({ required this.getIndustries, required this.getSkills, required this.saveExperience, - }) : super( - const ExperienceState( - availableIndustries: _kAvailableIndustries, - availableSkills: _kAvailableSkills, - ), - ) { + }) : super(const ExperienceState()) { on(_onLoaded); on(_onIndustryToggled); on(_onSkillToggled); @@ -148,6 +30,15 @@ class ExperienceBloc extends Bloc add(ExperienceLoaded()); } + /// Use case for fetching saved industries. + final GetStaffIndustriesUseCase getIndustries; + + /// Use case for fetching saved skills. + final GetStaffSkillsUseCase getSkills; + + /// Use case for saving experience selections. + final SaveExperienceUseCase saveExperience; + Future _onLoaded( ExperienceLoaded event, Emitter emit, @@ -156,13 +47,16 @@ class ExperienceBloc extends Bloc await handleError( emit: emit.call, action: () async { - final results = await Future.wait([getIndustries(), getSkills()]); + final List industries = await getIndustries(); + final ({List skills, List customSkills}) skillsResult = + await getSkills(); emit( state.copyWith( status: ExperienceStatus.initial, - selectedIndustries: results[0], - selectedSkills: results[1], + selectedIndustries: industries, + selectedSkills: skillsResult.skills, + customSkills: skillsResult.customSkills, ), ); }, @@ -177,7 +71,8 @@ class ExperienceBloc extends Bloc ExperienceIndustryToggled event, Emitter emit, ) { - final industries = List.from(state.selectedIndustries); + final List industries = + List.from(state.selectedIndustries); if (industries.contains(event.industry)) { industries.remove(event.industry); } else { @@ -190,7 +85,8 @@ class ExperienceBloc extends Bloc ExperienceSkillToggled event, Emitter emit, ) { - final skills = List.from(state.selectedSkills); + final List skills = + List.from(state.selectedSkills); if (skills.contains(event.skill)) { skills.remove(event.skill); } else { @@ -203,9 +99,10 @@ class ExperienceBloc extends Bloc ExperienceCustomSkillAdded event, Emitter emit, ) { - if (!state.selectedSkills.contains(event.skill)) { - final skills = List.from(state.selectedSkills)..add(event.skill); - emit(state.copyWith(selectedSkills: skills)); + if (!state.customSkills.contains(event.skill)) { + final List custom = List.from(state.customSkills) + ..add(event.skill); + emit(state.copyWith(customSkills: custom)); } } @@ -221,6 +118,7 @@ class ExperienceBloc extends Bloc SaveExperienceArguments( industries: state.selectedIndustries, skills: state.selectedSkills, + customSkills: state.customSkills, ), ); emit(state.copyWith(status: ExperienceStatus.success)); diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_event.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_event.dart new file mode 100644 index 00000000..aa54f2b7 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_event.dart @@ -0,0 +1,53 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart' show StaffIndustry, StaffSkill; + +/// Base event for the experience BLoC. +abstract class ExperienceEvent extends Equatable { + /// Creates an [ExperienceEvent]. + const ExperienceEvent(); + + @override + List get props => []; +} + +/// Triggers initial load of saved industries and skills. +class ExperienceLoaded extends ExperienceEvent {} + +/// Toggles an industry selection on or off. +class ExperienceIndustryToggled extends ExperienceEvent { + /// Creates an [ExperienceIndustryToggled] event. + const ExperienceIndustryToggled(this.industry); + + /// The industry to toggle. + final StaffIndustry industry; + + @override + List get props => [industry]; +} + +/// Toggles a skill selection on or off. +class ExperienceSkillToggled extends ExperienceEvent { + /// Creates an [ExperienceSkillToggled] event. + const ExperienceSkillToggled(this.skill); + + /// The skill to toggle. + final StaffSkill skill; + + @override + List get props => [skill]; +} + +/// Adds a custom skill not in the predefined [StaffSkill] enum. +class ExperienceCustomSkillAdded extends ExperienceEvent { + /// Creates an [ExperienceCustomSkillAdded] event. + const ExperienceCustomSkillAdded(this.skill); + + /// The custom skill value to add. + final String skill; + + @override + List get props => [skill]; +} + +/// Submits the selected industries and skills to the backend. +class ExperienceSubmitted extends ExperienceEvent {} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_state.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_state.dart new file mode 100644 index 00000000..6bd50bae --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/experience/lib/src/presentation/blocs/experience_state.dart @@ -0,0 +1,77 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart' show StaffIndustry, StaffSkill; + +/// Status of the experience feature. +enum ExperienceStatus { + /// Initial state before any action. + initial, + + /// Loading data from the backend. + loading, + + /// Operation completed successfully. + success, + + /// An error occurred. + failure, +} + +/// State for the experience BLoC. +class ExperienceState extends Equatable { + /// Creates an [ExperienceState]. + const ExperienceState({ + this.status = ExperienceStatus.initial, + this.selectedIndustries = const [], + this.selectedSkills = const [], + this.customSkills = const [], + this.errorMessage, + }); + + /// Current operation status. + final ExperienceStatus status; + + /// Industries the staff member has selected. + final List selectedIndustries; + + /// Skills the staff member has selected. + final List selectedSkills; + + /// Custom skills not in [StaffSkill] that the user added. + final List customSkills; + + /// Error message key when [status] is [ExperienceStatus.failure]. + final String? errorMessage; + + /// All selected skill values as API strings (enum + custom combined). + List get allSkillValues => + [ + ...selectedSkills.map((StaffSkill s) => s.value), + ...customSkills, + ]; + + /// Creates a copy with the given fields replaced. + ExperienceState copyWith({ + ExperienceStatus? status, + List? selectedIndustries, + List? selectedSkills, + List? customSkills, + String? errorMessage, + }) { + return ExperienceState( + status: status ?? this.status, + selectedIndustries: selectedIndustries ?? this.selectedIndustries, + selectedSkills: selectedSkills ?? this.selectedSkills, + customSkills: customSkills ?? this.customSkills, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [ + status, + selectedIndustries, + selectedSkills, + customSkills, + errorMessage, + ]; +} 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 index 2bf00f85..6955234e 100644 --- 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 @@ -4,90 +4,33 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart' show StaffIndustry, StaffSkill; import '../blocs/experience_bloc.dart'; import '../widgets/experience_section_title.dart'; +/// Page for selecting staff industries and skills. class ExperiencePage extends StatelessWidget { + /// Creates an [ExperiencePage]. const ExperiencePage({super.key}); - String _getIndustryLabel(dynamic node, String industry) { - switch (industry) { - 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 industry; - } - } - - String _getSkillLabel(dynamic node, String skill) { - switch (skill) { - 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 'driving': - return node.driving; - 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 skill; - } - } - @override Widget build(BuildContext context) { - final i18n = Translations.of(context).staff.onboarding.experience; + final dynamic i18n = Translations.of(context).staff.onboarding.experience; return Scaffold( appBar: UiAppBar( - title: i18n.title, + title: i18n.title as String, onLeadingPressed: () => Modular.to.toProfile(), ), body: BlocProvider( - create: (context) => Modular.get(), + create: (BuildContext context) => Modular.get(), child: BlocConsumer( - listener: (context, state) { + listener: (BuildContext context, ExperienceState state) { if (state.status == ExperienceStatus.success) { UiSnackbar.show( context, - message: 'Experience saved successfully', + message: i18n.save_success as String, type: UiSnackbarType.success, margin: const EdgeInsets.only( bottom: 120, @@ -100,7 +43,7 @@ class ExperiencePage extends StatelessWidget { context, message: state.errorMessage != null ? translateErrorKey(state.errorMessage!) - : 'An error occurred', + : i18n.save_error as String, type: UiSnackbarType.error, margin: const EdgeInsets.only( bottom: 120, @@ -110,65 +53,28 @@ class ExperiencePage extends StatelessWidget { ); } }, - builder: (context, state) { + builder: (BuildContext context, ExperienceState state) { return Column( - children: [ + children: [ Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(UiConstants.space5), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ ExperienceSectionTitle( - title: i18n.industries_title, - subtitle: i18n.industries_subtitle, + title: i18n.industries_title as String, + subtitle: i18n.industries_subtitle as String, ), const SizedBox(height: UiConstants.space3), - Wrap( - spacing: UiConstants.space2, - runSpacing: UiConstants.space2, - children: state.availableIndustries - .map( - (i) => UiChip( - label: _getIndustryLabel(i18n.industries, i), - isSelected: state.selectedIndustries.contains( - i, - ), - onTap: () => BlocProvider.of( - context, - ).add(ExperienceIndustryToggled(i)), - variant: state.selectedIndustries.contains(i) - ? UiChipVariant.primary - : UiChipVariant.secondary, - ), - ) - .toList(), - ), + _buildIndustryChips(context, state, i18n), const SizedBox(height: UiConstants.space10), ExperienceSectionTitle( - title: i18n.skills_title, - subtitle: i18n.skills_subtitle, + title: i18n.skills_title as String, + subtitle: i18n.skills_subtitle as String, ), const SizedBox(height: UiConstants.space3), - Wrap( - spacing: UiConstants.space2, - runSpacing: UiConstants.space2, - children: state.availableSkills - .map( - (s) => UiChip( - label: _getSkillLabel(i18n.skills, s), - isSelected: state.selectedSkills.contains(s), - onTap: () => BlocProvider.of( - context, - ).add(ExperienceSkillToggled(s)), - variant: - state.selectedSkills.contains(s) - ? UiChipVariant.primary - : UiChipVariant.secondary, - ), - ) - .toList(), - ), + _buildSkillChips(context, state, i18n), ], ), ), @@ -182,28 +88,45 @@ class ExperiencePage extends StatelessWidget { ); } - Widget _buildCustomSkillsList(ExperienceState state, dynamic i18n) { - final customSkills = state.selectedSkills - .where((s) => !state.availableSkills.contains(s)) - .toList(); - if (customSkills.isEmpty) return const SizedBox.shrink(); + Widget _buildIndustryChips( + BuildContext context, + ExperienceState state, + dynamic i18n, + ) { + return Wrap( + spacing: UiConstants.space2, + runSpacing: UiConstants.space2, + children: StaffIndustry.values.map((StaffIndustry industry) { + final bool isSelected = state.selectedIndustries.contains(industry); + return UiChip( + label: _getIndustryLabel(i18n.industries, industry), + isSelected: isSelected, + onTap: () => BlocProvider.of(context) + .add(ExperienceIndustryToggled(industry)), + variant: isSelected ? UiChipVariant.primary : UiChipVariant.secondary, + ); + }).toList(), + ); + } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - i18n.custom_skills_title, - style: UiTypography.body2m.textSecondary, - ), - const SizedBox(height: UiConstants.space2), - Wrap( - spacing: UiConstants.space2, - runSpacing: UiConstants.space2, - children: customSkills.map((skill) { - return UiChip(label: skill, variant: UiChipVariant.accent); - }).toList(), - ), - ], + Widget _buildSkillChips( + BuildContext context, + ExperienceState state, + dynamic i18n, + ) { + return Wrap( + spacing: UiConstants.space2, + runSpacing: UiConstants.space2, + children: StaffSkill.values.map((StaffSkill skill) { + final bool isSelected = state.selectedSkills.contains(skill); + return UiChip( + label: _getSkillLabel(i18n.skills, skill), + isSelected: isSelected, + onTap: () => BlocProvider.of(context) + .add(ExperienceSkillToggled(skill)), + variant: isSelected ? UiChipVariant.primary : UiChipVariant.secondary, + ); + }).toList(), ); } @@ -212,6 +135,7 @@ class ExperiencePage extends StatelessWidget { ExperienceState state, dynamic i18n, ) { + final bool isLoading = state.status == ExperienceStatus.loading; return Container( padding: const EdgeInsets.all(UiConstants.space4), decoration: const BoxDecoration( @@ -220,24 +144,20 @@ class ExperiencePage extends StatelessWidget { ), child: SafeArea( child: UiButton.primary( - onPressed: state.status == ExperienceStatus.loading + onPressed: isLoading ? null - : () => BlocProvider.of( - context, - ).add(ExperienceSubmitted()), + : () => BlocProvider.of(context) + .add(ExperienceSubmitted()), fullWidth: true, - text: state.status == ExperienceStatus.loading - ? null - : i18n.save_button, - child: state.status == ExperienceStatus.loading + text: isLoading ? null : i18n.save_button as String, + child: isLoading ? const SizedBox( height: UiConstants.iconMd, width: UiConstants.iconMd, child: CircularProgressIndicator( strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - UiColors.white, - ), // UiColors.primaryForeground is white mostly + valueColor: + AlwaysStoppedAnimation(UiColors.white), ), ) : null, @@ -245,4 +165,66 @@ class ExperiencePage extends StatelessWidget { ), ); } + + /// Maps a [StaffIndustry] to its localized label. + String _getIndustryLabel(dynamic node, StaffIndustry industry) { + switch (industry) { + case StaffIndustry.hospitality: + return node.hospitality as String; + case StaffIndustry.foodService: + return node.food_service as String; + case StaffIndustry.warehouse: + return node.warehouse as String; + case StaffIndustry.events: + return node.events as String; + case StaffIndustry.retail: + return node.retail as String; + case StaffIndustry.healthcare: + return node.healthcare as String; + case StaffIndustry.catering: + return node.catering as String; + case StaffIndustry.cafe: + return node.cafe as String; + case StaffIndustry.other: + return node.other as String; + } + } + + /// Maps a [StaffSkill] to its localized label. + String _getSkillLabel(dynamic node, StaffSkill skill) { + switch (skill) { + case StaffSkill.foodService: + return node.food_service as String; + case StaffSkill.bartending: + return node.bartending as String; + case StaffSkill.eventSetup: + return node.event_setup as String; + case StaffSkill.hospitality: + return node.hospitality as String; + case StaffSkill.warehouse: + return node.warehouse as String; + case StaffSkill.customerService: + return node.customer_service as String; + case StaffSkill.cleaning: + return node.cleaning as String; + case StaffSkill.security: + return node.security as String; + case StaffSkill.retail: + return node.retail as String; + case StaffSkill.driving: + return node.driving as String; + case StaffSkill.cooking: + return node.cooking as String; + case StaffSkill.cashier: + return node.cashier as String; + case StaffSkill.server: + return node.server as String; + case StaffSkill.barista: + return node.barista as String; + case StaffSkill.hostHostess: + return node.host_hostess as String; + case StaffSkill.busser: + return node.busser as String; + } + } }