feat: migrate experience management to V2 API; add support for industries and skills

This commit is contained in:
Achintha Isuru
2026-03-17 13:44:48 -04:00
parent e83b8fff1c
commit e1d30c124b
16 changed files with 503 additions and 314 deletions

View File

@@ -75,6 +75,10 @@ abstract final class StaffEndpoints {
/// Skills. /// Skills.
static const ApiEndpoint skills = ApiEndpoint('/staff/profile/skills'); static const ApiEndpoint skills = ApiEndpoint('/staff/profile/skills');
/// Save/update experience (industries + skills).
static const ApiEndpoint experience =
ApiEndpoint('/staff/profile/experience');
/// Documents. /// Documents.
static const ApiEndpoint documents = static const ApiEndpoint documents =
ApiEndpoint('/staff/profile/documents'); ApiEndpoint('/staff/profile/documents');

View File

@@ -823,6 +823,8 @@
"custom_skills_title": "Custom Skills:", "custom_skills_title": "Custom Skills:",
"custom_skill_hint": "Add custom skill...", "custom_skill_hint": "Add custom skill...",
"save_button": "Save & Continue", "save_button": "Save & Continue",
"save_success": "Experience saved successfully",
"save_error": "An error occurred",
"industries": { "industries": {
"hospitality": "Hospitality", "hospitality": "Hospitality",
"food_service": "Food Service", "food_service": "Food Service",
@@ -830,6 +832,8 @@
"events": "Events", "events": "Events",
"retail": "Retail", "retail": "Retail",
"healthcare": "Healthcare", "healthcare": "Healthcare",
"catering": "Catering",
"cafe": "Cafe",
"other": "Other" "other": "Other"
}, },
"skills": { "skills": {

View File

@@ -818,6 +818,8 @@
"custom_skills_title": "Habilidades personalizadas:", "custom_skills_title": "Habilidades personalizadas:",
"custom_skill_hint": "A\u00f1adir habilidad...", "custom_skill_hint": "A\u00f1adir habilidad...",
"save_button": "Guardar y continuar", "save_button": "Guardar y continuar",
"save_success": "Experiencia guardada exitosamente",
"save_error": "Ocurrió un error",
"industries": { "industries": {
"hospitality": "Hoteler\u00eda", "hospitality": "Hoteler\u00eda",
"food_service": "Servicio de alimentos", "food_service": "Servicio de alimentos",
@@ -825,6 +827,8 @@
"events": "Eventos", "events": "Eventos",
"retail": "Venta al por menor", "retail": "Venta al por menor",
"healthcare": "Cuidado de la salud", "healthcare": "Cuidado de la salud",
"catering": "Catering",
"cafe": "Cafetería",
"other": "Otro" "other": "Otro"
}, },
"skills": { "skills": {

View File

@@ -19,6 +19,8 @@ export 'src/entities/enums/onboarding_status.dart';
export 'src/entities/enums/order_type.dart'; export 'src/entities/enums/order_type.dart';
export 'src/entities/enums/payment_status.dart'; export 'src/entities/enums/payment_status.dart';
export 'src/entities/enums/shift_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/staff_status.dart';
export 'src/entities/enums/user_role.dart'; export 'src/entities/enums/user_role.dart';

View File

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

View File

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

View File

@@ -4,8 +4,6 @@ import 'package:krow_domain/krow_domain.dart';
import 'package:staff_profile_experience/src/domain/repositories/experience_repository_interface.dart'; import 'package:staff_profile_experience/src/domain/repositories/experience_repository_interface.dart';
/// Implementation of [ExperienceRepositoryInterface] using the V2 API. /// Implementation of [ExperienceRepositoryInterface] using the V2 API.
///
/// Replaces the previous Firebase Data Connect implementation.
class ExperienceRepositoryImpl implements ExperienceRepositoryInterface { class ExperienceRepositoryImpl implements ExperienceRepositoryInterface {
/// Creates an [ExperienceRepositoryImpl]. /// Creates an [ExperienceRepositoryImpl].
ExperienceRepositoryImpl({required BaseApiService apiService}) ExperienceRepositoryImpl({required BaseApiService apiService})
@@ -14,30 +12,54 @@ class ExperienceRepositoryImpl implements ExperienceRepositoryInterface {
final BaseApiService _api; final BaseApiService _api;
@override @override
Future<List<String>> getIndustries() async { Future<List<StaffIndustry>> getIndustries() async {
final ApiResponse response = final ApiResponse response = await _api.get(StaffEndpoints.industries);
await _api.get(StaffEndpoints.industries); final List<dynamic> items =
final List<dynamic> items = response.data['industries'] as List<dynamic>? ?? <dynamic>[]; response.data['items'] as List<dynamic>? ?? <dynamic>[];
return items.map((dynamic e) => e.toString()).toList(); return items
.map((dynamic e) => StaffIndustry.fromJson(e.toString()))
.whereType<StaffIndustry>()
.toList();
} }
@override @override
Future<List<String>> getSkills() async { Future<({List<StaffSkill> skills, List<String> customSkills})>
getSkills() async {
final ApiResponse response = await _api.get(StaffEndpoints.skills); final ApiResponse response = await _api.get(StaffEndpoints.skills);
final List<dynamic> items = response.data['skills'] as List<dynamic>? ?? <dynamic>[]; final List<dynamic> items =
return items.map((dynamic e) => e.toString()).toList(); response.data['items'] as List<dynamic>? ?? <dynamic>[];
final List<StaffSkill> skills = <StaffSkill>[];
final List<String> customSkills = <String>[];
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 @override
Future<void> saveExperience( Future<void> saveExperience(
List<String> industries, List<StaffIndustry> industries,
List<String> skills, List<StaffSkill> skills,
List<String> customSkills,
) async { ) async {
await _api.put( await _api.put(
StaffEndpoints.personalInfo, StaffEndpoints.experience,
data: <String, dynamic>{ data: <String, dynamic>{
'industries': industries, 'industries':
'skills': skills, industries.map((StaffIndustry i) => i.value).toList(),
'skills': <String>[
...skills.map((StaffSkill s) => s.value),
...customSkills,
],
}, },
); );
} }

View File

@@ -1,14 +1,24 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart' show StaffIndustry, StaffSkill;
/// Arguments for the [SaveExperienceUseCase].
class SaveExperienceArguments extends UseCaseArgument { class SaveExperienceArguments extends UseCaseArgument {
final List<String> industries; /// Creates a [SaveExperienceArguments].
final List<String> skills;
const SaveExperienceArguments({ const SaveExperienceArguments({
required this.industries, required this.industries,
required this.skills, required this.skills,
this.customSkills = const <String>[],
}); });
/// Selected industries.
final List<StaffIndustry> industries;
/// Selected predefined skills.
final List<StaffSkill> skills;
/// Custom skills not in the [StaffSkill] enum.
final List<String> customSkills;
@override @override
List<Object?> get props => [industries, skills]; List<Object?> get props => <Object?>[industries, skills, customSkills];
} }

View File

@@ -1,14 +1,20 @@
import 'package:krow_domain/krow_domain.dart' show StaffIndustry, StaffSkill;
/// Interface for accessing staff experience data. /// Interface for accessing staff experience data.
abstract class ExperienceRepositoryInterface { abstract class ExperienceRepositoryInterface {
/// Fetches the list of industries associated with the staff member. /// Fetches the list of industries associated with the staff member.
Future<List<String>> getIndustries(); Future<List<StaffIndustry>> getIndustries();
/// Fetches the list of skills associated with the staff member. /// Fetches the list of skills associated with the staff member.
Future<List<String>> getSkills(); ///
/// Returns recognised [StaffSkill] values. Unrecognised API values are
/// returned in [customSkills].
Future<({List<StaffSkill> skills, List<String> customSkills})> getSkills();
/// Saves the staff member's experience (industries and skills). /// Saves the staff member's experience (industries and skills).
Future<void> saveExperience( Future<void> saveExperience(
List<String> industries, List<StaffIndustry> industries,
List<String> skills, List<StaffSkill> skills,
List<String> customSkills,
); );
} }

View File

@@ -1,14 +1,18 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart' show StaffIndustry;
import '../repositories/experience_repository_interface.dart'; import '../repositories/experience_repository_interface.dart';
/// Use case for fetching staff industries. /// Use case for fetching staff industries.
class GetStaffIndustriesUseCase implements NoInputUseCase<List<String>> { class GetStaffIndustriesUseCase
final ExperienceRepositoryInterface _repository; implements NoInputUseCase<List<StaffIndustry>> {
/// Creates a [GetStaffIndustriesUseCase].
GetStaffIndustriesUseCase(this._repository); GetStaffIndustriesUseCase(this._repository);
final ExperienceRepositoryInterface _repository;
@override @override
Future<List<String>> call() { Future<List<StaffIndustry>> call() {
return _repository.getIndustries(); return _repository.getIndustries();
} }
} }

View File

@@ -1,14 +1,19 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart' show StaffSkill;
import '../repositories/experience_repository_interface.dart'; import '../repositories/experience_repository_interface.dart';
/// Use case for fetching staff skills. /// Use case for fetching staff skills.
class GetStaffSkillsUseCase implements NoInputUseCase<List<String>> { class GetStaffSkillsUseCase
final ExperienceRepositoryInterface _repository; implements
NoInputUseCase<({List<StaffSkill> skills, List<String> customSkills})> {
/// Creates a [GetStaffSkillsUseCase].
GetStaffSkillsUseCase(this._repository); GetStaffSkillsUseCase(this._repository);
final ExperienceRepositoryInterface _repository;
@override @override
Future<List<String>> call() { Future<({List<StaffSkill> skills, List<String> customSkills})> call() {
return _repository.getSkills(); return _repository.getSkills();
} }
} }

View File

@@ -1,21 +1,22 @@
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import '../arguments/save_experience_arguments.dart'; import '../arguments/save_experience_arguments.dart';
import '../repositories/experience_repository_interface.dart'; import '../repositories/experience_repository_interface.dart';
/// Use case for saving staff experience details. /// Use case for saving staff experience details.
///
/// Delegates the saving logic to [ExperienceRepositoryInterface].
class SaveExperienceUseCase extends UseCase<SaveExperienceArguments, void> { class SaveExperienceUseCase extends UseCase<SaveExperienceArguments, void> {
final ExperienceRepositoryInterface repository;
/// Creates a [SaveExperienceUseCase]. /// Creates a [SaveExperienceUseCase].
SaveExperienceUseCase(this.repository); SaveExperienceUseCase(this.repository);
/// The experience repository.
final ExperienceRepositoryInterface repository;
@override @override
Future<void> call(SaveExperienceArguments params) { Future<void> call(SaveExperienceArguments params) {
return repository.saveExperience( return repository.saveExperience(
params.industries, params.industries,
params.skills, params.skills,
params.customSkills,
); );
} }
} }

View File

@@ -1,144 +1,26 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.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/arguments/save_experience_arguments.dart';
import '../../domain/usecases/get_staff_industries_usecase.dart'; import '../../domain/usecases/get_staff_industries_usecase.dart';
import '../../domain/usecases/get_staff_skills_usecase.dart'; import '../../domain/usecases/get_staff_skills_usecase.dart';
import '../../domain/usecases/save_experience_usecase.dart'; import '../../domain/usecases/save_experience_usecase.dart';
import 'experience_event.dart';
import 'experience_state.dart';
// Events export 'experience_event.dart';
abstract class ExperienceEvent extends Equatable { export 'experience_state.dart';
const ExperienceEvent();
@override /// BLoC that manages the staff experience (industries & skills) selection.
List<Object?> get props => [];
}
class ExperienceLoaded extends ExperienceEvent {}
class ExperienceIndustryToggled extends ExperienceEvent {
final String industry;
const ExperienceIndustryToggled(this.industry);
@override
List<Object?> get props => [industry];
}
class ExperienceSkillToggled extends ExperienceEvent {
final String skill;
const ExperienceSkillToggled(this.skill);
@override
List<Object?> get props => [skill];
}
class ExperienceCustomSkillAdded extends ExperienceEvent {
final String skill;
const ExperienceCustomSkillAdded(this.skill);
@override
List<Object?> get props => [skill];
}
class ExperienceSubmitted extends ExperienceEvent {}
// State
enum ExperienceStatus { initial, loading, success, failure }
class ExperienceState extends Equatable {
final ExperienceStatus status;
final List<String> selectedIndustries;
final List<String> selectedSkills;
final List<String> availableIndustries;
final List<String> 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<String>? selectedIndustries,
List<String>? selectedSkills,
List<String>? availableIndustries,
List<String>? 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<Object?> get props => [
status,
selectedIndustries,
selectedSkills,
availableIndustries,
availableSkills,
errorMessage,
];
}
/// Available industry option values.
const List<String> _kAvailableIndustries = <String>[
'hospitality',
'food_service',
'warehouse',
'events',
'retail',
'healthcare',
'other',
];
/// Available skill option values.
const List<String> _kAvailableSkills = <String>[
'food_service',
'bartending',
'event_setup',
'hospitality',
'warehouse',
'customer_service',
'cleaning',
'security',
'retail',
'driving',
'cooking',
'cashier',
'server',
'barista',
'host_hostess',
'busser',
];
// BLoC
class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState> class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
with BlocErrorHandler<ExperienceState> { with BlocErrorHandler<ExperienceState> {
final GetStaffIndustriesUseCase getIndustries; /// Creates an [ExperienceBloc].
final GetStaffSkillsUseCase getSkills;
final SaveExperienceUseCase saveExperience;
ExperienceBloc({ ExperienceBloc({
required this.getIndustries, required this.getIndustries,
required this.getSkills, required this.getSkills,
required this.saveExperience, required this.saveExperience,
}) : super( }) : super(const ExperienceState()) {
const ExperienceState(
availableIndustries: _kAvailableIndustries,
availableSkills: _kAvailableSkills,
),
) {
on<ExperienceLoaded>(_onLoaded); on<ExperienceLoaded>(_onLoaded);
on<ExperienceIndustryToggled>(_onIndustryToggled); on<ExperienceIndustryToggled>(_onIndustryToggled);
on<ExperienceSkillToggled>(_onSkillToggled); on<ExperienceSkillToggled>(_onSkillToggled);
@@ -148,6 +30,15 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
add(ExperienceLoaded()); 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<void> _onLoaded( Future<void> _onLoaded(
ExperienceLoaded event, ExperienceLoaded event,
Emitter<ExperienceState> emit, Emitter<ExperienceState> emit,
@@ -156,13 +47,16 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
await handleError( await handleError(
emit: emit.call, emit: emit.call,
action: () async { action: () async {
final results = await Future.wait([getIndustries(), getSkills()]); final List<StaffIndustry> industries = await getIndustries();
final ({List<StaffSkill> skills, List<String> customSkills}) skillsResult =
await getSkills();
emit( emit(
state.copyWith( state.copyWith(
status: ExperienceStatus.initial, status: ExperienceStatus.initial,
selectedIndustries: results[0], selectedIndustries: industries,
selectedSkills: results[1], selectedSkills: skillsResult.skills,
customSkills: skillsResult.customSkills,
), ),
); );
}, },
@@ -177,7 +71,8 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
ExperienceIndustryToggled event, ExperienceIndustryToggled event,
Emitter<ExperienceState> emit, Emitter<ExperienceState> emit,
) { ) {
final industries = List<String>.from(state.selectedIndustries); final List<StaffIndustry> industries =
List<StaffIndustry>.from(state.selectedIndustries);
if (industries.contains(event.industry)) { if (industries.contains(event.industry)) {
industries.remove(event.industry); industries.remove(event.industry);
} else { } else {
@@ -190,7 +85,8 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
ExperienceSkillToggled event, ExperienceSkillToggled event,
Emitter<ExperienceState> emit, Emitter<ExperienceState> emit,
) { ) {
final skills = List<String>.from(state.selectedSkills); final List<StaffSkill> skills =
List<StaffSkill>.from(state.selectedSkills);
if (skills.contains(event.skill)) { if (skills.contains(event.skill)) {
skills.remove(event.skill); skills.remove(event.skill);
} else { } else {
@@ -203,9 +99,10 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
ExperienceCustomSkillAdded event, ExperienceCustomSkillAdded event,
Emitter<ExperienceState> emit, Emitter<ExperienceState> emit,
) { ) {
if (!state.selectedSkills.contains(event.skill)) { if (!state.customSkills.contains(event.skill)) {
final skills = List<String>.from(state.selectedSkills)..add(event.skill); final List<String> custom = List<String>.from(state.customSkills)
emit(state.copyWith(selectedSkills: skills)); ..add(event.skill);
emit(state.copyWith(customSkills: custom));
} }
} }
@@ -221,6 +118,7 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
SaveExperienceArguments( SaveExperienceArguments(
industries: state.selectedIndustries, industries: state.selectedIndustries,
skills: state.selectedSkills, skills: state.selectedSkills,
customSkills: state.customSkills,
), ),
); );
emit(state.copyWith(status: ExperienceStatus.success)); emit(state.copyWith(status: ExperienceStatus.success));

View File

@@ -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<Object?> get props => <Object?>[];
}
/// 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<Object?> get props => <Object?>[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<Object?> get props => <Object?>[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<Object?> get props => <Object?>[skill];
}
/// Submits the selected industries and skills to the backend.
class ExperienceSubmitted extends ExperienceEvent {}

View File

@@ -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 <StaffIndustry>[],
this.selectedSkills = const <StaffSkill>[],
this.customSkills = const <String>[],
this.errorMessage,
});
/// Current operation status.
final ExperienceStatus status;
/// Industries the staff member has selected.
final List<StaffIndustry> selectedIndustries;
/// Skills the staff member has selected.
final List<StaffSkill> selectedSkills;
/// Custom skills not in [StaffSkill] that the user added.
final List<String> customSkills;
/// Error message key when [status] is [ExperienceStatus.failure].
final String? errorMessage;
/// All selected skill values as API strings (enum + custom combined).
List<String> get allSkillValues =>
<String>[
...selectedSkills.map((StaffSkill s) => s.value),
...customSkills,
];
/// Creates a copy with the given fields replaced.
ExperienceState copyWith({
ExperienceStatus? status,
List<StaffIndustry>? selectedIndustries,
List<StaffSkill>? selectedSkills,
List<String>? 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<Object?> get props => <Object?>[
status,
selectedIndustries,
selectedSkills,
customSkills,
errorMessage,
];
}

View File

@@ -4,90 +4,33 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart' show StaffIndustry, StaffSkill;
import '../blocs/experience_bloc.dart'; import '../blocs/experience_bloc.dart';
import '../widgets/experience_section_title.dart'; import '../widgets/experience_section_title.dart';
/// Page for selecting staff industries and skills.
class ExperiencePage extends StatelessWidget { class ExperiencePage extends StatelessWidget {
/// Creates an [ExperiencePage].
const ExperiencePage({super.key}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final i18n = Translations.of(context).staff.onboarding.experience; final dynamic i18n = Translations.of(context).staff.onboarding.experience;
return Scaffold( return Scaffold(
appBar: UiAppBar( appBar: UiAppBar(
title: i18n.title, title: i18n.title as String,
onLeadingPressed: () => Modular.to.toProfile(), onLeadingPressed: () => Modular.to.toProfile(),
), ),
body: BlocProvider<ExperienceBloc>( body: BlocProvider<ExperienceBloc>(
create: (context) => Modular.get<ExperienceBloc>(), create: (BuildContext context) => Modular.get<ExperienceBloc>(),
child: BlocConsumer<ExperienceBloc, ExperienceState>( child: BlocConsumer<ExperienceBloc, ExperienceState>(
listener: (context, state) { listener: (BuildContext context, ExperienceState state) {
if (state.status == ExperienceStatus.success) { if (state.status == ExperienceStatus.success) {
UiSnackbar.show( UiSnackbar.show(
context, context,
message: 'Experience saved successfully', message: i18n.save_success as String,
type: UiSnackbarType.success, type: UiSnackbarType.success,
margin: const EdgeInsets.only( margin: const EdgeInsets.only(
bottom: 120, bottom: 120,
@@ -100,7 +43,7 @@ class ExperiencePage extends StatelessWidget {
context, context,
message: state.errorMessage != null message: state.errorMessage != null
? translateErrorKey(state.errorMessage!) ? translateErrorKey(state.errorMessage!)
: 'An error occurred', : i18n.save_error as String,
type: UiSnackbarType.error, type: UiSnackbarType.error,
margin: const EdgeInsets.only( margin: const EdgeInsets.only(
bottom: 120, bottom: 120,
@@ -110,65 +53,28 @@ class ExperiencePage extends StatelessWidget {
); );
} }
}, },
builder: (context, state) { builder: (BuildContext context, ExperienceState state) {
return Column( return Column(
children: [ children: <Widget>[
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space5),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: <Widget>[
ExperienceSectionTitle( ExperienceSectionTitle(
title: i18n.industries_title, title: i18n.industries_title as String,
subtitle: i18n.industries_subtitle, subtitle: i18n.industries_subtitle as String,
), ),
const SizedBox(height: UiConstants.space3), const SizedBox(height: UiConstants.space3),
Wrap( _buildIndustryChips(context, state, i18n),
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<ExperienceBloc>(
context,
).add(ExperienceIndustryToggled(i)),
variant: state.selectedIndustries.contains(i)
? UiChipVariant.primary
: UiChipVariant.secondary,
),
)
.toList(),
),
const SizedBox(height: UiConstants.space10), const SizedBox(height: UiConstants.space10),
ExperienceSectionTitle( ExperienceSectionTitle(
title: i18n.skills_title, title: i18n.skills_title as String,
subtitle: i18n.skills_subtitle, subtitle: i18n.skills_subtitle as String,
), ),
const SizedBox(height: UiConstants.space3), const SizedBox(height: UiConstants.space3),
Wrap( _buildSkillChips(context, state, i18n),
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<ExperienceBloc>(
context,
).add(ExperienceSkillToggled(s)),
variant:
state.selectedSkills.contains(s)
? UiChipVariant.primary
: UiChipVariant.secondary,
),
)
.toList(),
),
], ],
), ),
), ),
@@ -182,28 +88,45 @@ class ExperiencePage extends StatelessWidget {
); );
} }
Widget _buildCustomSkillsList(ExperienceState state, dynamic i18n) { Widget _buildIndustryChips(
final customSkills = state.selectedSkills BuildContext context,
.where((s) => !state.availableSkills.contains(s)) ExperienceState state,
.toList(); dynamic i18n,
if (customSkills.isEmpty) return const SizedBox.shrink(); ) {
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<ExperienceBloc>(context)
.add(ExperienceIndustryToggled(industry)),
variant: isSelected ? UiChipVariant.primary : UiChipVariant.secondary,
);
}).toList(),
);
}
return Column( Widget _buildSkillChips(
crossAxisAlignment: CrossAxisAlignment.start, BuildContext context,
children: [ ExperienceState state,
Text( dynamic i18n,
i18n.custom_skills_title, ) {
style: UiTypography.body2m.textSecondary, return Wrap(
), spacing: UiConstants.space2,
const SizedBox(height: UiConstants.space2), runSpacing: UiConstants.space2,
Wrap( children: StaffSkill.values.map((StaffSkill skill) {
spacing: UiConstants.space2, final bool isSelected = state.selectedSkills.contains(skill);
runSpacing: UiConstants.space2, return UiChip(
children: customSkills.map((skill) { label: _getSkillLabel(i18n.skills, skill),
return UiChip(label: skill, variant: UiChipVariant.accent); isSelected: isSelected,
}).toList(), onTap: () => BlocProvider.of<ExperienceBloc>(context)
), .add(ExperienceSkillToggled(skill)),
], variant: isSelected ? UiChipVariant.primary : UiChipVariant.secondary,
);
}).toList(),
); );
} }
@@ -212,6 +135,7 @@ class ExperiencePage extends StatelessWidget {
ExperienceState state, ExperienceState state,
dynamic i18n, dynamic i18n,
) { ) {
final bool isLoading = state.status == ExperienceStatus.loading;
return Container( return Container(
padding: const EdgeInsets.all(UiConstants.space4), padding: const EdgeInsets.all(UiConstants.space4),
decoration: const BoxDecoration( decoration: const BoxDecoration(
@@ -220,24 +144,20 @@ class ExperiencePage extends StatelessWidget {
), ),
child: SafeArea( child: SafeArea(
child: UiButton.primary( child: UiButton.primary(
onPressed: state.status == ExperienceStatus.loading onPressed: isLoading
? null ? null
: () => BlocProvider.of<ExperienceBloc>( : () => BlocProvider.of<ExperienceBloc>(context)
context, .add(ExperienceSubmitted()),
).add(ExperienceSubmitted()),
fullWidth: true, fullWidth: true,
text: state.status == ExperienceStatus.loading text: isLoading ? null : i18n.save_button as String,
? null child: isLoading
: i18n.save_button,
child: state.status == ExperienceStatus.loading
? const SizedBox( ? const SizedBox(
height: UiConstants.iconMd, height: UiConstants.iconMd,
width: UiConstants.iconMd, width: UiConstants.iconMd,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>( valueColor:
UiColors.white, AlwaysStoppedAnimation<Color>(UiColors.white),
), // UiColors.primaryForeground is white mostly
), ),
) )
: null, : 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;
}
}
} }