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:
Achintha Isuru
2026-01-25 12:23:28 -05:00
parent 13cacb9db7
commit 533a545da7
27 changed files with 1015 additions and 1 deletions

View File

@@ -640,5 +640,28 @@
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Remove" "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"
}
} }
} }

View File

@@ -639,5 +639,27 @@
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Remove" "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"
}
} }
} }

View File

@@ -49,6 +49,7 @@ export 'src/entities/financial/staff_payment.dart';
// Profile // Profile
export 'src/entities/profile/staff_document.dart'; export 'src/entities/profile/staff_document.dart';
export 'src/entities/profile/attire_item.dart';
// Ratings & Penalties // Ratings & Penalties
export 'src/entities/ratings/staff_rating.dart'; export 'src/entities/ratings/staff_rating.dart';

View File

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

View File

@@ -23,7 +23,7 @@ extension ProfileNavigator on IModularNavigator {
/// Navigates to the attire page. /// Navigates to the attire page.
void pushAttire() { void pushAttire() {
pushNamed('../onboarding/attire'); pushNamed('../attire');
} }
/// Navigates to the documents page. /// Navigates to the documents page.

View File

@@ -0,0 +1 @@
include: ../../../../../../analysis_options.yaml

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
),
],
);
},
),
),
);
}
}

View File

@@ -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),
),
),
],
),
);
}
}

View File

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

View File

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

View File

@@ -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,
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,3 @@
library staff_attire;
export 'src/attire_module.dart';

View File

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

View File

@@ -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_tax_forms/staff_tax_forms.dart';
import 'package:staff_documents/staff_documents.dart'; import 'package:staff_documents/staff_documents.dart';
import 'package:staff_certificates/staff_certificates.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/blocs/staff_main_cubit.dart';
import 'package:staff_main/src/presentation/constants/staff_main_routes.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('/onboarding', module: StaffProfileInfoModule());
r.module('/emergency-contact', module: StaffEmergencyContactModule()); r.module('/emergency-contact', module: StaffEmergencyContactModule());
r.module('/experience', module: StaffProfileExperienceModule()); r.module('/experience', module: StaffProfileExperienceModule());
r.module('/attire', module: StaffAttireModule());
r.module('/bank-account', module: StaffBankAccountModule()); r.module('/bank-account', module: StaffBankAccountModule());
r.module('/tax-forms', module: StaffTaxFormsModule()); r.module('/tax-forms', module: StaffTaxFormsModule());
r.module( r.module(

View File

@@ -41,6 +41,8 @@ dependencies:
path: ../profile_sections/compliance/documents path: ../profile_sections/compliance/documents
staff_certificates: staff_certificates:
path: ../profile_sections/compliance/certificates path: ../profile_sections/compliance/certificates
staff_attire:
path: ../profile_sections/onboarding/attire
# staff_shifts: # staff_shifts:
# path: ../shifts # path: ../shifts
# staff_payments: # staff_payments:

View File

@@ -1072,6 +1072,13 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.12.1" 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: staff_bank_account:
dependency: transitive dependency: transitive
description: description: