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:
@@ -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?,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>()),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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!,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user