feat: add staff attire management feature
- Introduced localization for staff attire in English and Spanish. - Created AttireItem entity to represent attire items. - Implemented StaffAttireModule with repository, use cases, and BLoC for state management. - Developed UI components including AttirePage, AttireGrid, AttireInfoCard, AttestationCheckbox, and AttireBottomBar. - Added navigation for attire management and integrated with existing staff profile flow. - Implemented functionality for selecting attire items, uploading photos, and saving selections with validation.
This commit is contained in:
@@ -640,5 +640,28 @@
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Remove"
|
||||
}
|
||||
},
|
||||
"staff_profile_attire": {
|
||||
"title": "Attire",
|
||||
"info_card": {
|
||||
"title": "Your Wardrobe",
|
||||
"description": "Select the attire items you own. This helps us match you with shifts that fit your wardrobe."
|
||||
},
|
||||
"status": {
|
||||
"required": "REQUIRED",
|
||||
"add_photo": "Add Photo",
|
||||
"added": "Added",
|
||||
"pending": "⏳ Pending verification"
|
||||
},
|
||||
"attestation": "I certify that I own these items and will wear them to my shifts. I understand that items are pending manager verification at my first shift.",
|
||||
"actions": {
|
||||
"save": "Save Attire"
|
||||
},
|
||||
"validation": {
|
||||
"select_required": "✓ Select all required items",
|
||||
"upload_required": "✓ Upload photos of required items",
|
||||
"accept_attestation": "✓ Accept attestation"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -639,5 +639,27 @@
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Remove"
|
||||
}
|
||||
},
|
||||
"staff_profile_attire": {
|
||||
"title": "Vestimenta",
|
||||
"info_card": {
|
||||
"title": "Tu Vestuario",
|
||||
"description": "Selecciona los artículos de vestimenta que posees. Esto nos ayuda a asignarte turnos que se ajusten a tu vestuario."
|
||||
},
|
||||
"status": {
|
||||
"required": "REQUERIDO",
|
||||
"add_photo": "Añadir Foto",
|
||||
"added": "Añadido",
|
||||
"pending": "⏳ Verificación pendiente"
|
||||
},
|
||||
"attestation": "Certifico que poseo estos artículos y los usaré en mis turnos. Entiendo que los artículos están pendientes de verificación por el gerente en mi primer turno.",
|
||||
"actions": {
|
||||
"save": "Guardar Vestimenta"
|
||||
},
|
||||
"validation": {
|
||||
"select_required": "✓ Seleccionar todos los artículos requeridos",
|
||||
"upload_required": "✓ Subir fotos de artículos requeridos",
|
||||
"accept_attestation": "✓ Aceptar certificación"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export 'src/entities/financial/staff_payment.dart';
|
||||
|
||||
// Profile
|
||||
export 'src/entities/profile/staff_document.dart';
|
||||
export 'src/entities/profile/attire_item.dart';
|
||||
|
||||
// Ratings & Penalties
|
||||
export 'src/entities/ratings/staff_rating.dart';
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Represents an attire item that a staff member might need or possess.
|
||||
///
|
||||
/// Attire items are specific clothing or equipment required for jobs.
|
||||
class AttireItem extends Equatable {
|
||||
/// Unique identifier of the attire item.
|
||||
final String id;
|
||||
|
||||
/// Display name of the item.
|
||||
final String label;
|
||||
|
||||
/// Name of the icon to display (mapped in UI).
|
||||
final String? iconName;
|
||||
|
||||
/// URL of the reference image.
|
||||
final String? imageUrl;
|
||||
|
||||
/// Whether this item is mandatory for onboarding.
|
||||
final bool isMandatory;
|
||||
|
||||
/// Creates an [AttireItem].
|
||||
const AttireItem({
|
||||
required this.id,
|
||||
required this.label,
|
||||
this.iconName,
|
||||
this.imageUrl,
|
||||
this.isMandatory = false,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[id, label, iconName, imageUrl, isMandatory];
|
||||
}
|
||||
@@ -23,7 +23,7 @@ extension ProfileNavigator on IModularNavigator {
|
||||
|
||||
/// Navigates to the attire page.
|
||||
void pushAttire() {
|
||||
pushNamed('../onboarding/attire');
|
||||
pushNamed('../attire');
|
||||
}
|
||||
|
||||
/// Navigates to the documents page.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
include: ../../../../../../analysis_options.yaml
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
|
||||
import 'data/repositories_impl/attire_repository_impl.dart';
|
||||
import 'domain/repositories/attire_repository.dart';
|
||||
import 'domain/usecases/get_attire_options_usecase.dart';
|
||||
import 'domain/usecases/save_attire_usecase.dart';
|
||||
import 'domain/usecases/upload_attire_photo_usecase.dart';
|
||||
import 'presentation/blocs/attire_cubit.dart';
|
||||
import 'presentation/pages/attire_page.dart';
|
||||
|
||||
class StaffAttireModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repository
|
||||
i.addLazySingleton<AttireRepository>(
|
||||
() => AttireRepositoryImpl(ExampleConnector.instance),
|
||||
);
|
||||
|
||||
// Use Cases
|
||||
i.addLazySingleton(GetAttireOptionsUseCase.new);
|
||||
i.addLazySingleton(SaveAttireUseCase.new);
|
||||
i.addLazySingleton(UploadAttirePhotoUseCase.new);
|
||||
|
||||
// BLoC
|
||||
i.addLazySingleton(AttireCubit.new);
|
||||
}
|
||||
|
||||
@override
|
||||
void routes(RouteManager r) {
|
||||
r.child('/', child: (_) => const AttirePage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../domain/repositories/attire_repository.dart';
|
||||
|
||||
/// Implementation of [AttireRepository].
|
||||
///
|
||||
/// Delegates data access to [ExampleConnector] from `data_connect`.
|
||||
class AttireRepositoryImpl implements AttireRepository {
|
||||
/// The Data Connect connector instance.
|
||||
final ExampleConnector _connector;
|
||||
|
||||
/// Creates an [AttireRepositoryImpl].
|
||||
AttireRepositoryImpl(this._connector);
|
||||
|
||||
@override
|
||||
Future<List<AttireItem>> getAttireOptions() async {
|
||||
final QueryResult<ListAttireOptionsData, void> result = await _connector.listAttireOptions().execute();
|
||||
return result.data.attireOptions.map((ListAttireOptionsAttireOptions e) => AttireItem(
|
||||
id: e.itemId,
|
||||
label: e.label,
|
||||
iconName: e.icon,
|
||||
imageUrl: e.imageUrl,
|
||||
isMandatory: e.isMandatory ?? false,
|
||||
)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveAttire({
|
||||
required List<String> selectedItemIds,
|
||||
required Map<String, String> photoUrls,
|
||||
}) async {
|
||||
// TODO: Connect to actual backend mutation when available.
|
||||
// For now, simulate network delay as per prototype behavior.
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> uploadPhoto(String itemId) async {
|
||||
// TODO: Connect to actual storage service/mutation when available.
|
||||
// For now, simulate upload delay and return mock URL.
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
return 'mock_url_for_$itemId';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Arguments for saving staff attire selections.
|
||||
class SaveAttireArguments extends UseCaseArgument {
|
||||
/// List of selected attire item IDs.
|
||||
final List<String> selectedItemIds;
|
||||
|
||||
/// Map of item IDs to uploaded photo URLs.
|
||||
final Map<String, String> photoUrls;
|
||||
|
||||
/// Creates a [SaveAttireArguments].
|
||||
const SaveAttireArguments({
|
||||
required this.selectedItemIds,
|
||||
required this.photoUrls,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[selectedItemIds, photoUrls];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
/// Arguments for uploading an attire photo.
|
||||
class UploadAttirePhotoArguments extends UseCaseArgument {
|
||||
/// The ID of the attire item being uploaded.
|
||||
final String itemId;
|
||||
// Note: typically we'd pass a File or path here too, but the prototype likely picks it internally or mocking it.
|
||||
// The current logic takes "itemId" and returns a mock URL.
|
||||
// We'll stick to that signature for now to "preserve behavior".
|
||||
|
||||
/// Creates a [UploadAttirePhotoArguments].
|
||||
const UploadAttirePhotoArguments({required this.itemId});
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[itemId];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
abstract interface class AttireRepository {
|
||||
/// Fetches the list of available attire options.
|
||||
Future<List<AttireItem>> getAttireOptions();
|
||||
|
||||
/// Simulates uploading a photo for a specific attire item.
|
||||
Future<String> uploadPhoto(String itemId);
|
||||
|
||||
/// Saves the user's attire selection and attestations.
|
||||
Future<void> saveAttire({
|
||||
required List<String> selectedItemIds,
|
||||
required Map<String, String> photoUrls,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../repositories/attire_repository.dart';
|
||||
|
||||
/// Use case to fetch available attire options.
|
||||
class GetAttireOptionsUseCase extends NoInputUseCase<List<AttireItem>> {
|
||||
final AttireRepository _repository;
|
||||
|
||||
/// Creates a [GetAttireOptionsUseCase].
|
||||
GetAttireOptionsUseCase(this._repository);
|
||||
|
||||
@override
|
||||
Future<List<AttireItem>> call() {
|
||||
return _repository.getAttireOptions();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../arguments/save_attire_arguments.dart';
|
||||
import '../repositories/attire_repository.dart';
|
||||
|
||||
/// Use case to save user's attire selections.
|
||||
class SaveAttireUseCase extends UseCase<SaveAttireArguments, void> {
|
||||
final AttireRepository _repository;
|
||||
|
||||
/// Creates a [SaveAttireUseCase].
|
||||
SaveAttireUseCase(this._repository);
|
||||
|
||||
@override
|
||||
Future<void> call(SaveAttireArguments arguments) {
|
||||
return _repository.saveAttire(
|
||||
selectedItemIds: arguments.selectedItemIds,
|
||||
photoUrls: arguments.photoUrls,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../arguments/upload_attire_photo_arguments.dart';
|
||||
import '../repositories/attire_repository.dart';
|
||||
|
||||
/// Use case to upload a photo for an attire item.
|
||||
class UploadAttirePhotoUseCase extends UseCase<UploadAttirePhotoArguments, String> {
|
||||
final AttireRepository _repository;
|
||||
|
||||
/// Creates a [UploadAttirePhotoUseCase].
|
||||
UploadAttirePhotoUseCase(this._repository);
|
||||
|
||||
@override
|
||||
Future<String> call(UploadAttirePhotoArguments arguments) {
|
||||
return _repository.uploadPhoto(arguments.itemId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../../domain/arguments/save_attire_arguments.dart';
|
||||
import '../../domain/arguments/upload_attire_photo_arguments.dart';
|
||||
import '../../domain/usecases/get_attire_options_usecase.dart';
|
||||
import '../../domain/usecases/save_attire_usecase.dart';
|
||||
import '../../domain/usecases/upload_attire_photo_usecase.dart';
|
||||
import 'attire_state.dart';
|
||||
|
||||
class AttireCubit extends Cubit<AttireState> {
|
||||
final GetAttireOptionsUseCase _getAttireOptionsUseCase;
|
||||
final SaveAttireUseCase _saveAttireUseCase;
|
||||
final UploadAttirePhotoUseCase _uploadAttirePhotoUseCase;
|
||||
|
||||
AttireCubit(
|
||||
this._getAttireOptionsUseCase,
|
||||
this._saveAttireUseCase,
|
||||
this._uploadAttirePhotoUseCase,
|
||||
) : super(const AttireState()) {
|
||||
loadOptions();
|
||||
}
|
||||
|
||||
Future<void> loadOptions() async {
|
||||
emit(state.copyWith(status: AttireStatus.loading));
|
||||
try {
|
||||
final List<AttireItem> options = await _getAttireOptionsUseCase();
|
||||
|
||||
// Auto-select mandatory items initially as per prototype
|
||||
final List<String> mandatoryIds = options
|
||||
.where((AttireItem e) => e.isMandatory)
|
||||
.map((AttireItem e) => e.id)
|
||||
.toList();
|
||||
|
||||
final List<String> initialSelection = List<String>.from(state.selectedIds);
|
||||
for (final String id in mandatoryIds) {
|
||||
if (!initialSelection.contains(id)) {
|
||||
initialSelection.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
emit(state.copyWith(
|
||||
status: AttireStatus.success,
|
||||
options: options,
|
||||
selectedIds: initialSelection,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(status: AttireStatus.failure, errorMessage: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
void toggleSelection(String id) {
|
||||
// Prevent unselecting mandatory items
|
||||
if (state.isMandatory(id)) return;
|
||||
|
||||
final List<String> currentSelection = List<String>.from(state.selectedIds);
|
||||
if (currentSelection.contains(id)) {
|
||||
currentSelection.remove(id);
|
||||
} else {
|
||||
currentSelection.add(id);
|
||||
}
|
||||
emit(state.copyWith(selectedIds: currentSelection));
|
||||
}
|
||||
|
||||
void toggleAttestation(bool value) {
|
||||
emit(state.copyWith(attestationChecked: value));
|
||||
}
|
||||
|
||||
Future<void> uploadPhoto(String itemId) async {
|
||||
final Map<String, bool> currentUploading = Map<String, bool>.from(state.uploadingStatus);
|
||||
currentUploading[itemId] = true;
|
||||
emit(state.copyWith(uploadingStatus: currentUploading));
|
||||
|
||||
try {
|
||||
final String url = await _uploadAttirePhotoUseCase(
|
||||
UploadAttirePhotoArguments(itemId: itemId),
|
||||
);
|
||||
|
||||
final Map<String, String> currentPhotos = Map<String, String>.from(state.photoUrls);
|
||||
currentPhotos[itemId] = url;
|
||||
|
||||
// Auto-select item on upload success if not selected
|
||||
final List<String> currentSelection = List<String>.from(state.selectedIds);
|
||||
if (!currentSelection.contains(itemId)) {
|
||||
currentSelection.add(itemId);
|
||||
}
|
||||
|
||||
currentUploading[itemId] = false;
|
||||
emit(state.copyWith(
|
||||
uploadingStatus: currentUploading,
|
||||
photoUrls: currentPhotos,
|
||||
selectedIds: currentSelection,
|
||||
));
|
||||
} catch (e) {
|
||||
currentUploading[itemId] = false;
|
||||
emit(state.copyWith(
|
||||
uploadingStatus: currentUploading,
|
||||
// Could handle error specifically via snackbar event
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> save() async {
|
||||
if (!state.canSave) return;
|
||||
|
||||
emit(state.copyWith(status: AttireStatus.saving));
|
||||
try {
|
||||
await _saveAttireUseCase(SaveAttireArguments(
|
||||
selectedItemIds: state.selectedIds,
|
||||
photoUrls: state.photoUrls,
|
||||
));
|
||||
emit(state.copyWith(status: AttireStatus.saved));
|
||||
} catch (e) {
|
||||
emit(state.copyWith(status: AttireStatus.failure, errorMessage: e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
enum AttireStatus { initial, loading, success, failure, saving, saved }
|
||||
|
||||
class AttireState extends Equatable {
|
||||
final AttireStatus status;
|
||||
final List<AttireItem> options;
|
||||
final List<String> selectedIds;
|
||||
final Map<String, String> photoUrls;
|
||||
final Map<String, bool> uploadingStatus;
|
||||
final bool attestationChecked;
|
||||
final String? errorMessage;
|
||||
|
||||
const AttireState({
|
||||
this.status = AttireStatus.initial,
|
||||
this.options = const <AttireItem>[],
|
||||
this.selectedIds = const <String>[],
|
||||
this.photoUrls = const <String, String>{},
|
||||
this.uploadingStatus = const <String, bool>{},
|
||||
this.attestationChecked = false,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
bool get uploading => uploadingStatus.values.any((bool u) => u);
|
||||
|
||||
/// Helper to check if item is mandatory
|
||||
bool isMandatory(String id) {
|
||||
return options.firstWhere((AttireItem e) => e.id == id, orElse: () => const AttireItem(id: '', label: '')).isMandatory;
|
||||
}
|
||||
|
||||
/// Validation logic
|
||||
bool get allMandatorySelected {
|
||||
final Iterable<String> mandatoryIds = options.where((AttireItem e) => e.isMandatory).map((AttireItem e) => e.id);
|
||||
return mandatoryIds.every((String id) => selectedIds.contains(id));
|
||||
}
|
||||
|
||||
bool get allMandatoryHavePhotos {
|
||||
final Iterable<String> mandatoryIds = options.where((AttireItem e) => e.isMandatory).map((AttireItem e) => e.id);
|
||||
return mandatoryIds.every((String id) => photoUrls.containsKey(id));
|
||||
}
|
||||
|
||||
bool get canSave => allMandatorySelected && allMandatoryHavePhotos && attestationChecked && !uploading;
|
||||
|
||||
AttireState copyWith({
|
||||
AttireStatus? status,
|
||||
List<AttireItem>? options,
|
||||
List<String>? selectedIds,
|
||||
Map<String, String>? photoUrls,
|
||||
Map<String, bool>? uploadingStatus,
|
||||
bool? attestationChecked,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return AttireState(
|
||||
status: status ?? this.status,
|
||||
options: options ?? this.options,
|
||||
selectedIds: selectedIds ?? this.selectedIds,
|
||||
photoUrls: photoUrls ?? this.photoUrls,
|
||||
uploadingStatus: uploadingStatus ?? this.uploadingStatus,
|
||||
attestationChecked: attestationChecked ?? this.attestationChecked,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
status,
|
||||
options,
|
||||
selectedIds,
|
||||
photoUrls,
|
||||
uploadingStatus,
|
||||
attestationChecked,
|
||||
errorMessage
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
|
||||
/// Extension on [IModularNavigator] to provide strongly-typed navigation
|
||||
/// for the staff attire feature.
|
||||
extension AttireNavigator on IModularNavigator {
|
||||
/// Navigates back.
|
||||
void popAttire() {
|
||||
pop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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 'package:core_localization/core_localization.dart';
|
||||
|
||||
import '../blocs/attire_cubit.dart';
|
||||
import '../blocs/attire_state.dart';
|
||||
import '../widgets/attestation_checkbox.dart';
|
||||
import '../widgets/attire_bottom_bar.dart';
|
||||
import '../widgets/attire_grid.dart';
|
||||
import '../widgets/attire_info_card.dart';
|
||||
|
||||
class AttirePage extends StatelessWidget {
|
||||
const AttirePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Note: t.staff_profile_attire is available via re-export of core_localization
|
||||
final AttireCubit cubit = Modular.get<AttireCubit>();
|
||||
|
||||
return BlocProvider<AttireCubit>.value(
|
||||
value: cubit,
|
||||
child: Scaffold(
|
||||
backgroundColor: UiColors.background, // FAFBFC
|
||||
appBar: AppBar(
|
||||
backgroundColor: UiColors.white,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(UiIcons.chevronLeft, color: UiColors.iconSecondary),
|
||||
onPressed: () => Modular.to.pop(),
|
||||
),
|
||||
title: Text(
|
||||
t.staff_profile_attire.title,
|
||||
style: UiTypography.headline3m.copyWith(
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1.0),
|
||||
child: Container(color: UiColors.border, height: 1.0),
|
||||
),
|
||||
),
|
||||
body: BlocConsumer<AttireCubit, AttireState>(
|
||||
listener: (BuildContext context, AttireState state) {
|
||||
if (state.status == AttireStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(state.errorMessage ?? 'Error')),
|
||||
);
|
||||
}
|
||||
if (state.status == AttireStatus.saved) {
|
||||
Modular.to.pop();
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, AttireState state) {
|
||||
if (state.status == AttireStatus.loading && state.options.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
const AttireInfoCard(),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
AttireGrid(
|
||||
items: state.options,
|
||||
selectedIds: state.selectedIds,
|
||||
photoUrls: state.photoUrls,
|
||||
uploadingStatus: state.uploadingStatus,
|
||||
onToggle: cubit.toggleSelection,
|
||||
onUpload: cubit.uploadPhoto,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
AttestationCheckbox(
|
||||
isChecked: state.attestationChecked,
|
||||
onChanged: (bool? val) => cubit.toggleAttestation(val ?? false),
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
AttireBottomBar(
|
||||
canSave: state.canSave,
|
||||
allMandatorySelected: state.allMandatorySelected,
|
||||
allMandatoryHavePhotos: state.allMandatoryHavePhotos,
|
||||
attestationChecked: state.attestationChecked,
|
||||
onSave: cubit.save,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
|
||||
class AttestationCheckbox extends StatelessWidget {
|
||||
final bool isChecked;
|
||||
final ValueChanged<bool?> onChanged;
|
||||
|
||||
const AttestationCheckbox({
|
||||
super.key,
|
||||
required this.isChecked,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Checkbox(
|
||||
value: isChecked,
|
||||
onChanged: onChanged,
|
||||
activeColor: UiColors.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Text(
|
||||
t.staff_profile_attire.attestation,
|
||||
style: UiTypography.body2r.copyWith(color: UiColors.textPrimary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
|
||||
class AttireBottomBar extends StatelessWidget {
|
||||
final bool canSave;
|
||||
final bool allMandatorySelected;
|
||||
final bool allMandatoryHavePhotos;
|
||||
final bool attestationChecked;
|
||||
final VoidCallback onSave;
|
||||
|
||||
const AttireBottomBar({
|
||||
super.key,
|
||||
required this.canSave,
|
||||
required this.allMandatorySelected,
|
||||
required this.allMandatoryHavePhotos,
|
||||
required this.attestationChecked,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.white,
|
||||
border: Border(top: BorderSide(color: UiColors.border)),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
if (!canSave)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
if (!allMandatorySelected)
|
||||
_buildValidationError(t.staff_profile_attire.validation.select_required),
|
||||
if (!allMandatoryHavePhotos)
|
||||
_buildValidationError(t.staff_profile_attire.validation.upload_required),
|
||||
if (!attestationChecked)
|
||||
_buildValidationError(t.staff_profile_attire.validation.accept_attestation),
|
||||
],
|
||||
),
|
||||
),
|
||||
UiButton.primary(
|
||||
text: t.staff_profile_attire.actions.save,
|
||||
onPressed: canSave ? onSave : null, // UiButton handles disabled/null?
|
||||
// UiButton usually takes nullable onPressed to disable.
|
||||
fullWidth: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildValidationError(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: UiTypography.body3r.copyWith(color: UiColors.destructive),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
class AttireGrid extends StatelessWidget {
|
||||
final List<AttireItem> items;
|
||||
final List<String> selectedIds;
|
||||
final Map<String, String> photoUrls;
|
||||
final Map<String, bool> uploadingStatus;
|
||||
final Function(String id) onToggle;
|
||||
final Function(String id) onUpload;
|
||||
|
||||
const AttireGrid({
|
||||
super.key,
|
||||
required this.items,
|
||||
required this.selectedIds,
|
||||
required this.photoUrls,
|
||||
required this.uploadingStatus,
|
||||
required this.onToggle,
|
||||
required this.onUpload,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: UiConstants.space3,
|
||||
mainAxisSpacing: UiConstants.space3,
|
||||
childAspectRatio: 0.8,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final AttireItem item = items[index];
|
||||
final bool isSelected = selectedIds.contains(item.id);
|
||||
final bool hasPhoto = photoUrls.containsKey(item.id);
|
||||
final bool isUploading = uploadingStatus[item.id] ?? false;
|
||||
|
||||
return _buildCard(item, isSelected, hasPhoto, isUploading);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCard(
|
||||
AttireItem item,
|
||||
bool isSelected,
|
||||
bool hasPhoto,
|
||||
bool isUploading,
|
||||
) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? UiColors.primary.withOpacity(0.1) : Colors.transparent,
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
border: Border.all(
|
||||
color: isSelected ? UiColors.primary : UiColors.border,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
if (item.isMandatory)
|
||||
Positioned(
|
||||
top: UiConstants.space2,
|
||||
left: UiConstants.space2,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.destructive, // Red
|
||||
borderRadius: UiConstants.radiusSm,
|
||||
),
|
||||
child: Text(
|
||||
t.staff_profile_attire.status.required,
|
||||
style: UiTypography.body3m.copyWith( // 12px Medium -> Bold
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 9,
|
||||
color: UiColors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasPhoto)
|
||||
Positioned(
|
||||
top: UiConstants.space2,
|
||||
right: UiConstants.space2,
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
UiIcons.check,
|
||||
color: UiColors.white,
|
||||
size: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
GestureDetector(
|
||||
onTap: () => onToggle(item.id),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
item.imageUrl != null
|
||||
? Container(
|
||||
height: 80,
|
||||
width: 80,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(item.imageUrl!),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_getIcon(item.iconName),
|
||||
size: 48,
|
||||
color: UiColors.textPrimary, // Was charcoal
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
item.label,
|
||||
textAlign: TextAlign.center,
|
||||
style: UiTypography.body2m.copyWith(
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
InkWell(
|
||||
onTap: () => onUpload(item.id),
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: UiConstants.space2,
|
||||
horizontal: UiConstants.space3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: hasPhoto
|
||||
? UiColors.primary.withOpacity(0.05)
|
||||
: UiColors.white,
|
||||
border: Border.all(
|
||||
color: hasPhoto ? UiColors.primary : UiColors.border,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
if (isUploading)
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(UiColors.primary),
|
||||
),
|
||||
)
|
||||
else if (hasPhoto)
|
||||
const Icon(
|
||||
UiIcons.check,
|
||||
size: 12,
|
||||
color: UiColors.primary,
|
||||
)
|
||||
else
|
||||
const Icon(
|
||||
UiIcons.camera,
|
||||
size: 12,
|
||||
color: UiColors.textSecondary, // Was muted
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
isUploading
|
||||
? '...'
|
||||
: hasPhoto
|
||||
? t.staff_profile_attire.status.added
|
||||
: t.staff_profile_attire.status.add_photo,
|
||||
style: UiTypography.body3m.copyWith(
|
||||
color: hasPhoto ? UiColors.primary : UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasPhoto)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
t.staff_profile_attire.status.pending,
|
||||
style: UiTypography.body3r.copyWith(
|
||||
fontSize: 10,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getIcon(String? name) {
|
||||
switch (name) {
|
||||
case 'footprints':
|
||||
return LucideIcons.footprints;
|
||||
case 'scissors':
|
||||
return LucideIcons.scissors;
|
||||
case 'user':
|
||||
return LucideIcons.user;
|
||||
case 'shirt':
|
||||
return LucideIcons.shirt;
|
||||
case 'hardHat':
|
||||
return LucideIcons.hardHat;
|
||||
case 'chefHat':
|
||||
return LucideIcons.chefHat;
|
||||
default:
|
||||
return LucideIcons.helpCircle;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
|
||||
class AttireInfoCard extends StatelessWidget {
|
||||
const AttireInfoCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// Using LucideIcons for domain specific content icon not in UiIcons
|
||||
const Icon(LucideIcons.shirt, color: UiColors.primary, size: 24),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
t.staff_profile_attire.info_card.title,
|
||||
style: UiTypography.body2m.copyWith(
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
t.staff_profile_attire.info_card.description,
|
||||
style: UiTypography.body2r.copyWith(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
library staff_attire;
|
||||
|
||||
export 'src/attire_module.dart';
|
||||
@@ -0,0 +1,34 @@
|
||||
name: staff_attire
|
||||
description: "Feature package for Staff Attire management"
|
||||
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.0.0
|
||||
equatable: ^2.0.5
|
||||
firebase_data_connect: ^0.2.2+1
|
||||
|
||||
# Internal packages
|
||||
krow_core:
|
||||
path: ../../../../../core
|
||||
krow_domain:
|
||||
path: ../../../../../domain
|
||||
krow_data_connect:
|
||||
path: ../../../../../data_connect
|
||||
design_system:
|
||||
path: ../../../../../design_system
|
||||
core_localization:
|
||||
path: ../../../../../core_localization
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^2.0.0
|
||||
@@ -9,6 +9,7 @@ import 'package:staff_bank_account/staff_bank_account.dart';
|
||||
import 'package:staff_tax_forms/staff_tax_forms.dart';
|
||||
import 'package:staff_documents/staff_documents.dart';
|
||||
import 'package:staff_certificates/staff_certificates.dart';
|
||||
import 'package:staff_attire/staff_attire.dart';
|
||||
|
||||
import 'package:staff_main/src/presentation/blocs/staff_main_cubit.dart';
|
||||
import 'package:staff_main/src/presentation/constants/staff_main_routes.dart';
|
||||
@@ -55,6 +56,7 @@ class StaffMainModule extends Module {
|
||||
r.module('/onboarding', module: StaffProfileInfoModule());
|
||||
r.module('/emergency-contact', module: StaffEmergencyContactModule());
|
||||
r.module('/experience', module: StaffProfileExperienceModule());
|
||||
r.module('/attire', module: StaffAttireModule());
|
||||
r.module('/bank-account', module: StaffBankAccountModule());
|
||||
r.module('/tax-forms', module: StaffTaxFormsModule());
|
||||
r.module(
|
||||
|
||||
@@ -41,6 +41,8 @@ dependencies:
|
||||
path: ../profile_sections/compliance/documents
|
||||
staff_certificates:
|
||||
path: ../profile_sections/compliance/certificates
|
||||
staff_attire:
|
||||
path: ../profile_sections/onboarding/attire
|
||||
# staff_shifts:
|
||||
# path: ../shifts
|
||||
# staff_payments:
|
||||
|
||||
@@ -1072,6 +1072,13 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
staff_attire:
|
||||
dependency: transitive
|
||||
description:
|
||||
path: "packages/features/staff/profile_sections/onboarding/attire"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
staff_bank_account:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
Reference in New Issue
Block a user