feat: Migrate staff profile features from Data Connect to V2 REST API

- Removed data_connect package from mobile pubspec.yaml.
- Added documentation for V2 profile migration status and QA findings.
- Implemented new session management with ClientSessionStore and StaffSessionStore.
- Created V2SessionService for handling user sessions via the V2 API.
- Developed use cases for cancelling late worker assignments and submitting worker reviews.
- Added arguments and use cases for payment chart retrieval and profile completion checks.
- Implemented repository interfaces and their implementations for staff main and profile features.
- Ensured proper error handling and validation in use cases.
This commit is contained in:
Achintha Isuru
2026-03-16 22:45:06 -04:00
parent 4834266986
commit b31a615092
478 changed files with 10512 additions and 19854 deletions

View File

@@ -13,16 +13,19 @@ import 'domain/usecases/upload_attire_photo_usecase.dart';
import 'presentation/pages/attire_capture_page.dart';
import 'presentation/pages/attire_page.dart';
/// Module for the Staff Attire feature.
///
/// Uses the V2 REST API via [BaseApiService] for backend access.
class StaffAttireModule extends Module {
@override
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
/// third party services
/// Third party services.
i.addLazySingleton<ImagePicker>(ImagePicker.new);
/// local services
/// Local services.
i.addLazySingleton<CameraService>(
() => CameraService(i.get<ImagePicker>()),
);
@@ -30,6 +33,7 @@ class StaffAttireModule extends Module {
// Repository
i.addLazySingleton<AttireRepository>(
() => AttireRepositoryImpl(
apiService: i.get<BaseApiService>(),
uploadService: i.get<FileUploadService>(),
signedUrlService: i.get<SignedUrlService>(),
verificationService: i.get<VerificationService>(),
@@ -55,7 +59,7 @@ class StaffAttireModule extends Module {
r.child(
StaffPaths.childRoute(StaffPaths.attire, StaffPaths.attireCapture),
child: (_) => AttireCapturePage(
item: r.args.data['item'] as AttireItem,
item: r.args.data['item'] as AttireChecklist,
initialPhotoUrl: r.args.data['initialPhotoUrl'] as String?,
),
);

View File

@@ -1,36 +1,38 @@
import 'package:flutter/foundation.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart'
hide AttireVerificationStatus;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/attire_repository.dart';
import 'package:staff_attire/src/domain/repositories/attire_repository.dart';
/// Implementation of [AttireRepository].
/// Implementation of [AttireRepository] using the V2 API for reads
/// and core services for uploads.
///
/// Delegates data access to [StaffConnectorRepository].
/// Replaces the previous Firebase Data Connect / StaffConnectorRepository.
class AttireRepositoryImpl implements AttireRepository {
/// Creates an [AttireRepositoryImpl].
AttireRepositoryImpl({
required BaseApiService apiService,
required FileUploadService uploadService,
required SignedUrlService signedUrlService,
required VerificationService verificationService,
StaffConnectorRepository? connector,
}) : _connector =
connector ?? DataConnectService.instance.getStaffRepository(),
_uploadService = uploadService,
_signedUrlService = signedUrlService,
_verificationService = verificationService;
}) : _api = apiService,
_uploadService = uploadService,
_signedUrlService = signedUrlService,
_verificationService = verificationService;
/// The Staff Connector repository.
final StaffConnectorRepository _connector;
final BaseApiService _api;
final FileUploadService _uploadService;
final SignedUrlService _signedUrlService;
final VerificationService _verificationService;
@override
Future<List<AttireItem>> getAttireOptions() async {
return _connector.getAttireOptions();
Future<List<AttireChecklist>> getAttireOptions() async {
final ApiResponse response = await _api.get(V2ApiEndpoints.staffAttire);
final List<dynamic> items = response.data['items'] as List<dynamic>;
return items
.map((dynamic json) =>
AttireChecklist.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
@@ -38,13 +40,11 @@ class AttireRepositoryImpl implements AttireRepository {
required List<String> selectedItemIds,
required Map<String, String> photoUrls,
}) async {
// We already upsert photos in uploadPhoto (to follow the new flow).
// This could save selections if there was a separate "SelectedAttire" table.
// For now, it's a no-op as the source of truth is the StaffAttire table.
// Attire selection is saved per-item via uploadPhoto; this is a no-op.
}
@override
Future<AttireItem> uploadPhoto(String itemId, String filePath) async {
Future<AttireChecklist> uploadPhoto(String itemId, String filePath) async {
// 1. Upload file to Core API
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
filePath: filePath,
@@ -53,41 +53,40 @@ class AttireRepositoryImpl implements AttireRepository {
final String fileUri = uploadRes.fileUri;
// 2. Create signed URL for the uploaded file
final SignedUrlResponse signedUrlRes = await _signedUrlService
.createSignedUrl(fileUri: fileUri);
// 2. Create signed URL
final SignedUrlResponse signedUrlRes =
await _signedUrlService.createSignedUrl(fileUri: fileUri);
final String photoUrl = signedUrlRes.signedUrl;
// 3. Initiate verification job
final Staff staff = await _connector.getStaffProfile();
// Get item details for verification rules
final List<AttireItem> options = await _connector.getAttireOptions();
final AttireItem targetItem = options.firstWhere(
(AttireItem e) => e.id == itemId,
final List<AttireChecklist> options = await getAttireOptions();
final AttireChecklist targetItem = options.firstWhere(
(AttireChecklist e) => e.documentId == itemId,
orElse: () => throw UnknownException(
technicalMessage: 'Attire item $itemId not found in checklist',
),
);
final String dressCode =
'${targetItem.description ?? ''} ${targetItem.label}'.trim();
'${targetItem.description} ${targetItem.name}'.trim();
final VerificationResponse verifyRes = await _verificationService
.createVerification(
type: 'attire',
subjectType: 'worker',
subjectId: staff.id,
fileUri: fileUri,
rules: <String, dynamic>{'dressCode': dressCode},
);
final String verificationId = verifyRes.verificationId;
final VerificationResponse verifyRes =
await _verificationService.createVerification(
type: 'attire',
subjectType: 'worker',
subjectId: itemId,
fileUri: fileUri,
rules: <String, dynamic>{'dressCode': dressCode},
);
// 4. Poll for status until finished or timeout (max 3 seconds)
VerificationStatus currentStatus = verifyRes.status;
// 4. Poll for status until it's finished or timeout (max 10 seconds)
try {
int attempts = 0;
bool isFinished = false;
while (!isFinished && attempts < 5) {
await Future<void>.delayed(const Duration(seconds: 2));
final VerificationResponse statusRes = await _verificationService
.getStatus(verificationId);
while (!isFinished && attempts < 3) {
await Future<void>.delayed(const Duration(seconds: 1));
final VerificationResponse statusRes =
await _verificationService.getStatus(verifyRes.verificationId);
currentStatus = statusRes.status;
if (currentStatus != VerificationStatus.pending &&
currentStatus != VerificationStatus.processing) {
@@ -97,40 +96,24 @@ class AttireRepositoryImpl implements AttireRepository {
}
} catch (e) {
debugPrint('Polling failed or timed out: $e');
// Continue anyway, as we have the verificationId
}
// 5. Update Data Connect
await _connector.upsertStaffAttire(
attireOptionId: itemId,
photoUrl: photoUrl,
verificationId: verificationId,
verificationStatus: _mapToAttireStatus(currentStatus),
// 5. Update attire item via V2 API
await _api.put(
V2ApiEndpoints.staffAttireUpload(itemId),
data: <String, dynamic>{
'photoUrl': photoUrl,
'verificationId': verifyRes.verificationId,
},
);
// 6. Return updated AttireItem by re-fetching to get the PENDING/SUCCESS status
final List<AttireItem> finalOptions = await _connector.getAttireOptions();
return finalOptions.firstWhere((AttireItem e) => e.id == itemId);
}
AttireVerificationStatus _mapToAttireStatus(VerificationStatus status) {
switch (status) {
case VerificationStatus.pending:
return AttireVerificationStatus.pending;
case VerificationStatus.processing:
return AttireVerificationStatus.processing;
case VerificationStatus.autoPass:
return AttireVerificationStatus.autoPass;
case VerificationStatus.autoFail:
return AttireVerificationStatus.autoFail;
case VerificationStatus.needsReview:
return AttireVerificationStatus.needsReview;
case VerificationStatus.approved:
return AttireVerificationStatus.approved;
case VerificationStatus.rejected:
return AttireVerificationStatus.rejected;
case VerificationStatus.error:
return AttireVerificationStatus.error;
}
// 6. Return updated item by re-fetching
final List<AttireChecklist> finalOptions = await getAttireOptions();
return finalOptions.firstWhere(
(AttireChecklist e) => e.documentId == itemId,
orElse: () => throw UnknownException(
technicalMessage: 'Attire item $itemId not found after upload',
),
);
}
}

View File

@@ -1,11 +1,14 @@
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for attire operations.
///
/// Uses [AttireChecklist] from the V2 domain layer.
abstract interface class AttireRepository {
/// Fetches the list of available attire options.
Future<List<AttireItem>> getAttireOptions();
/// Fetches the list of available attire checklist items from the V2 API.
Future<List<AttireChecklist>> getAttireOptions();
/// Uploads a photo for a specific attire item.
Future<AttireItem> uploadPhoto(String itemId, String filePath);
Future<AttireChecklist> uploadPhoto(String itemId, String filePath);
/// Saves the user's attire selection and attestations.
Future<void> saveAttire({

View File

@@ -4,14 +4,14 @@ 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>> {
class GetAttireOptionsUseCase extends NoInputUseCase<List<AttireChecklist>> {
/// Creates a [GetAttireOptionsUseCase].
GetAttireOptionsUseCase(this._repository);
final AttireRepository _repository;
@override
Future<List<AttireItem>> call() {
Future<List<AttireChecklist>> call() {
return _repository.getAttireOptions();
}
}

View File

@@ -5,13 +5,13 @@ import '../repositories/attire_repository.dart';
/// Use case to upload a photo for an attire item.
class UploadAttirePhotoUseCase
extends UseCase<UploadAttirePhotoArguments, AttireItem> {
extends UseCase<UploadAttirePhotoArguments, AttireChecklist> {
/// Creates a [UploadAttirePhotoUseCase].
UploadAttirePhotoUseCase(this._repository);
final AttireRepository _repository;
@override
Future<AttireItem> call(UploadAttirePhotoArguments arguments) {
Future<AttireChecklist> call(UploadAttirePhotoArguments arguments) {
return _repository.uploadPhoto(arguments.itemId, arguments.filePath);
}
}

View File

@@ -21,19 +21,19 @@ class AttireCubit extends Cubit<AttireState>
await handleError(
emit: emit,
action: () async {
final List<AttireItem> options = await _getAttireOptionsUseCase();
final List<AttireChecklist> options = await _getAttireOptionsUseCase();
// Extract photo URLs and selection status from backend data
final Map<String, String> photoUrls = <String, String>{};
final List<String> selectedIds = <String>[];
for (final AttireItem item in options) {
if (item.photoUrl != null) {
photoUrls[item.id] = item.photoUrl!;
for (final AttireChecklist item in options) {
if (item.photoUri != null) {
photoUrls[item.documentId] = item.photoUri!;
}
// If mandatory or has photo, consider it selected initially
if (item.isMandatory || item.photoUrl != null) {
selectedIds.add(item.id);
if (item.mandatory || item.photoUri != null) {
selectedIds.add(item.documentId);
}
}
@@ -68,18 +68,18 @@ class AttireCubit extends Cubit<AttireState>
emit(state.copyWith(filter: filter));
}
void syncCapturedPhoto(AttireItem item) {
void syncCapturedPhoto(AttireChecklist item) {
// Update the options list with the new item data
final List<AttireItem> updatedOptions = state.options
.map((AttireItem e) => e.id == item.id ? item : e)
final List<AttireChecklist> updatedOptions = state.options
.map((AttireChecklist e) => e.documentId == item.documentId ? item : e)
.toList();
// Update the photo URLs map
final Map<String, String> updatedPhotos = Map<String, String>.from(
state.photoUrls,
);
if (item.photoUrl != null) {
updatedPhotos[item.id] = item.photoUrl!;
if (item.photoUri != null) {
updatedPhotos[item.documentId] = item.photoUri!;
}
emit(state.copyWith(options: updatedOptions, photoUrls: updatedPhotos));

View File

@@ -6,14 +6,14 @@ enum AttireStatus { initial, loading, success, failure, saving, saved }
class AttireState extends Equatable {
const AttireState({
this.status = AttireStatus.initial,
this.options = const <AttireItem>[],
this.options = const <AttireChecklist>[],
this.selectedIds = const <String>[],
this.photoUrls = const <String, String>{},
this.filter = 'All',
this.errorMessage,
});
final AttireStatus status;
final List<AttireItem> options;
final List<AttireChecklist> options;
final List<String> selectedIds;
final Map<String, String> photoUrls;
final String filter;
@@ -23,40 +23,44 @@ class AttireState extends Equatable {
bool isMandatory(String id) {
return options
.firstWhere(
(AttireItem e) => e.id == id,
orElse: () => const AttireItem(id: '', code: '', label: ''),
(AttireChecklist e) => e.documentId == id,
orElse: () => const AttireChecklist(
documentId: '',
name: '',
status: AttireItemStatus.notUploaded,
),
)
.isMandatory;
.mandatory;
}
/// Validation logic
bool get allMandatorySelected {
final Iterable<String> mandatoryIds = options
.where((AttireItem e) => e.isMandatory)
.map((AttireItem e) => e.id);
.where((AttireChecklist e) => e.mandatory)
.map((AttireChecklist e) => e.documentId);
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);
.where((AttireChecklist e) => e.mandatory)
.map((AttireChecklist e) => e.documentId);
return mandatoryIds.every((String id) => photoUrls.containsKey(id));
}
bool get canSave => allMandatorySelected && allMandatoryHavePhotos;
List<AttireItem> get filteredOptions {
return options.where((AttireItem item) {
if (filter == 'Required') return item.isMandatory;
if (filter == 'Non-Essential') return !item.isMandatory;
List<AttireChecklist> get filteredOptions {
return options.where((AttireChecklist item) {
if (filter == 'Required') return item.mandatory;
if (filter == 'Non-Essential') return !item.mandatory;
return true;
}).toList();
}
AttireState copyWith({
AttireStatus? status,
List<AttireItem>? options,
List<AttireChecklist>? options,
List<String>? selectedIds,
Map<String, String>? photoUrls,
String? filter,

View File

@@ -23,14 +23,14 @@ class AttireCaptureCubit extends Cubit<AttireCaptureState>
await handleError(
emit: emit,
action: () async {
final AttireItem item = await _uploadAttirePhotoUseCase(
final AttireChecklist item = await _uploadAttirePhotoUseCase(
UploadAttirePhotoArguments(itemId: itemId, filePath: filePath),
);
emit(
state.copyWith(
status: AttireCaptureStatus.success,
photoUrl: item.photoUrl,
photoUrl: item.photoUri,
updatedItem: item,
),
);

View File

@@ -15,14 +15,14 @@ class AttireCaptureState extends Equatable {
final AttireCaptureStatus status;
final bool isAttested;
final String? photoUrl;
final AttireItem? updatedItem;
final AttireChecklist? updatedItem;
final String? errorMessage;
AttireCaptureState copyWith({
AttireCaptureStatus? status,
bool? isAttested,
String? photoUrl,
AttireItem? updatedItem,
AttireChecklist? updatedItem,
String? errorMessage,
}) {
return AttireCaptureState(

View File

@@ -24,8 +24,8 @@ class AttireCapturePage extends StatefulWidget {
this.initialPhotoUrl,
});
/// The attire item being captured.
final AttireItem item;
/// The attire checklist item being captured.
final AttireChecklist item;
/// Optional initial photo URL if it was already uploaded.
final String? initialPhotoUrl;
@@ -48,7 +48,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
/// Whether the item is currently pending verification.
bool get _isPending =>
widget.item.verificationStatus == AttireVerificationStatus.pending;
widget.item.status == AttireItemStatus.pending;
/// On gallery button press
Future<void> _onGallery(BuildContext context) async {
@@ -206,7 +206,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
return;
}
await cubit.uploadPhoto(widget.item.id, _selectedLocalPath!);
await cubit.uploadPhoto(widget.item.documentId, _selectedLocalPath!);
if (context.mounted && cubit.state.status == AttireCaptureStatus.success) {
setState(() {
_selectedLocalPath = null;
@@ -215,12 +215,12 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
}
String _getStatusText(bool hasUploadedPhoto) {
return switch (widget.item.verificationStatus) {
AttireVerificationStatus.approved =>
return switch (widget.item.status) {
AttireItemStatus.verified =>
t.staff_profile_attire.capture.approved,
AttireVerificationStatus.rejected =>
AttireItemStatus.rejected =>
t.staff_profile_attire.capture.rejected,
AttireVerificationStatus.pending =>
AttireItemStatus.pending =>
t.staff_profile_attire.capture.pending_verification,
_ =>
hasUploadedPhoto
@@ -230,10 +230,10 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
}
Color _getStatusColor(bool hasUploadedPhoto) {
return switch (widget.item.verificationStatus) {
AttireVerificationStatus.approved => UiColors.textSuccess,
AttireVerificationStatus.rejected => UiColors.textError,
AttireVerificationStatus.pending => UiColors.textWarning,
return switch (widget.item.status) {
AttireItemStatus.verified => UiColors.textSuccess,
AttireItemStatus.rejected => UiColors.textError,
AttireItemStatus.pending => UiColors.textWarning,
_ => hasUploadedPhoto ? UiColors.textWarning : UiColors.textInactive,
};
}
@@ -250,7 +250,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
return Scaffold(
appBar: UiAppBar(
title: widget.item.label,
title: widget.item.name,
onLeadingPressed: () {
Modular.to.toAttire();
},
@@ -296,7 +296,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
ImagePreviewSection(
selectedLocalPath: _selectedLocalPath,
currentPhotoUrl: currentPhotoUrl,
referenceImageUrl: widget.item.imageUrl,
referenceImageUrl: null,
),
InfoSection(
description: widget.item.description,

View File

@@ -53,11 +53,11 @@ class _AttirePageState extends State<AttirePage> {
return const AttireSkeleton();
}
final List<AttireItem> requiredItems = state.options
.where((AttireItem item) => item.isMandatory)
final List<AttireChecklist> requiredItems = state.options
.where((AttireChecklist item) => item.mandatory)
.toList();
final List<AttireItem> nonEssentialItems = state.options
.where((AttireItem item) => !item.isMandatory)
final List<AttireChecklist> nonEssentialItems = state.options
.where((AttireChecklist item) => !item.mandatory)
.toList();
return Column(
@@ -109,7 +109,7 @@ class _AttirePageState extends State<AttirePage> {
.no_items_filter,
)
else
...requiredItems.map((AttireItem item) {
...requiredItems.map((AttireChecklist item) {
return Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space3,
@@ -117,11 +117,11 @@ class _AttirePageState extends State<AttirePage> {
child: AttireItemCard(
item: item,
isUploading: false,
uploadedPhotoUrl: state.photoUrls[item.id],
uploadedPhotoUrl: state.photoUrls[item.documentId],
onTap: () {
Modular.to.toAttireCapture(
item: item,
initialPhotoUrl: state.photoUrls[item.id],
initialPhotoUrl: state.photoUrls[item.documentId],
);
},
),
@@ -156,7 +156,7 @@ class _AttirePageState extends State<AttirePage> {
.no_items_filter,
)
else
...nonEssentialItems.map((AttireItem item) {
...nonEssentialItems.map((AttireChecklist item) {
return Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space3,
@@ -164,11 +164,11 @@ class _AttirePageState extends State<AttirePage> {
child: AttireItemCard(
item: item,
isUploading: false,
uploadedPhotoUrl: state.photoUrls[item.id],
uploadedPhotoUrl: state.photoUrls[item.documentId],
onTap: () {
Modular.to.toAttireCapture(
item: item,
initialPhotoUrl: state.photoUrls[item.id],
initialPhotoUrl: state.photoUrls[item.documentId],
);
},
),

View File

@@ -39,7 +39,7 @@ class FooterSection extends StatelessWidget {
final bool hasUploadedPhoto;
/// The updated attire item, if any.
final AttireItem? updatedItem;
final AttireChecklist? updatedItem;
/// Whether to show the attestation checkbox.
final bool showCheckbox;

View File

@@ -14,7 +14,7 @@ class AttireGrid extends StatelessWidget {
required this.onToggle,
required this.onUpload,
});
final List<AttireItem> items;
final List<AttireChecklist> items;
final List<String> selectedIds;
final Map<String, String> photoUrls;
final Map<String, bool> uploadingStatus;
@@ -34,10 +34,10 @@ class AttireGrid extends StatelessWidget {
),
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;
final AttireChecklist item = items[index];
final bool isSelected = selectedIds.contains(item.documentId);
final bool hasPhoto = photoUrls.containsKey(item.documentId);
final bool isUploading = uploadingStatus[item.documentId] ?? false;
return _buildCard(item, isSelected, hasPhoto, isUploading);
},
@@ -45,7 +45,7 @@ class AttireGrid extends StatelessWidget {
}
Widget _buildCard(
AttireItem item,
AttireChecklist item,
bool isSelected,
bool hasPhoto,
bool isUploading,
@@ -63,20 +63,19 @@ class AttireGrid extends StatelessWidget {
),
child: Stack(
children: <Widget>[
if (item.isMandatory)
if (item.mandatory)
Positioned(
top: UiConstants.space2,
left: UiConstants.space2,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: UiColors.destructive, // Red
color: UiColors.destructive,
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,
@@ -106,37 +105,23 @@ class AttireGrid extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
GestureDetector(
onTap: () => onToggle(item.id),
onTap: () => onToggle(item.documentId),
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,
),
),
)
: const Icon(
UiIcons.shirt,
size: 48,
color: UiColors.iconSecondary,
),
const Icon(
UiIcons.shirt,
size: 48,
color: UiColors.iconSecondary,
),
const SizedBox(height: UiConstants.space2),
Text(
item.label,
item.name,
textAlign: TextAlign.center,
style: UiTypography.body2m.textPrimary,
),
if (item.description != null)
if (item.description.isNotEmpty)
Text(
item.description!,
item.description,
textAlign: TextAlign.center,
style: UiTypography.body3r.textSecondary,
maxLines: 2,
@@ -147,7 +132,7 @@ class AttireGrid extends StatelessWidget {
),
const SizedBox(height: UiConstants.space3),
InkWell(
onTap: () => onUpload(item.id),
onTap: () => onUpload(item.documentId),
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
child: Container(
padding: const EdgeInsets.symmetric(
@@ -189,7 +174,7 @@ class AttireGrid extends StatelessWidget {
const Icon(
UiIcons.camera,
size: 12,
color: UiColors.textSecondary, // Was muted
color: UiColors.textSecondary,
),
const SizedBox(width: 6),
Text(

View File

@@ -11,18 +11,18 @@ class AttireItemCard extends StatelessWidget {
required this.onTap,
});
final AttireItem item;
final AttireChecklist item;
final String? uploadedPhotoUrl;
final bool isUploading;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final bool hasPhoto = item.photoUrl != null;
final String statusText = switch (item.verificationStatus) {
AttireVerificationStatus.approved => 'Approved',
AttireVerificationStatus.rejected => 'Rejected',
AttireVerificationStatus.pending => 'Pending',
final bool hasPhoto = item.photoUri != null;
final String statusText = switch (item.status) {
AttireItemStatus.verified => 'Approved',
AttireItemStatus.rejected => 'Rejected',
AttireItemStatus.pending => 'Pending',
_ => hasPhoto ? 'Pending' : 'To Do',
};
@@ -38,21 +38,29 @@ class AttireItemCard extends StatelessWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Image
// Image placeholder
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: UiColors.background,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
image: DecorationImage(
image: NetworkImage(
item.imageUrl ??
'https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400',
),
fit: BoxFit.cover,
),
image: hasPhoto
? DecorationImage(
image: NetworkImage(item.photoUri!),
fit: BoxFit.cover,
)
: null,
),
child: hasPhoto
? null
: const Center(
child: Icon(
UiIcons.camera,
color: UiColors.textSecondary,
size: 24,
),
),
),
const SizedBox(width: UiConstants.space4),
// details
@@ -60,10 +68,10 @@ class AttireItemCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(item.label, style: UiTypography.body1m.textPrimary),
if (item.description != null) ...<Widget>[
Text(item.name, style: UiTypography.body1m.textPrimary),
if (item.description.isNotEmpty) ...<Widget>[
Text(
item.description!,
item.description,
style: UiTypography.body2r.textSecondary,
maxLines: 2,
overflow: TextOverflow.ellipsis,
@@ -73,7 +81,7 @@ class AttireItemCard extends StatelessWidget {
Row(
spacing: UiConstants.space2,
children: <Widget>[
if (item.isMandatory)
if (item.mandatory)
const UiChip(
label: 'Required',
size: UiChipSize.xSmall,
@@ -90,8 +98,7 @@ class AttireItemCard extends StatelessWidget {
label: statusText,
size: UiChipSize.xSmall,
variant:
item.verificationStatus ==
AttireVerificationStatus.approved
item.status == AttireItemStatus.verified
? UiChipVariant.primary
: UiChipVariant.secondary,
),
@@ -114,12 +121,11 @@ class AttireItemCard extends StatelessWidget {
)
else if (hasPhoto && !isUploading)
Icon(
item.verificationStatus == AttireVerificationStatus.approved
item.status == AttireItemStatus.verified
? UiIcons.check
: UiIcons.clock,
color:
item.verificationStatus ==
AttireVerificationStatus.approved
item.status == AttireItemStatus.verified
? UiColors.textPrimary
: UiColors.textWarning,
size: 24,

View File

@@ -14,15 +14,12 @@ dependencies:
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:

View File

@@ -1,81 +1,38 @@
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/emergency_contact_repository_interface.dart';
/// Implementation of [EmergencyContactRepositoryInterface].
import 'package:staff_emergency_contact/src/domain/repositories/emergency_contact_repository_interface.dart';
/// Implementation of [EmergencyContactRepositoryInterface] using the V2 API.
///
/// This repository delegates data operations to Firebase Data Connect.
/// Replaces the previous Firebase Data Connect implementation.
class EmergencyContactRepositoryImpl
implements EmergencyContactRepositoryInterface {
final dc.DataConnectService _service;
/// Creates an [EmergencyContactRepositoryImpl].
EmergencyContactRepositoryImpl({
dc.DataConnectService? service,
}) : _service = service ?? dc.DataConnectService.instance;
EmergencyContactRepositoryImpl({required BaseApiService apiService})
: _api = apiService;
final BaseApiService _api;
@override
Future<List<EmergencyContact>> getContacts() async {
return _service.run(() async {
final staffId = await _service.getStaffId();
final result = await _service.connector
.getEmergencyContactsByStaffId(staffId: staffId)
.execute();
return result.data.emergencyContacts.map((dto) {
return EmergencyContactAdapter.fromPrimitives(
id: dto.id,
name: dto.name,
phone: dto.phone,
relationship: dto.relationship.stringValue,
);
}).toList();
});
final ApiResponse response =
await _api.get(V2ApiEndpoints.staffEmergencyContacts);
final List<dynamic> items = response.data['contacts'] as List<dynamic>;
return items
.map((dynamic json) =>
EmergencyContact.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<void> saveContacts(List<EmergencyContact> contacts) async {
return _service.run(() async {
final staffId = await _service.getStaffId();
// 1. Get existing to delete
final existingResult = await _service.connector
.getEmergencyContactsByStaffId(staffId: staffId)
.execute();
final existingIds =
existingResult.data.emergencyContacts.map((e) => e.id).toList();
// 2. Delete all existing
await Future.wait(existingIds.map(
(id) => _service.connector.deleteEmergencyContact(id: id).execute()));
// 3. Create new
await Future.wait(contacts.map((contact) {
dc.RelationshipType rel = dc.RelationshipType.OTHER;
switch (contact.relationship) {
case RelationshipType.family:
rel = dc.RelationshipType.FAMILY;
break;
case RelationshipType.spouse:
rel = dc.RelationshipType.SPOUSE;
break;
case RelationshipType.friend:
rel = dc.RelationshipType.FRIEND;
break;
case RelationshipType.other:
rel = dc.RelationshipType.OTHER;
break;
}
return _service.connector
.createEmergencyContact(
name: contact.name,
phone: contact.phone,
relationship: rel,
staffId: staffId,
)
.execute();
}));
});
await _api.put(
V2ApiEndpoints.staffEmergencyContacts,
data: <String, dynamic>{
'contacts':
contacts.map((EmergencyContact c) => c.toJson()).toList(),
},
);
}
}
}

View File

@@ -2,10 +2,10 @@ import 'package:krow_domain/krow_domain.dart';
/// Repository interface for managing emergency contacts.
///
/// This interface defines the contract for fetching and saving emergency contact information.
/// It must be implemented by the data layer.
/// Defines the contract for fetching and saving emergency contact information
/// via the V2 API.
abstract class EmergencyContactRepositoryInterface {
/// Retrieves the list of emergency contacts.
/// Retrieves the list of emergency contacts for the current staff member.
Future<List<EmergencyContact>> getContacts();
/// Saves the list of emergency contacts.

View File

@@ -28,9 +28,7 @@ class EmergencyContactState extends Equatable {
bool get isValid {
if (contacts.isEmpty) return false;
// Check if at least one contact is valid (or all?)
// Usually all added contacts should be valid.
return contacts.every((c) => c.name.isNotEmpty && c.phone.isNotEmpty);
return contacts.every((c) => c.fullName.isNotEmpty && c.phone.isNotEmpty);
}
@override

View File

@@ -4,6 +4,14 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/emergency_contact_bloc.dart';
/// Available relationship type values.
const List<String> _kRelationshipTypes = <String>[
'FAMILY',
'SPOUSE',
'FRIEND',
'OTHER',
];
class EmergencyContactFormItem extends StatelessWidget {
final int index;
final EmergencyContact contact;
@@ -33,11 +41,11 @@ class EmergencyContactFormItem extends StatelessWidget {
const SizedBox(height: UiConstants.space4),
_buildLabel('Full Name'),
_buildTextField(
initialValue: contact.name,
initialValue: contact.fullName,
hint: 'Contact name',
icon: UiIcons.user,
onChanged: (val) => context.read<EmergencyContactBloc>().add(
EmergencyContactUpdated(index, contact.copyWith(name: val)),
EmergencyContactUpdated(index, contact.copyWith(fullName: val)),
),
),
const SizedBox(height: UiConstants.space4),
@@ -54,14 +62,14 @@ class EmergencyContactFormItem extends StatelessWidget {
_buildLabel('Relationship'),
_buildDropdown(
context,
value: contact.relationship,
items: RelationshipType.values,
value: contact.relationshipType,
items: _kRelationshipTypes,
onChanged: (val) {
if (val != null) {
context.read<EmergencyContactBloc>().add(
EmergencyContactUpdated(
index,
contact.copyWith(relationship: val),
contact.copyWith(relationshipType: val),
),
);
}
@@ -74,9 +82,9 @@ class EmergencyContactFormItem extends StatelessWidget {
Widget _buildDropdown(
BuildContext context, {
required RelationshipType value,
required List<RelationshipType> items,
required ValueChanged<RelationshipType?> onChanged,
required String value,
required List<String> items,
required ValueChanged<String?> onChanged,
}) {
return Container(
padding: const EdgeInsets.symmetric(
@@ -89,13 +97,13 @@ class EmergencyContactFormItem extends StatelessWidget {
border: Border.all(color: UiColors.border),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<RelationshipType>(
value: value,
child: DropdownButton<String>(
value: items.contains(value) ? value : items.first,
isExpanded: true,
dropdownColor: UiColors.bgPopup,
icon: const Icon(UiIcons.chevronDown, color: UiColors.iconSecondary),
items: items.map((type) {
return DropdownMenuItem<RelationshipType>(
return DropdownMenuItem<String>(
value: type,
child: Text(
_formatRelationship(type),
@@ -109,16 +117,18 @@ class EmergencyContactFormItem extends StatelessWidget {
);
}
String _formatRelationship(RelationshipType type) {
String _formatRelationship(String type) {
switch (type) {
case RelationshipType.family:
case 'FAMILY':
return 'Family';
case RelationshipType.spouse:
case 'SPOUSE':
return 'Spouse';
case RelationshipType.friend:
case 'FRIEND':
return 'Friend';
case RelationshipType.other:
case 'OTHER':
return 'Other';
default:
return type;
}
}

View File

@@ -1,26 +1,38 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'data/repositories/emergency_contact_repository_impl.dart';
import 'domain/repositories/emergency_contact_repository_interface.dart';
import 'domain/usecases/get_emergency_contacts_usecase.dart';
import 'domain/usecases/save_emergency_contacts_usecase.dart';
import 'presentation/blocs/emergency_contact_bloc.dart';
import 'presentation/pages/emergency_contact_screen.dart';
import 'package:staff_emergency_contact/src/data/repositories/emergency_contact_repository_impl.dart';
import 'package:staff_emergency_contact/src/domain/repositories/emergency_contact_repository_interface.dart';
import 'package:staff_emergency_contact/src/domain/usecases/get_emergency_contacts_usecase.dart';
import 'package:staff_emergency_contact/src/domain/usecases/save_emergency_contacts_usecase.dart';
import 'package:staff_emergency_contact/src/presentation/blocs/emergency_contact_bloc.dart';
import 'package:staff_emergency_contact/src/presentation/pages/emergency_contact_screen.dart';
/// Module for the Staff Emergency Contact feature.
///
/// Uses the V2 REST API via [BaseApiService] for backend access.
class StaffEmergencyContactModule extends Module {
@override
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repository
i.addLazySingleton<EmergencyContactRepositoryInterface>(
EmergencyContactRepositoryImpl.new,
() => EmergencyContactRepositoryImpl(
apiService: i.get<BaseApiService>(),
),
);
// UseCases
i.addLazySingleton<GetEmergencyContactsUseCase>(
() => GetEmergencyContactsUseCase(i.get<EmergencyContactRepositoryInterface>()),
() => GetEmergencyContactsUseCase(
i.get<EmergencyContactRepositoryInterface>()),
);
i.addLazySingleton<SaveEmergencyContactsUseCase>(
() => SaveEmergencyContactsUseCase(i.get<EmergencyContactRepositoryInterface>()),
() => SaveEmergencyContactsUseCase(
i.get<EmergencyContactRepositoryInterface>()),
);
// BLoC

View File

@@ -14,14 +14,12 @@ dependencies:
flutter_bloc: ^8.1.0
flutter_modular: ^6.3.0
equatable: ^2.0.5
# Architecture Packages
krow_domain:
path: ../../../../../domain
krow_core:
path: ../../../../../core
krow_data_connect:
path: ../../../../../data_connect
design_system:
path: ../../../../../design_system
core_localization:

View File

@@ -1,42 +1,31 @@
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/experience_repository_interface.dart';
import 'package:staff_profile_experience/src/domain/repositories/experience_repository_interface.dart';
/// Implementation of [ExperienceRepositoryInterface] that delegates to Data Connect.
/// Implementation of [ExperienceRepositoryInterface] using the V2 API.
///
/// Replaces the previous Firebase Data Connect implementation.
class ExperienceRepositoryImpl implements ExperienceRepositoryInterface {
final dc.DataConnectService _service;
/// Creates an [ExperienceRepositoryImpl].
ExperienceRepositoryImpl({required BaseApiService apiService})
: _api = apiService;
/// Creates a [ExperienceRepositoryImpl] using Data Connect Service.
ExperienceRepositoryImpl({
dc.DataConnectService? service,
}) : _service = service ?? dc.DataConnectService.instance;
Future<dc.GetStaffByIdStaff> _getStaff() async {
final staffId = await _service.getStaffId();
final result =
await _service.connector.getStaffById(id: staffId).execute();
if (result.data.staff == null) {
throw const ServerException(technicalMessage: 'Staff profile not found');
}
return result.data.staff!;
}
final BaseApiService _api;
@override
Future<List<String>> getIndustries() async {
return _service.run(() async {
final staff = await _getStaff();
return staff.industries ?? [];
});
final ApiResponse response =
await _api.get(V2ApiEndpoints.staffIndustries);
final List<dynamic> items = response.data['industries'] as List<dynamic>;
return items.map((dynamic e) => e.toString()).toList();
}
@override
Future<List<String>> getSkills() async {
return _service.run(() async {
final staff = await _getStaff();
return staff.skills ?? [];
});
final ApiResponse response = await _api.get(V2ApiEndpoints.staffSkills);
final List<dynamic> items = response.data['skills'] as List<dynamic>;
return items.map((dynamic e) => e.toString()).toList();
}
@override
@@ -44,13 +33,12 @@ class ExperienceRepositoryImpl implements ExperienceRepositoryInterface {
List<String> industries,
List<String> skills,
) async {
return _service.run(() async {
final staff = await _getStaff();
await _service.connector
.updateStaff(id: staff.id)
.industries(industries)
.skills(skills)
.execute();
});
await _api.put(
V2ApiEndpoints.staffPersonalInfo,
data: <String, dynamic>{
'industries': industries,
'skills': skills,
},
);
}
}

View File

@@ -1,7 +1,6 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/arguments/save_experience_arguments.dart';
import '../../domain/usecases/get_staff_industries_usecase.dart';
import '../../domain/usecases/get_staff_skills_usecase.dart';
@@ -18,7 +17,7 @@ abstract class ExperienceEvent extends Equatable {
class ExperienceLoaded extends ExperienceEvent {}
class ExperienceIndustryToggled extends ExperienceEvent {
final Industry industry;
final String industry;
const ExperienceIndustryToggled(this.industry);
@override
@@ -48,10 +47,10 @@ enum ExperienceStatus { initial, loading, success, failure }
class ExperienceState extends Equatable {
final ExperienceStatus status;
final List<Industry> selectedIndustries;
final List<String> selectedIndustries;
final List<String> selectedSkills;
final List<Industry> availableIndustries;
final List<ExperienceSkill> availableSkills;
final List<String> availableIndustries;
final List<String> availableSkills;
final String? errorMessage;
const ExperienceState({
@@ -65,10 +64,10 @@ class ExperienceState extends Equatable {
ExperienceState copyWith({
ExperienceStatus? status,
List<Industry>? selectedIndustries,
List<String>? selectedIndustries,
List<String>? selectedSkills,
List<Industry>? availableIndustries,
List<ExperienceSkill>? availableSkills,
List<String>? availableIndustries,
List<String>? availableSkills,
String? errorMessage,
}) {
return ExperienceState(
@@ -92,6 +91,37 @@ class ExperienceState extends Equatable {
];
}
/// Available industry option values.
const List<String> _kAvailableIndustries = <String>[
'hospitality',
'food_service',
'warehouse',
'events',
'retail',
'healthcare',
'other',
];
/// Available skill option values.
const List<String> _kAvailableSkills = <String>[
'food_service',
'bartending',
'event_setup',
'hospitality',
'warehouse',
'customer_service',
'cleaning',
'security',
'retail',
'driving',
'cooking',
'cashier',
'server',
'barista',
'host_hostess',
'busser',
];
// BLoC
class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
with BlocErrorHandler<ExperienceState> {
@@ -105,8 +135,8 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
required this.saveExperience,
}) : super(
const ExperienceState(
availableIndustries: Industry.values,
availableSkills: ExperienceSkill.values,
availableIndustries: _kAvailableIndustries,
availableSkills: _kAvailableSkills,
),
) {
on<ExperienceLoaded>(_onLoaded);
@@ -131,11 +161,7 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
emit(
state.copyWith(
status: ExperienceStatus.initial,
selectedIndustries:
results[0]
.map((e) => Industry.fromString(e))
.whereType<Industry>()
.toList(),
selectedIndustries: results[0],
selectedSkills: results[1],
),
);
@@ -151,7 +177,7 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
ExperienceIndustryToggled event,
Emitter<ExperienceState> emit,
) {
final industries = List<Industry>.from(state.selectedIndustries);
final industries = List<String>.from(state.selectedIndustries);
if (industries.contains(event.industry)) {
industries.remove(event.industry);
} else {
@@ -193,7 +219,7 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
action: () async {
await saveExperience(
SaveExperienceArguments(
industries: state.selectedIndustries.map((e) => e.value).toList(),
industries: state.selectedIndustries,
skills: state.selectedSkills,
),
);
@@ -206,4 +232,3 @@ class ExperienceBloc extends Bloc<ExperienceEvent, ExperienceState>
);
}
}

View File

@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/experience_bloc.dart';
import '../widgets/experience_section_title.dart';
@@ -12,59 +11,63 @@ import '../widgets/experience_section_title.dart';
class ExperiencePage extends StatelessWidget {
const ExperiencePage({super.key});
String _getIndustryLabel(dynamic node, Industry industry) {
String _getIndustryLabel(dynamic node, String industry) {
switch (industry) {
case Industry.hospitality:
case 'hospitality':
return node.hospitality;
case Industry.foodService:
case 'food_service':
return node.food_service;
case Industry.warehouse:
case 'warehouse':
return node.warehouse;
case Industry.events:
case 'events':
return node.events;
case Industry.retail:
case 'retail':
return node.retail;
case Industry.healthcare:
case 'healthcare':
return node.healthcare;
case Industry.other:
case 'other':
return node.other;
default:
return industry;
}
}
String _getSkillLabel(dynamic node, ExperienceSkill skill) {
String _getSkillLabel(dynamic node, String skill) {
switch (skill) {
case ExperienceSkill.foodService:
case 'food_service':
return node.food_service;
case ExperienceSkill.bartending:
case 'bartending':
return node.bartending;
case ExperienceSkill.eventSetup:
case 'event_setup':
return node.event_setup;
case ExperienceSkill.hospitality:
case 'hospitality':
return node.hospitality;
case ExperienceSkill.warehouse:
case 'warehouse':
return node.warehouse;
case ExperienceSkill.customerService:
case 'customer_service':
return node.customer_service;
case ExperienceSkill.cleaning:
case 'cleaning':
return node.cleaning;
case ExperienceSkill.security:
case 'security':
return node.security;
case ExperienceSkill.retail:
case 'retail':
return node.retail;
case ExperienceSkill.driving:
case 'driving':
return node.driving;
case ExperienceSkill.cooking:
case 'cooking':
return node.cooking;
case ExperienceSkill.cashier:
case 'cashier':
return node.cashier;
case ExperienceSkill.server:
case 'server':
return node.server;
case ExperienceSkill.barista:
case 'barista':
return node.barista;
case ExperienceSkill.hostHostess:
case 'host_hostess':
return node.host_hostess;
case ExperienceSkill.busser:
case 'busser':
return node.busser;
default:
return skill;
}
}
@@ -154,14 +157,12 @@ class ExperiencePage extends StatelessWidget {
.map(
(s) => UiChip(
label: _getSkillLabel(i18n.skills, s),
isSelected: state.selectedSkills.contains(
s.value,
),
isSelected: state.selectedSkills.contains(s),
onTap: () => BlocProvider.of<ExperienceBloc>(
context,
).add(ExperienceSkillToggled(s.value)),
).add(ExperienceSkillToggled(s)),
variant:
state.selectedSkills.contains(s.value)
state.selectedSkills.contains(s)
? UiChipVariant.primary
: UiChipVariant.secondary,
),
@@ -183,7 +184,7 @@ class ExperiencePage extends StatelessWidget {
Widget _buildCustomSkillsList(ExperienceState state, dynamic i18n) {
final customSkills = state.selectedSkills
.where((s) => !state.availableSkills.any((e) => e.value == s))
.where((s) => !state.availableSkills.contains(s))
.toList();
if (customSkills.isEmpty) return const SizedBox.shrink();

View File

@@ -1,7 +1,8 @@
library;
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'src/data/repositories/experience_repository_impl.dart';
import 'src/domain/repositories/experience_repository_interface.dart';
@@ -13,20 +14,26 @@ import 'src/presentation/pages/experience_page.dart';
export 'src/presentation/pages/experience_page.dart';
/// Module for the Staff Experience feature.
///
/// Uses the V2 REST API via [BaseApiService] for backend access.
class StaffProfileExperienceModule extends Module {
@override
List<Module> get imports => [DataConnectModule()];
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repository
i.addLazySingleton<ExperienceRepositoryInterface>(
ExperienceRepositoryImpl.new,
() => ExperienceRepositoryImpl(
apiService: i.get<BaseApiService>(),
),
);
// UseCases
i.addLazySingleton<GetStaffIndustriesUseCase>(
() => GetStaffIndustriesUseCase(i.get<ExperienceRepositoryInterface>()),
() =>
GetStaffIndustriesUseCase(i.get<ExperienceRepositoryInterface>()),
);
i.addLazySingleton<GetStaffSkillsUseCase>(
() => GetStaffSkillsUseCase(i.get<ExperienceRepositoryInterface>()),

View File

@@ -14,15 +14,12 @@ dependencies:
flutter_bloc: ^8.1.0
flutter_modular: ^6.3.0
equatable: ^2.0.5
# Architecture Packages
krow_domain:
path: ../../../../../domain
krow_core:
path: ../../../../../core
krow_data_connect:
path: ../../../../../data_connect
firebase_auth: ^6.1.2
design_system:
path: ../../../../../design_system
core_localization:

View File

@@ -1,119 +1,77 @@
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/personal_info_repository_interface.dart';
import 'package:staff_profile_info/src/domain/repositories/personal_info_repository_interface.dart';
/// Implementation of [PersonalInfoRepositoryInterface] that delegates
/// to Firebase Data Connect for all data operations.
/// to the V2 REST API for all data operations.
///
/// This implementation follows Clean Architecture by:
/// - Implementing the domain's repository interface
/// - Delegating all data access to the data_connect layer
/// - Mapping between data_connect DTOs and domain entities
/// - Containing no business logic
class PersonalInfoRepositoryImpl
implements PersonalInfoRepositoryInterface {
/// Replaces the previous Firebase Data Connect implementation.
class PersonalInfoRepositoryImpl implements PersonalInfoRepositoryInterface {
/// Creates a [PersonalInfoRepositoryImpl].
///
/// Requires the Firebase Data Connect service.
/// Requires the V2 [BaseApiService] for HTTP communication,
/// [FileUploadService] for uploading files to cloud storage, and
/// [SignedUrlService] for generating signed download URLs.
PersonalInfoRepositoryImpl({
DataConnectService? service,
}) : _service = service ?? DataConnectService.instance;
required BaseApiService apiService,
required FileUploadService uploadService,
required SignedUrlService signedUrlService,
}) : _api = apiService,
_uploadService = uploadService,
_signedUrlService = signedUrlService;
final DataConnectService _service;
final BaseApiService _api;
final FileUploadService _uploadService;
final SignedUrlService _signedUrlService;
@override
Future<Staff> getStaffProfile() async {
return _service.run(() async {
final String uid = _service.auth.currentUser!.uid;
// Query staff data from Firebase Data Connect
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables> result =
await _service.connector.getStaffByUserId(userId: uid).execute();
if (result.data.staffs.isEmpty) {
throw const ServerException(technicalMessage: 'Staff profile not found');
}
final GetStaffByUserIdStaffs rawStaff = result.data.staffs.first;
// Map from data_connect DTO to domain entity
return _mapToStaffEntity(rawStaff);
});
Future<StaffPersonalInfo> getStaffProfile() async {
final ApiResponse response =
await _api.get(V2ApiEndpoints.staffPersonalInfo);
final Map<String, dynamic> json =
response.data as Map<String, dynamic>;
return StaffPersonalInfo.fromJson(json);
}
@override
Future<Staff> updateStaffProfile(
{required String staffId, required Map<String, dynamic> data}) async {
return _service.run(() async {
// Start building the update mutation
UpdateStaffVariablesBuilder updateBuilder =
_service.connector.updateStaff(id: staffId);
// Apply updates from map if present
if (data.containsKey('name')) {
updateBuilder = updateBuilder.fullName(data['name'] as String);
}
if (data.containsKey('email')) {
updateBuilder = updateBuilder.email(data['email'] as String);
}
if (data.containsKey('phone')) {
updateBuilder = updateBuilder.phone(data['phone'] as String?);
}
if (data.containsKey('avatar')) {
updateBuilder = updateBuilder.photoUrl(data['avatar'] as String?);
}
if (data.containsKey('preferredLocations')) {
// After schema update and SDK regeneration, preferredLocations accepts List<String>
updateBuilder = updateBuilder.preferredLocations(
data['preferredLocations'] as List<String>);
}
// Execute the update
final OperationResult<UpdateStaffData, UpdateStaffVariables> result =
await updateBuilder.execute();
if (result.data.staff_update == null) {
throw const ServerException(
technicalMessage: 'Failed to update staff profile');
}
// Fetch the updated staff profile to return complete entity
return getStaffProfile();
});
Future<StaffPersonalInfo> updateStaffProfile({
required String staffId,
required Map<String, dynamic> data,
}) async {
final ApiResponse response = await _api.put(
V2ApiEndpoints.staffPersonalInfo,
data: data,
);
final Map<String, dynamic> json =
response.data as Map<String, dynamic>;
return StaffPersonalInfo.fromJson(json);
}
@override
Future<String> uploadProfilePhoto(String filePath) async {
// TODO: Implement photo upload to Firebase Storage
// This will be implemented when Firebase Storage integration is ready
throw UnimplementedError(
'Photo upload not yet implemented. Will integrate with Firebase Storage.',
// 1. Upload the file to cloud storage.
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
filePath: filePath,
fileName:
'staff_profile_photo_${DateTime.now().millisecondsSinceEpoch}.jpg',
visibility: FileVisibility.public,
);
}
/// Maps a data_connect Staff DTO to a domain Staff entity.
///
/// This mapping isolates the domain from data layer implementation details.
Staff _mapToStaffEntity(GetStaffByUserIdStaffs dto) {
return Staff(
id: dto.id,
authProviderId: dto.userId,
name: dto.fullName,
email: dto.email ?? '',
phone: dto.phone,
avatar: dto.photoUrl,
status: StaffStatus.active,
address: dto.addres,
totalShifts: dto.totalShifts,
averageRating: dto.averageRating,
onTimeRate: dto.onTimeRate,
noShowCount: dto.noShowCount,
cancellationCount: dto.cancellationCount,
reliabilityScore: dto.reliabilityScore,
// After schema update and SDK regeneration, preferredLocations is List<String>?
preferredLocations: dto.preferredLocations,
// 2. Generate a signed URL for the uploaded file.
final SignedUrlResponse signedUrlRes =
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
final String photoUrl = signedUrlRes.signedUrl;
// 3. Submit the photo URL to the V2 API.
await _api.post(
V2ApiEndpoints.staffProfilePhoto,
data: <String, dynamic>{
'fileUri': uploadRes.fileUri,
'photoUrl': photoUrl,
},
);
return photoUrl;
}
}

View File

@@ -4,24 +4,23 @@ import 'package:krow_domain/krow_domain.dart';
///
/// This repository defines the contract for loading and updating
/// staff profile information during onboarding or profile editing.
///
/// Implementations must delegate all data operations through
/// the data_connect layer, following Clean Architecture principles.
abstract interface class PersonalInfoRepositoryInterface {
/// Retrieves the staff profile for the current authenticated user.
/// Retrieves the personal info for the current authenticated staff member.
///
/// Returns the complete [Staff] entity with all profile information.
Future<Staff> getStaffProfile();
/// Returns the [StaffPersonalInfo] entity with name, contact, and location data.
Future<StaffPersonalInfo> getStaffProfile();
/// Updates the staff profile information.
/// Updates the staff personal information.
///
/// Takes a [Staff] entity ID and updated fields map and persists changes
/// through the data layer. Returns the updated [Staff] entity.
Future<Staff> updateStaffProfile({required String staffId, required Map<String, dynamic> data});
/// Takes the staff member's [staffId] and updated [data] map.
/// Returns the updated [StaffPersonalInfo] entity.
Future<StaffPersonalInfo> updateStaffProfile({
required String staffId,
required Map<String, dynamic> data,
});
/// Uploads a profile photo and returns the URL.
///
/// Takes the file path of the photo to upload.
/// Returns the URL where the photo is stored.
Future<String> uploadProfilePhoto(String filePath);
}

View File

@@ -1,22 +1,19 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/personal_info_repository_interface.dart';
/// Use case for retrieving staff profile information.
import 'package:staff_profile_info/src/domain/repositories/personal_info_repository_interface.dart';
/// Use case for retrieving staff personal information.
///
/// This use case fetches the complete staff profile from the repository,
/// which delegates to the data_connect layer for data access.
class GetPersonalInfoUseCase
implements NoInputUseCase<Staff> {
/// Fetches the personal info from the V2 API via the repository.
class GetPersonalInfoUseCase implements NoInputUseCase<StaffPersonalInfo> {
/// Creates a [GetPersonalInfoUseCase].
///
/// Requires a [PersonalInfoRepositoryInterface] to fetch data.
GetPersonalInfoUseCase(this._repository);
final PersonalInfoRepositoryInterface _repository;
@override
Future<Staff> call() {
Future<StaffPersonalInfo> call() {
return _repository.getStaffProfile();
}
}

View File

@@ -1,14 +1,16 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/personal_info_repository_interface.dart';
/// Arguments for updating staff profile information.
import 'package:staff_profile_info/src/domain/repositories/personal_info_repository_interface.dart';
/// Arguments for updating staff personal information.
class UpdatePersonalInfoParams extends UseCaseArgument {
/// Creates [UpdatePersonalInfoParams].
const UpdatePersonalInfoParams({
required this.staffId,
required this.data,
});
/// The staff member's ID.
final String staffId;
@@ -19,21 +21,16 @@ class UpdatePersonalInfoParams extends UseCaseArgument {
List<Object?> get props => <Object?>[staffId, data];
}
/// Use case for updating staff profile information.
///
/// This use case updates the staff profile information
/// through the repository, which delegates to the data_connect layer.
/// Use case for updating staff personal information via the V2 API.
class UpdatePersonalInfoUseCase
implements UseCase<UpdatePersonalInfoParams, Staff> {
implements UseCase<UpdatePersonalInfoParams, StaffPersonalInfo> {
/// Creates an [UpdatePersonalInfoUseCase].
///
/// Requires a [PersonalInfoRepositoryInterface] to update data.
UpdatePersonalInfoUseCase(this._repository);
final PersonalInfoRepositoryInterface _repository;
@override
Future<Staff> call(UpdatePersonalInfoParams params) {
Future<StaffPersonalInfo> call(UpdatePersonalInfoParams params) {
return _repository.updateStaffProfile(
staffId: params.staffId,
data: params.data,

View File

@@ -0,0 +1,19 @@
import 'package:krow_core/core.dart';
import 'package:staff_profile_info/src/domain/repositories/personal_info_repository_interface.dart';
/// Use case for uploading a staff profile photo via the V2 API.
///
/// Accepts the local file path and returns the public URL of the
/// uploaded photo after it has been stored and registered.
class UploadProfilePhotoUseCase implements UseCase<String, String> {
/// Creates an [UploadProfilePhotoUseCase].
UploadProfilePhotoUseCase(this._repository);
final PersonalInfoRepositoryInterface _repository;
@override
Future<String> call(String filePath) {
return _repository.uploadProfilePhoto(filePath);
}
}

View File

@@ -1,44 +1,48 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/usecases/get_personal_info_usecase.dart';
import '../../domain/usecases/update_personal_info_usecase.dart';
import 'personal_info_event.dart';
import 'personal_info_state.dart';
import 'package:staff_profile_info/src/domain/usecases/get_personal_info_usecase.dart';
import 'package:staff_profile_info/src/domain/usecases/update_personal_info_usecase.dart';
import 'package:staff_profile_info/src/domain/usecases/upload_profile_photo_usecase.dart';
import 'package:staff_profile_info/src/presentation/blocs/personal_info_event.dart';
import 'package:staff_profile_info/src/presentation/blocs/personal_info_state.dart';
/// BLoC responsible for managing staff profile information state.
/// BLoC responsible for managing staff personal information state.
///
/// This BLoC handles loading, updating, and saving staff profile information
/// during onboarding or profile editing. It delegates business logic to
/// use cases following Clean Architecture principles.
/// Handles loading, updating, and saving personal information
/// via V2 API use cases following Clean Architecture.
class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
with BlocErrorHandler<PersonalInfoState>, SafeBloc<PersonalInfoEvent, PersonalInfoState>
with
BlocErrorHandler<PersonalInfoState>,
SafeBloc<PersonalInfoEvent, PersonalInfoState>
implements Disposable {
/// Creates a [PersonalInfoBloc].
///
/// Requires the use cases to load and update the profile.
PersonalInfoBloc({
required GetPersonalInfoUseCase getPersonalInfoUseCase,
required UpdatePersonalInfoUseCase updatePersonalInfoUseCase,
}) : _getPersonalInfoUseCase = getPersonalInfoUseCase,
_updatePersonalInfoUseCase = updatePersonalInfoUseCase,
super(const PersonalInfoState.initial()) {
required UploadProfilePhotoUseCase uploadProfilePhotoUseCase,
}) : _getPersonalInfoUseCase = getPersonalInfoUseCase,
_updatePersonalInfoUseCase = updatePersonalInfoUseCase,
_uploadProfilePhotoUseCase = uploadProfilePhotoUseCase,
super(const PersonalInfoState.initial()) {
on<PersonalInfoLoadRequested>(_onLoadRequested);
on<PersonalInfoFieldChanged>(_onFieldChanged);
on<PersonalInfoAddressSelected>(_onAddressSelected);
on<PersonalInfoFormSubmitted>(_onSubmitted);
on<PersonalInfoLocationAdded>(_onLocationAdded);
on<PersonalInfoLocationRemoved>(_onLocationRemoved);
on<PersonalInfoPhotoUploadRequested>(_onPhotoUploadRequested);
add(const PersonalInfoLoadRequested());
}
final GetPersonalInfoUseCase _getPersonalInfoUseCase;
final UpdatePersonalInfoUseCase _updatePersonalInfoUseCase;
final UploadProfilePhotoUseCase _uploadProfilePhotoUseCase;
/// Handles loading staff profile information.
/// Handles loading staff personal information.
Future<void> _onLoadRequested(
PersonalInfoLoadRequested event,
Emitter<PersonalInfoState> emit,
@@ -47,25 +51,23 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
await handleError(
emit: emit.call,
action: () async {
final Staff staff = await _getPersonalInfoUseCase();
final StaffPersonalInfo info = await _getPersonalInfoUseCase();
// Initialize form values from staff entity
// Note: Staff entity currently stores address as a string, but we want to map it to 'preferredLocations'
final Map<String, dynamic> initialValues = <String, dynamic>{
'name': staff.name,
'email': staff.email,
'phone': staff.phone,
'firstName': info.firstName ?? '',
'lastName': info.lastName ?? '',
'email': info.email ?? '',
'phone': info.phone ?? '',
'bio': info.bio ?? '',
'preferredLocations':
staff.preferredLocations != null
? List<String>.from(staff.preferredLocations!)
: <String>[],
'avatar': staff.avatar,
List<String>.from(info.preferredLocations),
'maxDistanceMiles': info.maxDistanceMiles,
};
emit(
state.copyWith(
status: PersonalInfoStatus.loaded,
staff: staff,
personalInfo: info,
formValues: initialValues,
),
);
@@ -77,50 +79,50 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
);
}
/// Handles updating a field value in the current staff profile.
/// Handles updating a field value in the current form.
void _onFieldChanged(
PersonalInfoFieldChanged event,
Emitter<PersonalInfoState> emit,
) {
final Map<String, dynamic> updatedValues = Map.from(state.formValues);
final Map<String, dynamic> updatedValues =
Map<String, dynamic>.from(state.formValues);
updatedValues[event.field] = event.value;
emit(state.copyWith(formValues: updatedValues));
}
/// Handles saving staff profile information.
/// Handles saving staff personal information.
Future<void> _onSubmitted(
PersonalInfoFormSubmitted event,
Emitter<PersonalInfoState> emit,
) async {
if (state.staff == null) return;
if (state.personalInfo == null) return;
emit(state.copyWith(status: PersonalInfoStatus.saving));
await handleError(
emit: emit.call,
action: () async {
final Staff updatedStaff = await _updatePersonalInfoUseCase(
final StaffPersonalInfo updated = await _updatePersonalInfoUseCase(
UpdatePersonalInfoParams(
staffId: state.staff!.id,
staffId: state.personalInfo!.staffId,
data: state.formValues,
),
);
// Update local state with the returned staff and keep form values in sync
final Map<String, dynamic> newValues = <String, dynamic>{
'name': updatedStaff.name,
'email': updatedStaff.email,
'phone': updatedStaff.phone,
'firstName': updated.firstName ?? '',
'lastName': updated.lastName ?? '',
'email': updated.email ?? '',
'phone': updated.phone ?? '',
'bio': updated.bio ?? '',
'preferredLocations':
updatedStaff.preferredLocations != null
? List<String>.from(updatedStaff.preferredLocations!)
: <String>[],
'avatar': updatedStaff.avatar,
List<String>.from(updated.preferredLocations),
'maxDistanceMiles': updated.maxDistanceMiles,
};
emit(
state.copyWith(
status: PersonalInfoStatus.saved,
staff: updatedStaff,
personalInfo: updated,
formValues: newValues,
),
);
@@ -132,11 +134,12 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
);
}
/// Legacy address selected no-op.
void _onAddressSelected(
PersonalInfoAddressSelected event,
Emitter<PersonalInfoState> emit,
) {
// Legacy address selected no-op; use PersonalInfoLocationAdded instead.
// No-op; use PersonalInfoLocationAdded instead.
}
/// Adds a location to the preferredLocations list (max 5, no duplicates).
@@ -144,15 +147,18 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
PersonalInfoLocationAdded event,
Emitter<PersonalInfoState> emit,
) {
final dynamic raw = state.formValues['preferredLocations'];
final List<String> current = _toStringList(raw);
final List<String> current = _toStringList(
state.formValues['preferredLocations'],
);
if (current.length >= 5) return; // max guard
if (current.contains(event.location)) return; // no duplicates
if (current.length >= 5) return;
if (current.contains(event.location)) return;
final List<String> updated = List<String>.from(current)..add(event.location);
final Map<String, dynamic> updatedValues = Map<String, dynamic>.from(state.formValues)
..['preferredLocations'] = updated;
final List<String> updated = List<String>.from(current)
..add(event.location);
final Map<String, dynamic> updatedValues =
Map<String, dynamic>.from(state.formValues)
..['preferredLocations'] = updated;
emit(state.copyWith(formValues: updatedValues));
}
@@ -162,17 +168,62 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
PersonalInfoLocationRemoved event,
Emitter<PersonalInfoState> emit,
) {
final dynamic raw = state.formValues['preferredLocations'];
final List<String> current = _toStringList(raw);
final List<String> current = _toStringList(
state.formValues['preferredLocations'],
);
final List<String> updated = List<String>.from(current)
..remove(event.location);
final Map<String, dynamic> updatedValues = Map<String, dynamic>.from(state.formValues)
..['preferredLocations'] = updated;
final Map<String, dynamic> updatedValues =
Map<String, dynamic>.from(state.formValues)
..['preferredLocations'] = updated;
emit(state.copyWith(formValues: updatedValues));
}
/// Handles uploading a profile photo via the V2 API.
Future<void> _onPhotoUploadRequested(
PersonalInfoPhotoUploadRequested event,
Emitter<PersonalInfoState> emit,
) async {
emit(state.copyWith(status: PersonalInfoStatus.uploadingPhoto));
await handleError(
emit: emit.call,
action: () async {
final String photoUrl =
await _uploadProfilePhotoUseCase(event.filePath);
// Update the personalInfo entity with the new photo URL.
final StaffPersonalInfo? currentInfo = state.personalInfo;
final StaffPersonalInfo updatedInfo = StaffPersonalInfo(
staffId: currentInfo?.staffId ?? '',
firstName: currentInfo?.firstName,
lastName: currentInfo?.lastName,
bio: currentInfo?.bio,
preferredLocations: currentInfo?.preferredLocations ?? const <String>[],
maxDistanceMiles: currentInfo?.maxDistanceMiles,
industries: currentInfo?.industries ?? const <String>[],
skills: currentInfo?.skills ?? const <String>[],
email: currentInfo?.email,
phone: currentInfo?.phone,
photoUrl: photoUrl,
);
emit(
state.copyWith(
status: PersonalInfoStatus.photoUploaded,
personalInfo: updatedInfo,
),
);
},
onError: (String errorKey) => state.copyWith(
status: PersonalInfoStatus.error,
errorMessage: errorKey,
),
);
}
/// Safely converts a dynamic value to a string list.
List<String> _toStringList(dynamic raw) {
if (raw is List<String>) return raw;
if (raw is List) return raw.map((dynamic e) => e.toString()).toList();
@@ -184,5 +235,3 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
close();
}
}

View File

@@ -52,9 +52,24 @@ class PersonalInfoLocationAdded extends PersonalInfoEvent {
/// Event to remove a preferred location.
class PersonalInfoLocationRemoved extends PersonalInfoEvent {
/// Creates a [PersonalInfoLocationRemoved].
const PersonalInfoLocationRemoved({required this.location});
/// The location to remove.
final String location;
@override
List<Object?> get props => <Object?>[location];
}
/// Event to upload a profile photo from the given file path.
class PersonalInfoPhotoUploadRequested extends PersonalInfoEvent {
/// Creates a [PersonalInfoPhotoUploadRequested].
const PersonalInfoPhotoUploadRequested({required this.filePath});
/// The local file path of the selected photo.
final String filePath;
@override
List<Object?> get props => <Object?>[filePath];
}

View File

@@ -21,19 +21,21 @@ enum PersonalInfoStatus {
/// Uploading photo.
uploadingPhoto,
/// Photo uploaded successfully.
photoUploaded,
/// An error occurred.
error,
}
/// State for the Personal Info BLoC.
///
/// Uses the shared [Staff] entity from the domain layer.
/// Uses [StaffPersonalInfo] from the V2 domain layer.
class PersonalInfoState extends Equatable {
/// Creates a [PersonalInfoState].
const PersonalInfoState({
this.status = PersonalInfoStatus.initial,
this.staff,
this.personalInfo,
this.formValues = const <String, dynamic>{},
this.errorMessage,
});
@@ -41,14 +43,15 @@ class PersonalInfoState extends Equatable {
/// Initial state.
const PersonalInfoState.initial()
: status = PersonalInfoStatus.initial,
staff = null,
personalInfo = null,
formValues = const <String, dynamic>{},
errorMessage = null;
/// The current status of the operation.
final PersonalInfoStatus status;
/// The staff profile information.
final Staff? staff;
/// The staff personal information.
final StaffPersonalInfo? personalInfo;
/// The form values being edited.
final Map<String, dynamic> formValues;
@@ -59,18 +62,19 @@ class PersonalInfoState extends Equatable {
/// Creates a copy of this state with the given fields replaced.
PersonalInfoState copyWith({
PersonalInfoStatus? status,
Staff? staff,
StaffPersonalInfo? personalInfo,
Map<String, dynamic>? formValues,
String? errorMessage,
}) {
return PersonalInfoState(
status: status ?? this.status,
staff: staff ?? this.staff,
personalInfo: personalInfo ?? this.personalInfo,
formValues: formValues ?? this.formValues,
errorMessage: errorMessage,
);
}
@override
List<Object?> get props => <Object?>[status, staff, formValues, errorMessage];
List<Object?> get props =>
<Object?>[status, personalInfo, formValues, errorMessage];
}

View File

@@ -9,14 +9,10 @@ import 'package:staff_profile_info/src/presentation/blocs/personal_info_state.da
import 'package:staff_profile_info/src/presentation/widgets/personal_info_page/personal_info_content.dart';
import 'package:staff_profile_info/src/presentation/widgets/personal_info_skeleton/personal_info_skeleton.dart';
/// The Personal Info page for staff onboarding.
///
/// This page allows staff members to view and edit their personal information
/// including phone number and address. Full name and email are read-only as they come from authentication.
///
/// This page is a StatelessWidget that uses BLoC for state management,
/// following Clean Architecture and the design system guidelines.
/// Allows staff members to view and edit their personal information
/// including phone number and address. Uses V2 API via BLoC.
class PersonalInfoPage extends StatelessWidget {
/// Creates a [PersonalInfoPage].
const PersonalInfoPage({super.key});
@@ -37,6 +33,12 @@ class PersonalInfoPage extends StatelessWidget {
type: UiSnackbarType.success,
);
Modular.to.popSafe();
} else if (state.status == PersonalInfoStatus.photoUploaded) {
UiSnackbar.show(
context,
message: i18n.photo_upload_success,
type: UiSnackbarType.success,
);
} else if (state.status == PersonalInfoStatus.error) {
UiSnackbar.show(
context,
@@ -60,7 +62,7 @@ class PersonalInfoPage extends StatelessWidget {
return const PersonalInfoSkeleton();
}
if (state.staff == null) {
if (state.personalInfo == null) {
return Center(
child: Text(
'Failed to load personal information',
@@ -69,7 +71,9 @@ class PersonalInfoPage extends StatelessWidget {
);
}
return PersonalInfoContent(staff: state.staff!);
return PersonalInfoContent(
personalInfo: state.personalInfo!,
);
},
),
),

View File

@@ -1,7 +1,9 @@
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 'package:design_system/design_system.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_profile_info/src/presentation/blocs/personal_info_bloc.dart';
import 'package:staff_profile_info/src/presentation/blocs/personal_info_event.dart';
@@ -12,18 +14,16 @@ import 'package:staff_profile_info/src/presentation/widgets/save_button.dart';
/// Content widget that displays and manages the staff profile form.
///
/// This widget is extracted from the page to handle form state separately,
/// following Clean Architecture's separation of concerns principle and the design system guidelines.
/// Works with the shared [Staff] entity from the domain layer.
/// Works with [StaffPersonalInfo] from the V2 domain layer.
class PersonalInfoContent extends StatefulWidget {
/// Creates a [PersonalInfoContent].
const PersonalInfoContent({
super.key,
required this.staff,
required this.personalInfo,
});
/// The staff profile to display and edit.
final Staff staff;
/// The staff personal info to display and edit.
final StaffPersonalInfo personalInfo;
@override
State<PersonalInfoContent> createState() => _PersonalInfoContentState();
@@ -36,10 +36,13 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
@override
void initState() {
super.initState();
_emailController = TextEditingController(text: widget.staff.email);
_phoneController = TextEditingController(text: widget.staff.phone ?? '');
_emailController = TextEditingController(
text: widget.personalInfo.email ?? '',
);
_phoneController = TextEditingController(
text: widget.personalInfo.phone ?? '',
);
// Listen to changes and update BLoC
_emailController.addListener(_onEmailChanged);
_phoneController.addListener(_onPhoneChanged);
}
@@ -51,42 +54,120 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
super.dispose();
}
void _onEmailChanged() {
context.read<PersonalInfoBloc>().add(
PersonalInfoFieldChanged(
field: 'email',
value: _emailController.text,
),
);
ReadContext(context).read<PersonalInfoBloc>().add(
PersonalInfoFieldChanged(
field: 'email',
value: _emailController.text,
),
);
}
void _onPhoneChanged() {
context.read<PersonalInfoBloc>().add(
PersonalInfoFieldChanged(
field: 'phone',
value: _phoneController.text,
),
);
ReadContext(context).read<PersonalInfoBloc>().add(
PersonalInfoFieldChanged(
field: 'phone',
value: _phoneController.text,
),
);
}
void _handleSave() {
context.read<PersonalInfoBloc>().add(const PersonalInfoFormSubmitted());
ReadContext(context).read<PersonalInfoBloc>().add(const PersonalInfoFormSubmitted());
}
void _handlePhotoTap() {
// TODO: Implement photo picker
// context.read<PersonalInfoBloc>().add(
// PersonalInfoPhotoUploadRequested(filePath: pickedFilePath),
// );
/// Shows a bottom sheet to choose between camera and gallery, then
/// dispatches the upload event to the BLoC.
Future<void> _handlePhotoTap() async {
final TranslationsStaffOnboardingPersonalInfoEn i18n =
t.staff.onboarding.personal_info;
final TranslationsCommonEn common = t.common;
final String? source = await showModalBottomSheet<String>(
context: context,
builder: (BuildContext ctx) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space4,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space4,
),
child: Text(
i18n.choose_photo_source,
style: UiTypography.body1b.textPrimary,
),
),
ListTile(
leading: const Icon(
UiIcons.camera,
color: UiColors.primary,
),
title: Text(
common.camera,
style: UiTypography.body1r.textPrimary,
),
onTap: () => Navigator.pop(ctx, 'camera'),
),
ListTile(
leading: const Icon(
UiIcons.gallery,
color: UiColors.primary,
),
title: Text(
common.gallery,
style: UiTypography.body1r.textPrimary,
),
onTap: () => Navigator.pop(ctx, 'gallery'),
),
],
),
),
);
},
);
if (source == null || !mounted) return;
String? filePath;
if (source == 'camera') {
final CameraService cameraService = Modular.get<CameraService>();
filePath = await cameraService.takePhoto();
} else {
final GalleryService galleryService = Modular.get<GalleryService>();
filePath = await galleryService.pickImage();
}
if (filePath == null || !mounted) return;
ReadContext(context).read<PersonalInfoBloc>().add(
PersonalInfoPhotoUploadRequested(filePath: filePath),
);
}
/// Computes the display name from personal info first/last name.
String get _displayName {
final String first = widget.personalInfo.firstName ?? '';
final String last = widget.personalInfo.lastName ?? '';
final String name = '$first $last'.trim();
return name.isNotEmpty ? name : 'Staff';
}
@override
Widget build(BuildContext context) {
final TranslationsStaffOnboardingPersonalInfoEn i18n = t.staff.onboarding.personal_info;
final TranslationsStaffOnboardingPersonalInfoEn i18n =
t.staff.onboarding.personal_info;
return BlocBuilder<PersonalInfoBloc, PersonalInfoState>(
builder: (BuildContext context, PersonalInfoState state) {
final bool isSaving = state.status == PersonalInfoStatus.saving;
final bool isUploadingPhoto =
state.status == PersonalInfoStatus.uploadingPhoto;
final bool isBusy = isSaving || isUploadingPhoto;
return Column(
children: <Widget>[
Expanded(
@@ -96,26 +177,29 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
ProfilePhotoWidget(
photoUrl: widget.staff.avatar,
fullName: widget.staff.name,
onTap: isSaving ? null : _handlePhotoTap,
photoUrl: state.personalInfo?.photoUrl,
fullName: _displayName,
onTap: isBusy ? null : _handlePhotoTap,
isUploading: isUploadingPhoto,
),
const SizedBox(height: UiConstants.space6),
PersonalInfoForm(
fullName: widget.staff.name,
email: widget.staff.email,
fullName: _displayName,
email: widget.personalInfo.email ?? '',
emailController: _emailController,
phoneController: _phoneController,
currentLocations: _toStringList(state.formValues['preferredLocations']),
enabled: !isSaving,
currentLocations: _toStringList(
state.formValues['preferredLocations'],
),
enabled: !isBusy,
),
const SizedBox(height: UiConstants.space16), // Space for bottom button
const SizedBox(height: UiConstants.space16),
],
),
),
),
SaveButton(
onPressed: isSaving ? null : _handleSave,
onPressed: isBusy ? null : _handleSave,
label: i18n.save_button,
isLoading: isSaving,
),
@@ -125,6 +209,7 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
);
}
/// Safely converts a dynamic value to a string list.
List<String> _toStringList(dynamic raw) {
if (raw is List<String>) return raw;
if (raw is List) return raw.map((dynamic e) => e.toString()).toList();

View File

@@ -16,7 +16,9 @@ class ProfilePhotoWidget extends StatelessWidget {
required this.photoUrl,
required this.fullName,
required this.onTap,
this.isUploading = false,
});
/// The URL of the staff member's photo.
final String? photoUrl;
@@ -26,6 +28,9 @@ class ProfilePhotoWidget extends StatelessWidget {
/// Callback when the photo/camera button is tapped.
final VoidCallback? onTap;
/// Whether a photo upload is currently in progress.
final bool isUploading;
@override
Widget build(BuildContext context) {
final TranslationsStaffOnboardingPersonalInfoEn i18n =
@@ -44,19 +49,34 @@ class ProfilePhotoWidget extends StatelessWidget {
shape: BoxShape.circle,
color: UiColors.primary.withValues(alpha: 0.1),
),
child: photoUrl != null
? ClipOval(
child: Image.network(
photoUrl!,
fit: BoxFit.cover,
child: isUploading
? const Center(
child: SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
strokeWidth: 2,
color: UiColors.primary,
),
),
)
: Center(
child: Text(
fullName.isNotEmpty ? fullName[0].toUpperCase() : '?',
style: UiTypography.displayL.primary,
),
),
: photoUrl != null
? ClipOval(
child: Image.network(
photoUrl!,
width: 96,
height: 96,
fit: BoxFit.cover,
),
)
: Center(
child: Text(
fullName.isNotEmpty
? fullName[0].toUpperCase()
: '?',
style: UiTypography.displayL.primary,
),
),
),
Positioned(
bottom: 0,

View File

@@ -1,47 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'data/repositories/personal_info_repository_impl.dart';
import 'domain/repositories/personal_info_repository_interface.dart';
import 'domain/usecases/get_personal_info_usecase.dart';
import 'domain/usecases/update_personal_info_usecase.dart';
import 'presentation/blocs/personal_info_bloc.dart';
import 'presentation/pages/personal_info_page.dart';
import 'presentation/pages/language_selection_page.dart';
import 'presentation/pages/preferred_locations_page.dart';
import 'package:staff_profile_info/src/data/repositories/personal_info_repository_impl.dart';
import 'package:staff_profile_info/src/domain/repositories/personal_info_repository_interface.dart';
import 'package:staff_profile_info/src/domain/usecases/get_personal_info_usecase.dart';
import 'package:staff_profile_info/src/domain/usecases/update_personal_info_usecase.dart';
import 'package:staff_profile_info/src/domain/usecases/upload_profile_photo_usecase.dart';
import 'package:staff_profile_info/src/presentation/blocs/personal_info_bloc.dart';
import 'package:staff_profile_info/src/presentation/pages/personal_info_page.dart';
import 'package:staff_profile_info/src/presentation/pages/language_selection_page.dart';
import 'package:staff_profile_info/src/presentation/pages/preferred_locations_page.dart';
/// The entry module for the Staff Profile Info feature.
///
/// This module provides routing and dependency injection for
/// personal information functionality following Clean Architecture.
///
/// The module:
/// - Registers repository implementations
/// - Registers use cases that contain business logic
/// - Registers BLoC for state management
/// - Defines routes for navigation
/// Provides routing and dependency injection for personal information
/// functionality, using the V2 REST API via [BaseApiService].
class StaffProfileInfoModule extends Module {
@override
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
// Repository
i.addLazySingleton<PersonalInfoRepositoryInterface>(
PersonalInfoRepositoryImpl.new,
() => PersonalInfoRepositoryImpl(
apiService: i.get<BaseApiService>(),
uploadService: i.get<FileUploadService>(),
signedUrlService: i.get<SignedUrlService>(),
),
);
// Use Cases - delegate business logic to repository
// Use Cases
i.addLazySingleton<GetPersonalInfoUseCase>(
() => GetPersonalInfoUseCase(i.get<PersonalInfoRepositoryInterface>()),
);
i.addLazySingleton<UpdatePersonalInfoUseCase>(
() => UpdatePersonalInfoUseCase(i.get<PersonalInfoRepositoryInterface>()),
() =>
UpdatePersonalInfoUseCase(i.get<PersonalInfoRepositoryInterface>()),
);
i.addLazySingleton<UploadProfilePhotoUseCase>(
() => UploadProfilePhotoUseCase(
i.get<PersonalInfoRepositoryInterface>(),
),
);
// BLoC - manages presentation state
// BLoC
i.addLazySingleton<PersonalInfoBloc>(
() => PersonalInfoBloc(
getPersonalInfoUseCase: i.get<GetPersonalInfoUseCase>(),
updatePersonalInfoUseCase: i.get<UpdatePersonalInfoUseCase>(),
uploadProfilePhotoUseCase: i.get<UploadProfilePhotoUseCase>(),
),
);
}

View File

@@ -15,7 +15,7 @@ dependencies:
bloc: ^8.1.0
flutter_modular: ^6.3.0
equatable: ^2.0.5
# Architecture Packages
design_system:
path: ../../../../../design_system
@@ -25,13 +25,10 @@ dependencies:
path: ../../../../../core
krow_domain:
path: ../../../../../domain
krow_data_connect:
path: ../../../../../data_connect
firebase_auth: any
firebase_data_connect: any
google_places_flutter: ^2.1.1
http: ^1.2.2
dev_dependencies:
flutter_test:
sdk: flutter