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 d7961da9..1f529e83 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 @@ -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" + } } } + 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 ee71168b..6023c314 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 @@ -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" + } } } diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index f9f4c332..8028872a 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -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'; diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart b/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart new file mode 100644 index 00000000..97cd9df6 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.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 get props => [id, label, iconName, imageUrl, isMandatory]; +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/navigation/profile_navigator.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/navigation/profile_navigator.dart index 0a931581..13285adc 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/navigation/profile_navigator.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/navigation/profile_navigator.dart @@ -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. diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/analysis_options.yaml b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/analysis_options.yaml new file mode 100644 index 00000000..81e71ce5 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../../../../../analysis_options.yaml diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart new file mode 100644 index 00000000..3302ae28 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart @@ -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( + () => 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()); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart new file mode 100644 index 00000000..aec7ee03 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart @@ -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> getAttireOptions() async { + final QueryResult 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 saveAttire({ + required List selectedItemIds, + required Map photoUrls, + }) async { + // TODO: Connect to actual backend mutation when available. + // For now, simulate network delay as per prototype behavior. + await Future.delayed(const Duration(seconds: 1)); + } + + @override + Future uploadPhoto(String itemId) async { + // TODO: Connect to actual storage service/mutation when available. + // For now, simulate upload delay and return mock URL. + await Future.delayed(const Duration(seconds: 1)); + return 'mock_url_for_$itemId'; + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/save_attire_arguments.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/save_attire_arguments.dart new file mode 100644 index 00000000..5894e163 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/save_attire_arguments.dart @@ -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 selectedItemIds; + + /// Map of item IDs to uploaded photo URLs. + final Map photoUrls; + + /// Creates a [SaveAttireArguments]. + const SaveAttireArguments({ + required this.selectedItemIds, + required this.photoUrls, + }); + + @override + List get props => [selectedItemIds, photoUrls]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/upload_attire_photo_arguments.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/upload_attire_photo_arguments.dart new file mode 100644 index 00000000..14ea832d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/upload_attire_photo_arguments.dart @@ -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 get props => [itemId]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart new file mode 100644 index 00000000..1b4742ad --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart @@ -0,0 +1,15 @@ +import 'package:krow_domain/krow_domain.dart'; + +abstract interface class AttireRepository { + /// Fetches the list of available attire options. + Future> getAttireOptions(); + + /// Simulates uploading a photo for a specific attire item. + Future uploadPhoto(String itemId); + + /// Saves the user's attire selection and attestations. + Future saveAttire({ + required List selectedItemIds, + required Map photoUrls, + }); +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/get_attire_options_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/get_attire_options_usecase.dart new file mode 100644 index 00000000..9d8490d3 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/get_attire_options_usecase.dart @@ -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> { + final AttireRepository _repository; + + /// Creates a [GetAttireOptionsUseCase]. + GetAttireOptionsUseCase(this._repository); + + @override + Future> call() { + return _repository.getAttireOptions(); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/save_attire_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/save_attire_usecase.dart new file mode 100644 index 00000000..e8adb221 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/save_attire_usecase.dart @@ -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 { + final AttireRepository _repository; + + /// Creates a [SaveAttireUseCase]. + SaveAttireUseCase(this._repository); + + @override + Future call(SaveAttireArguments arguments) { + return _repository.saveAttire( + selectedItemIds: arguments.selectedItemIds, + photoUrls: arguments.photoUrls, + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart new file mode 100644 index 00000000..2b5f6698 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart @@ -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 { + final AttireRepository _repository; + + /// Creates a [UploadAttirePhotoUseCase]. + UploadAttirePhotoUseCase(this._repository); + + @override + Future call(UploadAttirePhotoArguments arguments) { + return _repository.uploadPhoto(arguments.itemId); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_cubit.dart new file mode 100644 index 00000000..76ae73c9 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_cubit.dart @@ -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 { + final GetAttireOptionsUseCase _getAttireOptionsUseCase; + final SaveAttireUseCase _saveAttireUseCase; + final UploadAttirePhotoUseCase _uploadAttirePhotoUseCase; + + AttireCubit( + this._getAttireOptionsUseCase, + this._saveAttireUseCase, + this._uploadAttirePhotoUseCase, + ) : super(const AttireState()) { + loadOptions(); + } + + Future loadOptions() async { + emit(state.copyWith(status: AttireStatus.loading)); + try { + final List options = await _getAttireOptionsUseCase(); + + // Auto-select mandatory items initially as per prototype + final List mandatoryIds = options + .where((AttireItem e) => e.isMandatory) + .map((AttireItem e) => e.id) + .toList(); + + final List initialSelection = List.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 currentSelection = List.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 uploadPhoto(String itemId) async { + final Map currentUploading = Map.from(state.uploadingStatus); + currentUploading[itemId] = true; + emit(state.copyWith(uploadingStatus: currentUploading)); + + try { + final String url = await _uploadAttirePhotoUseCase( + UploadAttirePhotoArguments(itemId: itemId), + ); + + final Map currentPhotos = Map.from(state.photoUrls); + currentPhotos[itemId] = url; + + // Auto-select item on upload success if not selected + final List currentSelection = List.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 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())); + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_state.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_state.dart new file mode 100644 index 00000000..aba87810 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_state.dart @@ -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 options; + final List selectedIds; + final Map photoUrls; + final Map uploadingStatus; + final bool attestationChecked; + final String? errorMessage; + + const AttireState({ + this.status = AttireStatus.initial, + this.options = const [], + this.selectedIds = const [], + this.photoUrls = const {}, + this.uploadingStatus = const {}, + 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 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 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? options, + List? selectedIds, + Map? photoUrls, + Map? 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 get props => [ + status, + options, + selectedIds, + photoUrls, + uploadingStatus, + attestationChecked, + errorMessage + ]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/navigation/attire_navigator.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/navigation/attire_navigator.dart new file mode 100644 index 00000000..77c58df6 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/navigation/attire_navigator.dart @@ -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(); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart new file mode 100644 index 00000000..776f5f77 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart @@ -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(); + + return BlocProvider.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( + 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: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + children: [ + 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, + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attestation_checkbox.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attestation_checkbox.dart new file mode 100644 index 00000000..b7a1b7c8 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attestation_checkbox.dart @@ -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 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: [ + 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), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_bottom_bar.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_bottom_bar.dart new file mode 100644 index 00000000..54b2fa4f --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_bottom_bar.dart @@ -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: [ + if (!canSave) + Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: Column( + children: [ + 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), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_grid.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_grid.dart new file mode 100644 index 00000000..4acb68c6 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_grid.dart @@ -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 items; + final List selectedIds; + final Map photoUrls; + final Map 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: [ + 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: [ + GestureDetector( + onTap: () => onToggle(item.id), + child: Column( + children: [ + 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: [ + if (isUploading) + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(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; + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_info_card.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_info_card.dart new file mode 100644 index 00000000..656d1626 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_info_card.dart @@ -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: [ + // 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: [ + 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, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/staff_attire.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/staff_attire.dart new file mode 100644 index 00000000..c63a8cbe --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/staff_attire.dart @@ -0,0 +1,3 @@ +library staff_attire; + +export 'src/attire_module.dart'; diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml new file mode 100644 index 00000000..b87789a7 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml @@ -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 diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart index aaf9bf79..33ed2fe5 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart @@ -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( diff --git a/apps/mobile/packages/features/staff/staff_main/pubspec.yaml b/apps/mobile/packages/features/staff/staff_main/pubspec.yaml index 4356137f..2d6f04a3 100644 --- a/apps/mobile/packages/features/staff/staff_main/pubspec.yaml +++ b/apps/mobile/packages/features/staff/staff_main/pubspec.yaml @@ -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: diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index bf1c76f4..18fc5b20 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -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: