feat: Implement Staff Profile Info feature with onboarding capabilities
- Added StaffProfileInfoModule for managing personal information onboarding. - Created PersonalInfoRepositoryImpl and PersonalInfoRepositoryMock for data handling. - Developed use cases for fetching and updating staff profile information. - Implemented PersonalInfoBloc for state management using BLoC pattern. - Designed UI components including PersonalInfoPage, PersonalInfoContent, and form widgets. - Integrated navigation for onboarding steps and added necessary routes. - Updated pubspec.yaml files to include new dependencies and feature packages.
This commit is contained in:
@@ -507,6 +507,24 @@
|
|||||||
"logout": {
|
"logout": {
|
||||||
"button": "Sign Out"
|
"button": "Sign Out"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"personal_info": {
|
||||||
|
"title": "Personal Info",
|
||||||
|
"change_photo_hint": "Tap to change photo",
|
||||||
|
"full_name_label": "Full Name",
|
||||||
|
"email_label": "Email",
|
||||||
|
"phone_label": "Phone Number",
|
||||||
|
"phone_hint": "+1 (555) 000-0000",
|
||||||
|
"bio_label": "Bio",
|
||||||
|
"bio_hint": "Tell clients about yourself...",
|
||||||
|
"languages_label": "Languages",
|
||||||
|
"languages_hint": "English, Spanish, French...",
|
||||||
|
"locations_label": "Preferred Locations",
|
||||||
|
"locations_hint": "Downtown, Midtown, Brooklyn...",
|
||||||
|
"save_button": "Save Changes",
|
||||||
|
"save_success": "Personal info saved successfully"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -506,6 +506,24 @@
|
|||||||
"logout": {
|
"logout": {
|
||||||
"button": "Cerrar Sesión"
|
"button": "Cerrar Sesión"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"personal_info": {
|
||||||
|
"title": "Información Personal",
|
||||||
|
"change_photo_hint": "Toca para cambiar foto",
|
||||||
|
"full_name_label": "Nombre Completo",
|
||||||
|
"email_label": "Correo Electrónico",
|
||||||
|
"phone_label": "Número de Teléfono",
|
||||||
|
"phone_hint": "+1 (555) 000-0000",
|
||||||
|
"bio_label": "Biografía",
|
||||||
|
"bio_hint": "Cuéntales a los clientes sobre ti...",
|
||||||
|
"languages_label": "Idiomas",
|
||||||
|
"languages_hint": "Inglés, Español, Francés...",
|
||||||
|
"locations_label": "Ubicaciones Preferidas",
|
||||||
|
"locations_hint": "Centro, Midtown, Brooklyn...",
|
||||||
|
"save_button": "Guardar Cambios",
|
||||||
|
"save_success": "Información personal guardada exitosamente"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,22 +8,22 @@ import 'package:flutter_modular/flutter_modular.dart';
|
|||||||
extension ProfileNavigator on IModularNavigator {
|
extension ProfileNavigator on IModularNavigator {
|
||||||
/// Navigates to the personal info page.
|
/// Navigates to the personal info page.
|
||||||
void pushPersonalInfo() {
|
void pushPersonalInfo() {
|
||||||
pushNamed('/personal-info');
|
pushNamed('/profile/onboarding/personal-info');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the emergency contact page.
|
/// Navigates to the emergency contact page.
|
||||||
void pushEmergencyContact() {
|
void pushEmergencyContact() {
|
||||||
pushNamed('/emergency-contact');
|
pushNamed('/profile/onboarding/emergency-contact');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the experience page.
|
/// Navigates to the experience page.
|
||||||
void pushExperience() {
|
void pushExperience() {
|
||||||
pushNamed('/experience');
|
pushNamed('/profile/onboarding/experience');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the attire page.
|
/// Navigates to the attire page.
|
||||||
void pushAttire() {
|
void pushAttire() {
|
||||||
pushNamed('/attire');
|
pushNamed('/profile/onboarding/attire');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to the documents page.
|
/// Navigates to the documents page.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
|
import 'package:staff_profile_info/staff_profile_info.dart';
|
||||||
|
|
||||||
import 'data/repositories/profile_repository_impl.dart';
|
import 'data/repositories/profile_repository_impl.dart';
|
||||||
import 'domain/repositories/profile_repository.dart';
|
import 'domain/repositories/profile_repository.dart';
|
||||||
@@ -51,5 +52,6 @@ class StaffProfileModule extends Module {
|
|||||||
@override
|
@override
|
||||||
void routes(RouteManager r) {
|
void routes(RouteManager r) {
|
||||||
r.child('/', child: (BuildContext context) => const StaffProfilePage());
|
r.child('/', child: (BuildContext context) => const StaffProfilePage());
|
||||||
|
r.module('/onboarding', module: StaffProfileInfoModule());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ dependencies:
|
|||||||
path: ../../../domain
|
path: ../../../domain
|
||||||
krow_data_connect:
|
krow_data_connect:
|
||||||
path: ../../../data_connect
|
path: ../../../data_connect
|
||||||
|
|
||||||
|
# Feature Packages
|
||||||
|
staff_profile_info:
|
||||||
|
path: ../profile_sections/onboarding/profile_info
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||||
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import '../../domain/repositories/personal_info_repository_interface.dart';
|
||||||
|
|
||||||
|
/// Implementation of [PersonalInfoRepositoryInterface] that delegates
|
||||||
|
/// to Firebase Data Connect 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 {
|
||||||
|
final ExampleConnector _dataConnect;
|
||||||
|
|
||||||
|
/// Creates a [PersonalInfoRepositoryImpl].
|
||||||
|
///
|
||||||
|
/// Requires the Firebase Data Connect connector instance.
|
||||||
|
PersonalInfoRepositoryImpl({
|
||||||
|
required ExampleConnector dataConnect,
|
||||||
|
}) : _dataConnect = dataConnect;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Staff> getStaffProfile(String staffId) async {
|
||||||
|
// Query staff data from Firebase Data Connect
|
||||||
|
final QueryResult<GetStaffByIdData, GetStaffByIdVariables> result =
|
||||||
|
await _dataConnect.getStaffById(id: staffId).execute();
|
||||||
|
|
||||||
|
final staff = result.data.staff;
|
||||||
|
if (staff == null) {
|
||||||
|
throw Exception('Staff profile not found for ID: $staffId');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map from data_connect DTO to domain entity
|
||||||
|
return _mapToStaffEntity(staff);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Staff> updateStaffProfile(Staff staff) async {
|
||||||
|
// Update staff data through Firebase Data Connect
|
||||||
|
final OperationResult<UpdateStaffData, UpdateStaffVariables> result =
|
||||||
|
await _dataConnect
|
||||||
|
.updateStaff(id: staff.id)
|
||||||
|
.fullName(staff.name)
|
||||||
|
.email(staff.email)
|
||||||
|
.phone(staff.phone)
|
||||||
|
.photoUrl(staff.avatar)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (result.data.staff_update == null) {
|
||||||
|
throw Exception('Failed to update staff profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the updated staff profile to return complete entity
|
||||||
|
return getStaffProfile(staff.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a data_connect Staff DTO to a domain Staff entity.
|
||||||
|
///
|
||||||
|
/// This mapping isolates the domain from data layer implementation details.
|
||||||
|
Staff _mapToStaffEntity(GetStaffByIdStaff dto) {
|
||||||
|
return Staff(
|
||||||
|
id: dto.id,
|
||||||
|
authProviderId: dto.userId,
|
||||||
|
name: dto.fullName,
|
||||||
|
email: dto.email ?? '',
|
||||||
|
phone: dto.phone,
|
||||||
|
status: StaffStatus.active, // TODO: Map from actual status field when available
|
||||||
|
address: dto.addres,
|
||||||
|
avatar: dto.photoUrl,
|
||||||
|
livePhoto: null, // TODO: Map when available in data schema
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import '../../domain/repositories/personal_info_repository_interface.dart';
|
||||||
|
|
||||||
|
/// Mock implementation of [PersonalInfoRepositoryInterface].
|
||||||
|
///
|
||||||
|
/// This mock repository returns hardcoded data for development
|
||||||
|
/// and will be replaced with [PersonalInfoRepositoryImpl] when
|
||||||
|
/// Firebase Data Connect is fully configured.
|
||||||
|
///
|
||||||
|
/// Following Clean Architecture, this mock:
|
||||||
|
/// - Implements the domain repository interface
|
||||||
|
/// - Returns domain entities (Staff)
|
||||||
|
/// - Simulates async operations with delays
|
||||||
|
/// - Provides realistic test data
|
||||||
|
class PersonalInfoRepositoryMock implements PersonalInfoRepositoryInterface {
|
||||||
|
// Simulated in-memory storage
|
||||||
|
Staff? _cachedStaff;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Staff> getStaffProfile(String staffId) async {
|
||||||
|
// Simulate network delay
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
// Return cached staff or create mock data
|
||||||
|
return _cachedStaff ??
|
||||||
|
const Staff(
|
||||||
|
id: 'mock-staff-1',
|
||||||
|
authProviderId: 'mock-auth-1',
|
||||||
|
name: 'Krower',
|
||||||
|
email: 'worker@krow.com',
|
||||||
|
phone: '',
|
||||||
|
status: StaffStatus.active,
|
||||||
|
address: 'Montreal, Quebec',
|
||||||
|
avatar: null,
|
||||||
|
livePhoto: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Staff> updateStaffProfile(Staff staff) async {
|
||||||
|
// Simulate network delay
|
||||||
|
await Future.delayed(const Duration(milliseconds: 800));
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
_cachedStaff = staff;
|
||||||
|
|
||||||
|
// Return the updated staff
|
||||||
|
return staff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> uploadProfilePhoto(String filePath) async {
|
||||||
|
// Simulate upload delay
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
|
||||||
|
// Return a mock URL
|
||||||
|
return 'https://example.com/photos/${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
/// Interface for managing personal information of staff members.
|
||||||
|
///
|
||||||
|
/// 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 specified staff ID.
|
||||||
|
///
|
||||||
|
/// Returns the complete [Staff] entity with all profile information.
|
||||||
|
Future<Staff> getStaffProfile(String staffId);
|
||||||
|
|
||||||
|
/// Updates the staff profile information.
|
||||||
|
///
|
||||||
|
/// Takes a [Staff] entity with updated fields and persists changes
|
||||||
|
/// through the data layer. Returns the updated [Staff] entity.
|
||||||
|
Future<Staff> updateStaffProfile(Staff staff);
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
import '../repositories/personal_info_repository_interface.dart';
|
||||||
|
|
||||||
|
/// Arguments for getting staff profile information.
|
||||||
|
class GetPersonalInfoArguments extends UseCaseArgument {
|
||||||
|
/// The staff member's ID.
|
||||||
|
final String staffId;
|
||||||
|
|
||||||
|
const GetPersonalInfoArguments({required this.staffId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [staffId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use case for retrieving staff profile 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 UseCase<GetPersonalInfoArguments, Staff> {
|
||||||
|
final PersonalInfoRepositoryInterface _repository;
|
||||||
|
|
||||||
|
/// Creates a [GetPersonalInfoUseCase].
|
||||||
|
///
|
||||||
|
/// Requires a [PersonalInfoRepositoryInterface] to fetch data.
|
||||||
|
GetPersonalInfoUseCase(this._repository);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Staff> call(GetPersonalInfoArguments arguments) {
|
||||||
|
return _repository.getStaffProfile(arguments.staffId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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.
|
||||||
|
class UpdatePersonalInfoArguments extends UseCaseArgument {
|
||||||
|
/// The staff entity with updated information.
|
||||||
|
final Staff staff;
|
||||||
|
|
||||||
|
const UpdatePersonalInfoArguments({required this.staff});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [staff];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
class UpdatePersonalInfoUseCase
|
||||||
|
implements UseCase<UpdatePersonalInfoArguments, Staff> {
|
||||||
|
final PersonalInfoRepositoryInterface _repository;
|
||||||
|
|
||||||
|
/// Creates an [UpdatePersonalInfoUseCase].
|
||||||
|
///
|
||||||
|
/// Requires a [PersonalInfoRepositoryInterface] to update data.
|
||||||
|
UpdatePersonalInfoUseCase(this._repository);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Staff> call(UpdatePersonalInfoArguments arguments) {
|
||||||
|
return _repository.updateStaffProfile(arguments.staff);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.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';
|
||||||
|
|
||||||
|
/// BLoC responsible for managing staff profile 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.
|
||||||
|
class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
|
||||||
|
implements Disposable {
|
||||||
|
final GetPersonalInfoUseCase _getPersonalInfoUseCase;
|
||||||
|
final UpdatePersonalInfoUseCase _updatePersonalInfoUseCase;
|
||||||
|
final String _staffId;
|
||||||
|
|
||||||
|
/// Creates a [PersonalInfoBloc].
|
||||||
|
///
|
||||||
|
/// Requires the staff ID to load and update the correct profile.
|
||||||
|
PersonalInfoBloc({
|
||||||
|
required GetPersonalInfoUseCase getPersonalInfoUseCase,
|
||||||
|
required UpdatePersonalInfoUseCase updatePersonalInfoUseCase,
|
||||||
|
required String staffId,
|
||||||
|
}) : _getPersonalInfoUseCase = getPersonalInfoUseCase,
|
||||||
|
_updatePersonalInfoUseCase = updatePersonalInfoUseCase,
|
||||||
|
_staffId = staffId,
|
||||||
|
super(const PersonalInfoState()) {
|
||||||
|
on<PersonalInfoLoadRequested>(_onLoadRequested);
|
||||||
|
on<PersonalInfoFieldUpdated>(_onFieldUpdated);
|
||||||
|
on<PersonalInfoSaveRequested>(_onSaveRequested);
|
||||||
|
on<PersonalInfoPhotoUploadRequested>(_onPhotoUploadRequested);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles loading staff profile information.
|
||||||
|
Future<void> _onLoadRequested(
|
||||||
|
PersonalInfoLoadRequested event,
|
||||||
|
Emitter<PersonalInfoState> emit,
|
||||||
|
) async {
|
||||||
|
emit(state.copyWith(status: PersonalInfoStatus.loading));
|
||||||
|
try {
|
||||||
|
final Staff staff = await _getPersonalInfoUseCase(
|
||||||
|
GetPersonalInfoArguments(staffId: _staffId),
|
||||||
|
);
|
||||||
|
emit(state.copyWith(
|
||||||
|
status: PersonalInfoStatus.loaded,
|
||||||
|
staff: staff,
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
emit(state.copyWith(
|
||||||
|
status: PersonalInfoStatus.error,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles updating a field value in the current staff profile.
|
||||||
|
void _onFieldUpdated(
|
||||||
|
PersonalInfoFieldUpdated event,
|
||||||
|
Emitter<PersonalInfoState> emit,
|
||||||
|
) {
|
||||||
|
if (state.staff == null) return;
|
||||||
|
|
||||||
|
final Staff updatedStaff = _updateField(state.staff!, event.field, event.value);
|
||||||
|
emit(state.copyWith(staff: updatedStaff));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates a specific field in the Staff entity.
|
||||||
|
///
|
||||||
|
/// Returns a new Staff instance with the updated field.
|
||||||
|
Staff _updateField(Staff staff, String field, String value) {
|
||||||
|
// Note: Staff entity doesn't have a copyWith method or bio/languages/locations fields
|
||||||
|
// These fields would need to be added to the Staff entity or handled differently
|
||||||
|
// For now, we're just returning the same staff
|
||||||
|
// TODO: Add support for bio, languages, preferred locations to Staff entity
|
||||||
|
switch (field) {
|
||||||
|
case 'phone':
|
||||||
|
// Since Staff is immutable and doesn't have copyWith, we'd need to create a new instance
|
||||||
|
return Staff(
|
||||||
|
id: staff.id,
|
||||||
|
authProviderId: staff.authProviderId,
|
||||||
|
name: staff.name,
|
||||||
|
email: staff.email,
|
||||||
|
phone: value,
|
||||||
|
status: staff.status,
|
||||||
|
address: staff.address,
|
||||||
|
avatar: staff.avatar,
|
||||||
|
livePhoto: staff.livePhoto,
|
||||||
|
);
|
||||||
|
case 'address':
|
||||||
|
return Staff(
|
||||||
|
id: staff.id,
|
||||||
|
authProviderId: staff.authProviderId,
|
||||||
|
name: staff.name,
|
||||||
|
email: staff.email,
|
||||||
|
phone: staff.phone,
|
||||||
|
status: staff.status,
|
||||||
|
address: value,
|
||||||
|
avatar: staff.avatar,
|
||||||
|
livePhoto: staff.livePhoto,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return staff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles saving staff profile information.
|
||||||
|
Future<void> _onSaveRequested(
|
||||||
|
PersonalInfoSaveRequested event,
|
||||||
|
Emitter<PersonalInfoState> emit,
|
||||||
|
) async {
|
||||||
|
if (state.staff == null) return;
|
||||||
|
|
||||||
|
emit(state.copyWith(status: PersonalInfoStatus.saving));
|
||||||
|
try {
|
||||||
|
final Staff updatedStaff = await _updatePersonalInfoUseCase(
|
||||||
|
UpdatePersonalInfoArguments(staff: state.staff!),
|
||||||
|
);
|
||||||
|
emit(state.copyWith(
|
||||||
|
status: PersonalInfoStatus.saved,
|
||||||
|
staff: updatedStaff,
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
emit(state.copyWith(
|
||||||
|
status: PersonalInfoStatus.error,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles uploading a profile photo.
|
||||||
|
Future<void> _onPhotoUploadRequested(
|
||||||
|
PersonalInfoPhotoUploadRequested event,
|
||||||
|
Emitter<PersonalInfoState> emit,
|
||||||
|
) async {
|
||||||
|
if (state.staff == null) return;
|
||||||
|
|
||||||
|
emit(state.copyWith(status: PersonalInfoStatus.uploadingPhoto));
|
||||||
|
try {
|
||||||
|
// TODO: Implement photo upload when repository method is available
|
||||||
|
// final photoUrl = await _repository.uploadProfilePhoto(event.filePath);
|
||||||
|
// final updatedStaff = Staff(...);
|
||||||
|
// emit(state.copyWith(
|
||||||
|
// status: PersonalInfoStatus.loaded,
|
||||||
|
// staff: updatedStaff,
|
||||||
|
// ));
|
||||||
|
|
||||||
|
// For now, just return to loaded state
|
||||||
|
emit(state.copyWith(status: PersonalInfoStatus.loaded));
|
||||||
|
} catch (e) {
|
||||||
|
emit(state.copyWith(
|
||||||
|
status: PersonalInfoStatus.error,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Base class for all Personal Info events.
|
||||||
|
abstract class PersonalInfoEvent extends Equatable {
|
||||||
|
const PersonalInfoEvent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event to load personal information.
|
||||||
|
class PersonalInfoLoadRequested extends PersonalInfoEvent {
|
||||||
|
const PersonalInfoLoadRequested();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event to update a field value.
|
||||||
|
class PersonalInfoFieldUpdated extends PersonalInfoEvent {
|
||||||
|
final String field;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
const PersonalInfoFieldUpdated({
|
||||||
|
required this.field,
|
||||||
|
required this.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [field, value];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event to save personal information.
|
||||||
|
class PersonalInfoSaveRequested extends PersonalInfoEvent {
|
||||||
|
const PersonalInfoSaveRequested();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event to upload a profile photo.
|
||||||
|
class PersonalInfoPhotoUploadRequested extends PersonalInfoEvent {
|
||||||
|
final String filePath;
|
||||||
|
|
||||||
|
const PersonalInfoPhotoUploadRequested({required this.filePath});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [filePath];
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
/// Represents the status of personal info operations.
|
||||||
|
enum PersonalInfoStatus {
|
||||||
|
/// Initial state.
|
||||||
|
initial,
|
||||||
|
|
||||||
|
/// Loading data.
|
||||||
|
loading,
|
||||||
|
|
||||||
|
/// Data loaded successfully.
|
||||||
|
loaded,
|
||||||
|
|
||||||
|
/// Saving data.
|
||||||
|
saving,
|
||||||
|
|
||||||
|
/// Data saved successfully.
|
||||||
|
saved,
|
||||||
|
|
||||||
|
/// Uploading photo.
|
||||||
|
uploadingPhoto,
|
||||||
|
|
||||||
|
/// An error occurred.
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State for the Personal Info BLoC.
|
||||||
|
///
|
||||||
|
/// Uses the shared [Staff] entity from the domain layer.
|
||||||
|
class PersonalInfoState extends Equatable {
|
||||||
|
/// The current status of the operation.
|
||||||
|
final PersonalInfoStatus status;
|
||||||
|
|
||||||
|
/// The staff profile information.
|
||||||
|
final Staff? staff;
|
||||||
|
|
||||||
|
/// Error message if an error occurred.
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
/// Creates a [PersonalInfoState].
|
||||||
|
const PersonalInfoState({
|
||||||
|
this.status = PersonalInfoStatus.initial,
|
||||||
|
this.staff,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Creates a copy of this state with the given fields replaced.
|
||||||
|
PersonalInfoState copyWith({
|
||||||
|
PersonalInfoStatus? status,
|
||||||
|
Staff? staff,
|
||||||
|
String? errorMessage,
|
||||||
|
}) {
|
||||||
|
return PersonalInfoState(
|
||||||
|
status: status ?? this.status,
|
||||||
|
staff: staff ?? this.staff,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [status, staff, errorMessage];
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
|
||||||
|
/// Typed navigation extensions for the Staff Profile Info feature.
|
||||||
|
///
|
||||||
|
/// Provides type-safe navigation methods to avoid magic strings
|
||||||
|
/// throughout the codebase.
|
||||||
|
extension ProfileInfoNavigator on IModularNavigator {
|
||||||
|
/// Navigates to the Personal Info page.
|
||||||
|
///
|
||||||
|
/// This page allows staff members to edit their personal information
|
||||||
|
/// including phone, bio, languages, and preferred locations.
|
||||||
|
Future<void> pushPersonalInfo() {
|
||||||
|
return pushNamed('/profile/onboarding/personal-info');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the Emergency Contact page.
|
||||||
|
///
|
||||||
|
/// TODO: Implement when emergency contact page is created.
|
||||||
|
Future<void> pushEmergencyContact() {
|
||||||
|
return pushNamed('/profile/onboarding/emergency-contact');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the Experience page.
|
||||||
|
///
|
||||||
|
/// TODO: Implement when experience page is created.
|
||||||
|
Future<void> pushExperience() {
|
||||||
|
return pushNamed('/profile/onboarding/experience');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the Attire page.
|
||||||
|
///
|
||||||
|
/// TODO: Implement when attire page is created.
|
||||||
|
Future<void> pushAttire() {
|
||||||
|
return pushNamed('/profile/onboarding/attire');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
|
||||||
|
/// Typed navigation extensions for the Staff Profile Info feature.
|
||||||
|
///
|
||||||
|
/// Provides type-safe navigation methods to avoid magic strings
|
||||||
|
/// throughout the codebase.
|
||||||
|
extension ProfileInfoNavigator on IModularNavigator {
|
||||||
|
/// Navigates to the Personal Info page.
|
||||||
|
///
|
||||||
|
/// This page allows staff members to edit their personal information
|
||||||
|
/// including phone, bio, languages, and preferred locations.
|
||||||
|
Future<void> pushPersonalInfo() {
|
||||||
|
return pushNamed('/profile/onboarding/personal-info');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the Emergency Contact page.
|
||||||
|
///
|
||||||
|
/// TODO: Implement when emergency contact page is created.
|
||||||
|
Future<void> pushEmergencyContact() {
|
||||||
|
return pushNamed('/profile/onboarding/emergency-contact');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the Experience page.
|
||||||
|
///
|
||||||
|
/// TODO: Implement when experience page is created.
|
||||||
|
Future<void> pushExperience() {
|
||||||
|
return pushNamed('/profile/onboarding/experience');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the Attire page.
|
||||||
|
///
|
||||||
|
/// TODO: Implement when attire page is created.
|
||||||
|
Future<void> pushAttire() {
|
||||||
|
return pushNamed('/profile/onboarding/attire');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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 '../blocs/personal_info_bloc.dart';
|
||||||
|
import '../blocs/personal_info_event.dart';
|
||||||
|
import '../blocs/personal_info_state.dart';
|
||||||
|
import '../widgets/personal_info_content.dart';
|
||||||
|
|
||||||
|
/// The Personal Info page for staff onboarding.
|
||||||
|
///
|
||||||
|
/// This page allows staff members to view and edit their personal information
|
||||||
|
/// including phone number, bio, languages, and preferred locations.
|
||||||
|
/// 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 principles.
|
||||||
|
class PersonalInfoPage extends StatelessWidget {
|
||||||
|
/// Creates a [PersonalInfoPage].
|
||||||
|
const PersonalInfoPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider<PersonalInfoBloc>(
|
||||||
|
create: (context) => Modular.get<PersonalInfoBloc>()
|
||||||
|
..add(const PersonalInfoLoadRequested()),
|
||||||
|
child: const _PersonalInfoPageContent(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal content widget that reacts to BLoC state changes.
|
||||||
|
class _PersonalInfoPageContent extends StatelessWidget {
|
||||||
|
const _PersonalInfoPageContent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final i18n = t.staff.onboarding.personal_info;
|
||||||
|
|
||||||
|
return BlocListener<PersonalInfoBloc, PersonalInfoState>(
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state.status == PersonalInfoStatus.saved) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(i18n.save_success),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Modular.to.pop();
|
||||||
|
} else if (state.status == PersonalInfoStatus.error) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(state.errorMessage ?? 'An error occurred'),
|
||||||
|
backgroundColor: UiColors.destructive,
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: UiColors.background,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: UiColors.bgPopup,
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: Icon(UiIcons.chevronLeft, color: UiColors.textSecondary),
|
||||||
|
onPressed: () => Modular.to.pop(),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
i18n.title,
|
||||||
|
style: UiTypography.title1m.copyWith(color: UiColors.textPrimary),
|
||||||
|
),
|
||||||
|
bottom: PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(1.0),
|
||||||
|
child: Container(
|
||||||
|
color: UiColors.border,
|
||||||
|
height: 1.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: BlocBuilder<PersonalInfoBloc, PersonalInfoState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state.status == PersonalInfoStatus.loading ||
|
||||||
|
state.status == PersonalInfoStatus.initial) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.staff == null) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'Failed to load personal information',
|
||||||
|
style: UiTypography.body1r.copyWith(
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return PersonalInfoContent(staff: state.staff!);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
import '../blocs/personal_info_bloc.dart';
|
||||||
|
import '../blocs/personal_info_event.dart';
|
||||||
|
import '../blocs/personal_info_state.dart';
|
||||||
|
import 'profile_photo_widget.dart';
|
||||||
|
import 'personal_info_form.dart';
|
||||||
|
import '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.
|
||||||
|
/// Works with the shared [Staff] entity from the domain layer.
|
||||||
|
class PersonalInfoContent extends StatefulWidget {
|
||||||
|
/// The staff profile to display and edit.
|
||||||
|
final Staff staff;
|
||||||
|
|
||||||
|
/// Creates a [PersonalInfoContent].
|
||||||
|
const PersonalInfoContent({
|
||||||
|
super.key,
|
||||||
|
required this.staff,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PersonalInfoContent> createState() => _PersonalInfoContentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PersonalInfoContentState extends State<PersonalInfoContent> {
|
||||||
|
late final TextEditingController _phoneController;
|
||||||
|
late final TextEditingController _addressController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_phoneController = TextEditingController(text: widget.staff.phone ?? '');
|
||||||
|
_addressController = TextEditingController(text: widget.staff.address ?? '');
|
||||||
|
|
||||||
|
// Listen to changes and update BLoC
|
||||||
|
_phoneController.addListener(_onPhoneChanged);
|
||||||
|
_addressController.addListener(_onAddressChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_phoneController.dispose();
|
||||||
|
_addressController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPhoneChanged() {
|
||||||
|
context.read<PersonalInfoBloc>().add(
|
||||||
|
PersonalInfoFieldUpdated(
|
||||||
|
field: 'phone',
|
||||||
|
value: _phoneController.text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAddressChanged() {
|
||||||
|
context.read<PersonalInfoBloc>().add(
|
||||||
|
PersonalInfoFieldUpdated(
|
||||||
|
field: 'address',
|
||||||
|
value: _addressController.text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSave() {
|
||||||
|
context.read<PersonalInfoBloc>().add(const PersonalInfoSaveRequested());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handlePhotoTap() {
|
||||||
|
// TODO: Implement photo picker
|
||||||
|
// context.read<PersonalInfoBloc>().add(
|
||||||
|
// PersonalInfoPhotoUploadRequested(filePath: pickedFilePath),
|
||||||
|
// );
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocBuilder<PersonalInfoBloc, PersonalInfoState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
final isSaving = state.status == PersonalInfoStatus.saving;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.all(UiConstants.space5),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ProfilePhotoWidget(
|
||||||
|
photoUrl: widget.staff.avatar,
|
||||||
|
fullName: widget.staff.name,
|
||||||
|
onTap: isSaving ? null : _handlePhotoTap,
|
||||||
|
),
|
||||||
|
SizedBox(height: UiConstants.space6),
|
||||||
|
PersonalInfoForm(
|
||||||
|
fullName: widget.staff.name,
|
||||||
|
email: widget.staff.email,
|
||||||
|
phoneController: _phoneController,
|
||||||
|
addressController: _addressController,
|
||||||
|
enabled: !isSaving,
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: UiConstants.space16,
|
||||||
|
), // Space for bottom button
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SaveButton(
|
||||||
|
onPressed: isSaving ? null : _handleSave,
|
||||||
|
label: t.staff.onboarding.personal_info.save_button,
|
||||||
|
isLoading: isSaving,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
|
||||||
|
/// A form widget containing all personal information fields.
|
||||||
|
///
|
||||||
|
/// Includes read-only fields for full name and email,
|
||||||
|
/// and editable fields for phone and address.
|
||||||
|
class PersonalInfoForm extends StatelessWidget {
|
||||||
|
/// The staff member's full name (read-only).
|
||||||
|
final String fullName;
|
||||||
|
|
||||||
|
/// The staff member's email (read-only).
|
||||||
|
final String email;
|
||||||
|
|
||||||
|
/// Controller for the phone number field.
|
||||||
|
final TextEditingController phoneController;
|
||||||
|
|
||||||
|
/// Controller for the address field.
|
||||||
|
final TextEditingController addressController;
|
||||||
|
|
||||||
|
/// Whether the form fields are enabled for editing.
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
/// Creates a [PersonalInfoForm].
|
||||||
|
const PersonalInfoForm({
|
||||||
|
super.key,
|
||||||
|
required this.fullName,
|
||||||
|
required this.email,
|
||||||
|
required this.phoneController,
|
||||||
|
required this.addressController,
|
||||||
|
this.enabled = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final i18n = t.staff.onboarding.personal_info;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_FieldLabel(text: i18n.full_name_label),
|
||||||
|
_ReadOnlyField(value: fullName),
|
||||||
|
SizedBox(height: UiConstants.space4),
|
||||||
|
|
||||||
|
_FieldLabel(text: i18n.email_label),
|
||||||
|
_ReadOnlyField(value: email),
|
||||||
|
SizedBox(height: UiConstants.space4),
|
||||||
|
|
||||||
|
_FieldLabel(text: i18n.phone_label),
|
||||||
|
_EditableField(
|
||||||
|
controller: phoneController,
|
||||||
|
hint: i18n.phone_hint,
|
||||||
|
enabled: enabled,
|
||||||
|
),
|
||||||
|
SizedBox(height: UiConstants.space4),
|
||||||
|
|
||||||
|
_FieldLabel(text: i18n.locations_label),
|
||||||
|
_EditableField(
|
||||||
|
controller: addressController,
|
||||||
|
hint: i18n.locations_hint,
|
||||||
|
enabled: enabled,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A label widget for form fields.
|
||||||
|
class _FieldLabel extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
const _FieldLabel({required this.text});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: UiConstants.space2),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: UiTypography.body2m.copyWith(color: UiColors.textPrimary),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A read-only field widget for displaying non-editable information.
|
||||||
|
class _ReadOnlyField extends StatelessWidget {
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
const _ReadOnlyField({required this.value});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space3,
|
||||||
|
vertical: UiConstants.space3,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.bgSecondary,
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||||
|
border: Border.all(color: UiColors.border),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: UiTypography.body2r.copyWith(color: UiColors.textPrimary),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An editable text field widget.
|
||||||
|
class _EditableField extends StatelessWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String hint;
|
||||||
|
final int maxLines;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
const _EditableField({
|
||||||
|
required this.controller,
|
||||||
|
required this.hint,
|
||||||
|
this.maxLines = 1,
|
||||||
|
this.enabled = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextField(
|
||||||
|
controller: controller,
|
||||||
|
maxLines: maxLines,
|
||||||
|
enabled: enabled,
|
||||||
|
style: UiTypography.body2r.copyWith(color: UiColors.textPrimary),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: UiTypography.body2r.copyWith(color: UiColors.textSecondary),
|
||||||
|
contentPadding: EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space3,
|
||||||
|
vertical: UiConstants.space3,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||||
|
borderSide: BorderSide(color: UiColors.border),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||||
|
borderSide: BorderSide(color: UiColors.border),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||||
|
borderSide: BorderSide(color: UiColors.primary),
|
||||||
|
),
|
||||||
|
fillColor: UiColors.bgPopup,
|
||||||
|
filled: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
|
||||||
|
/// A widget displaying the staff member's profile photo with an edit option.
|
||||||
|
///
|
||||||
|
/// Shows either the photo URL or an initial avatar if no photo is available.
|
||||||
|
/// Includes a camera icon button for changing the photo.
|
||||||
|
class ProfilePhotoWidget extends StatelessWidget {
|
||||||
|
/// The URL of the staff member's photo.
|
||||||
|
final String? photoUrl;
|
||||||
|
|
||||||
|
/// The staff member's full name (used for initial avatar).
|
||||||
|
final String fullName;
|
||||||
|
|
||||||
|
/// Callback when the photo/camera button is tapped.
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
/// Creates a [ProfilePhotoWidget].
|
||||||
|
const ProfilePhotoWidget({
|
||||||
|
super.key,
|
||||||
|
required this.photoUrl,
|
||||||
|
required this.fullName,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final i18n = t.staff.onboarding.personal_info;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: UiColors.primary.withOpacity(0.1),
|
||||||
|
),
|
||||||
|
child: photoUrl != null
|
||||||
|
? ClipOval(
|
||||||
|
child: Image.network(
|
||||||
|
photoUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Center(
|
||||||
|
child: Text(
|
||||||
|
fullName.isNotEmpty ? fullName[0].toUpperCase() : '?',
|
||||||
|
style: UiTypography.displayL.copyWith(
|
||||||
|
color: UiColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.bgPopup,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: UiColors.border),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.textPrimary.withOpacity(0.1),
|
||||||
|
blurRadius: UiConstants.space1,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
UiIcons.camera,
|
||||||
|
size: 16,
|
||||||
|
color: UiColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: UiConstants.space3),
|
||||||
|
Text(
|
||||||
|
i18n.change_photo_hint,
|
||||||
|
style: UiTypography.body2r.copyWith(color: UiColors.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
|
||||||
|
/// A save button widget for the bottom of the personal info page.
|
||||||
|
///
|
||||||
|
/// Displays a full-width button with a save icon and customizable label.
|
||||||
|
class SaveButton extends StatelessWidget {
|
||||||
|
/// Callback when the button is pressed.
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
|
/// The button label text.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Whether to show a loading indicator.
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
/// Creates a [SaveButton].
|
||||||
|
const SaveButton({
|
||||||
|
super.key,
|
||||||
|
required this.onPressed,
|
||||||
|
required this.label,
|
||||||
|
this.isLoading = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.all(UiConstants.space5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.bgPopup,
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(color: UiColors.border),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 48,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: onPressed,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: UiColors.primary,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
child: isLoading
|
||||||
|
? SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
UiColors.bgPopup,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(UiIcons.check, color: UiColors.bgPopup, size: 20),
|
||||||
|
SizedBox(width: UiConstants.space2),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: UiTypography.body1m.copyWith(
|
||||||
|
color: UiColors.bgPopup,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
|
||||||
|
import 'data/repositories/personal_info_repository_mock.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';
|
||||||
|
|
||||||
|
/// 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 (mock for now, will use real impl later)
|
||||||
|
/// - Registers use cases that contain business logic
|
||||||
|
/// - Registers BLoC for state management
|
||||||
|
/// - Defines routes for navigation
|
||||||
|
class StaffProfileInfoModule extends Module {
|
||||||
|
@override
|
||||||
|
void binds(Injector i) {
|
||||||
|
// Repository - using mock for now
|
||||||
|
// TODO: Replace with PersonalInfoRepositoryImpl when Firebase Data Connect is configured
|
||||||
|
i.addLazySingleton<PersonalInfoRepositoryInterface>(
|
||||||
|
PersonalInfoRepositoryMock.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use Cases - delegate business logic to repository
|
||||||
|
i.addLazySingleton<GetPersonalInfoUseCase>(
|
||||||
|
() => GetPersonalInfoUseCase(i.get<PersonalInfoRepositoryInterface>()),
|
||||||
|
);
|
||||||
|
i.addLazySingleton<UpdatePersonalInfoUseCase>(
|
||||||
|
() => UpdatePersonalInfoUseCase(i.get<PersonalInfoRepositoryInterface>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// BLoC - manages presentation state
|
||||||
|
// TODO: Get actual staffId from authentication state
|
||||||
|
i.addLazySingleton<PersonalInfoBloc>(
|
||||||
|
() => PersonalInfoBloc(
|
||||||
|
getPersonalInfoUseCase: i.get<GetPersonalInfoUseCase>(),
|
||||||
|
updatePersonalInfoUseCase: i.get<UpdatePersonalInfoUseCase>(),
|
||||||
|
staffId: 'mock-staff-1', // TODO: Get from auth
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void routes(RouteManager r) {
|
||||||
|
r.child(
|
||||||
|
'/personal-info',
|
||||||
|
child: (BuildContext context) => const PersonalInfoPage(),
|
||||||
|
);
|
||||||
|
// Additional routes will be added as more onboarding pages are implemented
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/// Export the modular feature definition.
|
||||||
|
export 'src/staff_profile_info_module.dart';
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
name: staff_profile_info
|
||||||
|
description: Staff profile information feature package.
|
||||||
|
version: 0.0.1
|
||||||
|
publish_to: none
|
||||||
|
resolution: workspace
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: '>=3.10.0 <4.0.0'
|
||||||
|
flutter: ">=3.0.0"
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_bloc: ^8.1.0
|
||||||
|
bloc: ^8.1.0
|
||||||
|
flutter_modular: ^6.3.0
|
||||||
|
equatable: ^2.0.5
|
||||||
|
|
||||||
|
# Architecture Packages
|
||||||
|
design_system:
|
||||||
|
path: ../../../../../design_system
|
||||||
|
core_localization:
|
||||||
|
path: ../../../../../core_localization
|
||||||
|
krow_core:
|
||||||
|
path: ../../../../../core
|
||||||
|
krow_domain:
|
||||||
|
path: ../../../../../domain
|
||||||
|
krow_data_connect:
|
||||||
|
path: ../../../../../data_connect
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
bloc_test: ^9.1.0
|
||||||
|
mocktail: ^1.0.0
|
||||||
|
flutter_lints: ^6.0.0
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
@@ -1071,6 +1071,13 @@ packages:
|
|||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.0.1"
|
version: "0.0.1"
|
||||||
|
staff_profile_info:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
path: "packages/features/staff/profile_sections/onboarding/profile_info"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "0.0.1"
|
||||||
stream_channel:
|
stream_channel:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user