diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index a08a4bce..5abb403e 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -507,6 +507,24 @@ "logout": { "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" + } } } } diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 3d5e5394..c3aa03e7 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -506,6 +506,24 @@ "logout": { "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" + } } } } diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/navigation/profile_navigator.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/navigation/profile_navigator.dart index e71d7067..aba723c5 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/navigation/profile_navigator.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/navigation/profile_navigator.dart @@ -8,22 +8,22 @@ import 'package:flutter_modular/flutter_modular.dart'; extension ProfileNavigator on IModularNavigator { /// Navigates to the personal info page. void pushPersonalInfo() { - pushNamed('/personal-info'); + pushNamed('/profile/onboarding/personal-info'); } /// Navigates to the emergency contact page. void pushEmergencyContact() { - pushNamed('/emergency-contact'); + pushNamed('/profile/onboarding/emergency-contact'); } /// Navigates to the experience page. void pushExperience() { - pushNamed('/experience'); + pushNamed('/profile/onboarding/experience'); } /// Navigates to the attire page. void pushAttire() { - pushNamed('/attire'); + pushNamed('/profile/onboarding/attire'); } /// Navigates to the documents page. diff --git a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart index 4ad1dcca..64bba981 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.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 'domain/repositories/profile_repository.dart'; @@ -51,5 +52,6 @@ class StaffProfileModule extends Module { @override void routes(RouteManager r) { r.child('/', child: (BuildContext context) => const StaffProfilePage()); + r.module('/onboarding', module: StaffProfileInfoModule()); } } diff --git a/apps/mobile/packages/features/staff/profile/pubspec.yaml b/apps/mobile/packages/features/staff/profile/pubspec.yaml index 8552ba94..6f72c239 100644 --- a/apps/mobile/packages/features/staff/profile/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile/pubspec.yaml @@ -28,6 +28,10 @@ dependencies: path: ../../../domain krow_data_connect: path: ../../../data_connect + + # Feature Packages + staff_profile_info: + path: ../profile_sections/onboarding/profile_info dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/data/repositories/personal_info_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/data/repositories/personal_info_repository_impl.dart new file mode 100644 index 00000000..1674b42b --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/data/repositories/personal_info_repository_impl.dart @@ -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 getStaffProfile(String staffId) async { + // Query staff data from Firebase Data Connect + final QueryResult 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 updateStaffProfile(Staff staff) async { + // Update staff data through Firebase Data Connect + final OperationResult 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 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 + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/data/repositories/personal_info_repository_mock.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/data/repositories/personal_info_repository_mock.dart new file mode 100644 index 00000000..e19847f9 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/data/repositories/personal_info_repository_mock.dart @@ -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 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 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 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'; + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/repositories/personal_info_repository_interface.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/repositories/personal_info_repository_interface.dart new file mode 100644 index 00000000..bb327203 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/repositories/personal_info_repository_interface.dart @@ -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 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 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 uploadProfilePhoto(String filePath); +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/usecases/get_personal_info_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/usecases/get_personal_info_usecase.dart new file mode 100644 index 00000000..51c44264 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/usecases/get_personal_info_usecase.dart @@ -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 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 { + final PersonalInfoRepositoryInterface _repository; + + /// Creates a [GetPersonalInfoUseCase]. + /// + /// Requires a [PersonalInfoRepositoryInterface] to fetch data. + GetPersonalInfoUseCase(this._repository); + + @override + Future call(GetPersonalInfoArguments arguments) { + return _repository.getStaffProfile(arguments.staffId); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/usecases/update_personal_info_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/usecases/update_personal_info_usecase.dart new file mode 100644 index 00000000..ea399231 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/domain/usecases/update_personal_info_usecase.dart @@ -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 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 { + final PersonalInfoRepositoryInterface _repository; + + /// Creates an [UpdatePersonalInfoUseCase]. + /// + /// Requires a [PersonalInfoRepositoryInterface] to update data. + UpdatePersonalInfoUseCase(this._repository); + + @override + Future call(UpdatePersonalInfoArguments arguments) { + return _repository.updateStaffProfile(arguments.staff); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_bloc.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_bloc.dart new file mode 100644 index 00000000..7bba02b1 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_bloc.dart @@ -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 + 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(_onLoadRequested); + on(_onFieldUpdated); + on(_onSaveRequested); + on(_onPhotoUploadRequested); + } + + /// Handles loading staff profile information. + Future _onLoadRequested( + PersonalInfoLoadRequested event, + Emitter 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 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 _onSaveRequested( + PersonalInfoSaveRequested event, + Emitter 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 _onPhotoUploadRequested( + PersonalInfoPhotoUploadRequested event, + Emitter 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(); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_event.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_event.dart new file mode 100644 index 00000000..05958631 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_event.dart @@ -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 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 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 get props => [filePath]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_state.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_state.dart new file mode 100644 index 00000000..5641a4ed --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/blocs/personal_info_state.dart @@ -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 get props => [status, staff, errorMessage]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/navigation/onboarding_navigator.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/navigation/onboarding_navigator.dart new file mode 100644 index 00000000..57ba732b --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/navigation/onboarding_navigator.dart @@ -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 pushPersonalInfo() { + return pushNamed('/profile/onboarding/personal-info'); + } + + /// Navigates to the Emergency Contact page. + /// + /// TODO: Implement when emergency contact page is created. + Future pushEmergencyContact() { + return pushNamed('/profile/onboarding/emergency-contact'); + } + + /// Navigates to the Experience page. + /// + /// TODO: Implement when experience page is created. + Future pushExperience() { + return pushNamed('/profile/onboarding/experience'); + } + + /// Navigates to the Attire page. + /// + /// TODO: Implement when attire page is created. + Future pushAttire() { + return pushNamed('/profile/onboarding/attire'); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/navigation/profile_info_navigator.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/navigation/profile_info_navigator.dart new file mode 100644 index 00000000..57ba732b --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/navigation/profile_info_navigator.dart @@ -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 pushPersonalInfo() { + return pushNamed('/profile/onboarding/personal-info'); + } + + /// Navigates to the Emergency Contact page. + /// + /// TODO: Implement when emergency contact page is created. + Future pushEmergencyContact() { + return pushNamed('/profile/onboarding/emergency-contact'); + } + + /// Navigates to the Experience page. + /// + /// TODO: Implement when experience page is created. + Future pushExperience() { + return pushNamed('/profile/onboarding/experience'); + } + + /// Navigates to the Attire page. + /// + /// TODO: Implement when attire page is created. + Future pushAttire() { + return pushNamed('/profile/onboarding/attire'); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart new file mode 100644 index 00000000..8b3ec989 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart @@ -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( + create: (context) => Modular.get() + ..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( + 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( + 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!); + }, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_content.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_content.dart new file mode 100644 index 00000000..14734347 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_content.dart @@ -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 createState() => _PersonalInfoContentState(); +} + +class _PersonalInfoContentState extends State { + 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().add( + PersonalInfoFieldUpdated( + field: 'phone', + value: _phoneController.text, + ), + ); + } + + void _onAddressChanged() { + context.read().add( + PersonalInfoFieldUpdated( + field: 'address', + value: _addressController.text, + ), + ); + } + + void _handleSave() { + context.read().add(const PersonalInfoSaveRequested()); + } + + void _handlePhotoTap() { + // TODO: Implement photo picker + // context.read().add( + // PersonalInfoPhotoUploadRequested(filePath: pickedFilePath), + // ); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + 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, + ), + ], + ); + }, + ); + } +} \ No newline at end of file diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart new file mode 100644 index 00000000..b175a645 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart @@ -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, + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/profile_photo_widget.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/profile_photo_widget.dart new file mode 100644 index 00000000..f625f9c7 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/profile_photo_widget.dart @@ -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), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/save_button.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/save_button.dart new file mode 100644 index 00000000..e6e8a074 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/save_button.dart @@ -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( + 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, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/staff_profile_info_module.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/staff_profile_info_module.dart new file mode 100644 index 00000000..5b41a59d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/staff_profile_info_module.dart @@ -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( + PersonalInfoRepositoryMock.new, + ); + + // Use Cases - delegate business logic to repository + i.addLazySingleton( + () => GetPersonalInfoUseCase(i.get()), + ); + i.addLazySingleton( + () => UpdatePersonalInfoUseCase(i.get()), + ); + + // BLoC - manages presentation state + // TODO: Get actual staffId from authentication state + i.addLazySingleton( + () => PersonalInfoBloc( + getPersonalInfoUseCase: i.get(), + updatePersonalInfoUseCase: i.get(), + 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 + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/staff_profile_info.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/staff_profile_info.dart new file mode 100644 index 00000000..28387ab4 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/staff_profile_info.dart @@ -0,0 +1,2 @@ +/// Export the modular feature definition. +export 'src/staff_profile_info_module.dart'; diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/pubspec.yaml new file mode 100644 index 00000000..86592162 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/pubspec.yaml @@ -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 diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 99c2d280..ed6afc89 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -1071,6 +1071,13 @@ packages: relative: true source: path 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: dependency: transitive description: