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:
Achintha Isuru
2026-01-24 19:28:14 -05:00
parent 5039743c03
commit 0cfc19fa60
24 changed files with 1302 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
/// Export the modular feature definition.
export 'src/staff_profile_info_module.dart';

View File

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

View File

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