feat: Update navigation paths and enhance personal info page with design system compliance

This commit is contained in:
Achintha Isuru
2026-01-24 19:57:10 -05:00
parent 0cfc19fa60
commit caaf972349
9 changed files with 139 additions and 133 deletions

View File

@@ -8,7 +8,7 @@ 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('/profile/onboarding/personal-info'); pushNamed('./onboarding/personal-info');
} }
/// Navigates to the emergency contact page. /// Navigates to the emergency contact page.

View File

@@ -10,7 +10,7 @@ extension ProfileInfoNavigator on IModularNavigator {
/// This page allows staff members to edit their personal information /// This page allows staff members to edit their personal information
/// including phone, bio, languages, and preferred locations. /// including phone, bio, languages, and preferred locations.
Future<void> pushPersonalInfo() { Future<void> pushPersonalInfo() {
return pushNamed('/profile/onboarding/personal-info'); return pushNamed('./personal-info');
} }
/// Navigates to the Emergency Contact page. /// Navigates to the Emergency Contact page.

View File

@@ -10,7 +10,7 @@ extension ProfileInfoNavigator on IModularNavigator {
/// This page allows staff members to edit their personal information /// This page allows staff members to edit their personal information
/// including phone, bio, languages, and preferred locations. /// including phone, bio, languages, and preferred locations.
Future<void> pushPersonalInfo() { Future<void> pushPersonalInfo() {
return pushNamed('/profile/onboarding/personal-info'); return pushNamed('./personal-info');
} }
/// Navigates to the Emergency Contact page. /// Navigates to the Emergency Contact page.

View File

@@ -1,3 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
@@ -9,99 +10,91 @@ import '../blocs/personal_info_event.dart';
import '../blocs/personal_info_state.dart'; import '../blocs/personal_info_state.dart';
import '../widgets/personal_info_content.dart'; import '../widgets/personal_info_content.dart';
/// The Personal Info page for staff onboarding. /// The Personal Info page for staff onboarding.
/// ///
/// This page allows staff members to view and edit their personal information /// This page allows staff members to view and edit their personal information
/// including phone number, bio, languages, and preferred locations. /// including phone number and address. Full name and email are read-only as they come from authentication.
/// Full name and email are read-only as they come from authentication.
/// ///
/// This page is a StatelessWidget that uses BLoC for state management, /// This page is a StatelessWidget that uses BLoC for state management,
/// following Clean Architecture principles. /// following Clean Architecture and the design system guidelines.
class PersonalInfoPage extends StatelessWidget { class PersonalInfoPage extends StatelessWidget {
/// Creates a [PersonalInfoPage]. /// Creates a [PersonalInfoPage].
const PersonalInfoPage({super.key}); const PersonalInfoPage({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsStaffOnboardingPersonalInfoEn i18n = t.staff.onboarding.personal_info;
return BlocProvider<PersonalInfoBloc>( return BlocProvider<PersonalInfoBloc>(
create: (context) => Modular.get<PersonalInfoBloc>() create: (BuildContext context) => Modular.get<PersonalInfoBloc>()
..add(const PersonalInfoLoadRequested()), ..add(const PersonalInfoLoadRequested()),
child: const _PersonalInfoPageContent(), child: BlocListener<PersonalInfoBloc, PersonalInfoState>(
); listener: (BuildContext context, PersonalInfoState state) {
} if (state.status == PersonalInfoStatus.saved) {
} ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
/// Internal content widget that reacts to BLoC state changes. content: Text(i18n.save_success),
class _PersonalInfoPageContent extends StatelessWidget { duration: const Duration(seconds: 2),
const _PersonalInfoPageContent(); ),
);
@override Modular.to.pop();
Widget build(BuildContext context) { } else if (state.status == PersonalInfoStatus.error) {
final i18n = t.staff.onboarding.personal_info; ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
return BlocListener<PersonalInfoBloc, PersonalInfoState>( content: Text(state.errorMessage ?? 'An error occurred'),
listener: (context, state) { backgroundColor: UiColors.destructive,
if (state.status == PersonalInfoStatus.saved) { duration: const Duration(seconds: 3),
ScaffoldMessenger.of(context).showSnackBar( ),
SnackBar( );
content: Text(i18n.save_success), }
duration: const Duration(seconds: 2), },
child: Scaffold(
backgroundColor: UiColors.background,
appBar: AppBar(
backgroundColor: UiColors.bgPopup,
elevation: 0,
leading: IconButton(
icon: const Icon(UiIcons.chevronLeft, color: UiColors.textSecondary),
onPressed: () => Modular.to.pop(),
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
), ),
); title: Text(
Modular.to.pop(); i18n.title,
} else if (state.status == PersonalInfoStatus.error) { style: UiTypography.title1m.copyWith(color: UiColors.textPrimary),
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'An error occurred'),
backgroundColor: UiColors.destructive,
duration: const Duration(seconds: 3),
), ),
); bottom: PreferredSize(
} preferredSize: const Size.fromHeight(1.0),
}, child: Container(
child: Scaffold( color: UiColors.border,
backgroundColor: UiColors.background, height: 1.0,
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: SafeArea(
body: BlocBuilder<PersonalInfoBloc, PersonalInfoState>( child: BlocBuilder<PersonalInfoBloc, PersonalInfoState>(
builder: (context, state) { builder: (BuildContext context, PersonalInfoState state) {
if (state.status == PersonalInfoStatus.loading || if (state.status == PersonalInfoStatus.loading ||
state.status == PersonalInfoStatus.initial) { state.status == PersonalInfoStatus.initial) {
return const Center( return const Center(
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
); );
} }
if (state.staff == null) { if (state.staff == null) {
return Center( return Center(
child: Text( child: Text(
'Failed to load personal information', 'Failed to load personal information',
style: UiTypography.body1r.copyWith( style: UiTypography.body1r.copyWith(
color: UiColors.textSecondary, color: UiColors.textSecondary,
), ),
), ),
); );
} }
return PersonalInfoContent(staff: state.staff!); return PersonalInfoContent(staff: state.staff!);
}, },
),
),
), ),
), ),
); );

View File

@@ -11,10 +11,11 @@ import 'profile_photo_widget.dart';
import 'personal_info_form.dart'; import 'personal_info_form.dart';
import 'save_button.dart'; import 'save_button.dart';
/// Content widget that displays and manages the staff profile form. /// Content widget that displays and manages the staff profile form.
/// ///
/// This widget is extracted from the page to handle form state separately, /// This widget is extracted from the page to handle form state separately,
/// following Clean Architecture's separation of concerns principle. /// following Clean Architecture's separation of concerns principle and the design system guidelines.
/// Works with the shared [Staff] entity from the domain layer. /// Works with the shared [Staff] entity from the domain layer.
class PersonalInfoContent extends StatefulWidget { class PersonalInfoContent extends StatefulWidget {
/// The staff profile to display and edit. /// The staff profile to display and edit.
@@ -52,22 +53,23 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
super.dispose(); super.dispose();
} }
void _onPhoneChanged() { void _onPhoneChanged() {
context.read<PersonalInfoBloc>().add( context.read<PersonalInfoBloc>().add(
PersonalInfoFieldUpdated( PersonalInfoFieldUpdated(
field: 'phone', field: 'phone',
value: _phoneController.text, value: _phoneController.text,
), ),
); );
} }
void _onAddressChanged() { void _onAddressChanged() {
context.read<PersonalInfoBloc>().add( context.read<PersonalInfoBloc>().add(
PersonalInfoFieldUpdated( PersonalInfoFieldUpdated(
field: 'address', field: 'address',
value: _addressController.text, value: _addressController.text,
), ),
); );
} }
void _handleSave() { void _handleSave() {
@@ -83,23 +85,24 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsStaffOnboardingPersonalInfoEn i18n = t.staff.onboarding.personal_info;
return BlocBuilder<PersonalInfoBloc, PersonalInfoState>( return BlocBuilder<PersonalInfoBloc, PersonalInfoState>(
builder: (context, state) { builder: (BuildContext context, PersonalInfoState state) {
final isSaving = state.status == PersonalInfoStatus.saving; final bool isSaving = state.status == PersonalInfoStatus.saving;
return Column( return Column(
children: [ children: <Widget>[
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: EdgeInsets.all(UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space6),
child: Column( child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
ProfilePhotoWidget( ProfilePhotoWidget(
photoUrl: widget.staff.avatar, photoUrl: widget.staff.avatar,
fullName: widget.staff.name, fullName: widget.staff.name,
onTap: isSaving ? null : _handlePhotoTap, onTap: isSaving ? null : _handlePhotoTap,
), ),
SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
PersonalInfoForm( PersonalInfoForm(
fullName: widget.staff.name, fullName: widget.staff.name,
email: widget.staff.email, email: widget.staff.email,
@@ -107,16 +110,14 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
addressController: _addressController, addressController: _addressController,
enabled: !isSaving, enabled: !isSaving,
), ),
SizedBox( const SizedBox(height: UiConstants.space16), // Space for bottom button
height: UiConstants.space16,
), // Space for bottom button
], ],
), ),
), ),
), ),
SaveButton( SaveButton(
onPressed: isSaving ? null : _handleSave, onPressed: isSaving ? null : _handleSave,
label: t.staff.onboarding.personal_info.save_button, label: i18n.save_button,
isLoading: isSaving, isLoading: isSaving,
), ),
], ],

View File

@@ -2,10 +2,12 @@ import 'package:flutter/material.dart';
import 'package:core_localization/core_localization.dart'; import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
/// A form widget containing all personal information fields. /// A form widget containing all personal information fields.
/// ///
/// Includes read-only fields for full name and email, /// Includes read-only fields for full name and email,
/// and editable fields for phone and address. /// and editable fields for phone and address.
/// Uses only design system tokens for colors, typography, and spacing.
class PersonalInfoForm extends StatelessWidget { class PersonalInfoForm extends StatelessWidget {
/// The staff member's full name (read-only). /// The staff member's full name (read-only).
final String fullName; final String fullName;
@@ -34,28 +36,32 @@ class PersonalInfoForm extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final i18n = t.staff.onboarding.personal_info; final TranslationsStaffOnboardingPersonalInfoEn i18n = t.staff.onboarding.personal_info;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: <Widget>[
_FieldLabel(text: i18n.full_name_label), _FieldLabel(text: i18n.full_name_label),
const SizedBox(height: UiConstants.space2),
_ReadOnlyField(value: fullName), _ReadOnlyField(value: fullName),
SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
_FieldLabel(text: i18n.email_label), _FieldLabel(text: i18n.email_label),
const SizedBox(height: UiConstants.space2),
_ReadOnlyField(value: email), _ReadOnlyField(value: email),
SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
_FieldLabel(text: i18n.phone_label), _FieldLabel(text: i18n.phone_label),
const SizedBox(height: UiConstants.space2),
_EditableField( _EditableField(
controller: phoneController, controller: phoneController,
hint: i18n.phone_hint, hint: i18n.phone_hint,
enabled: enabled, enabled: enabled,
), ),
SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
_FieldLabel(text: i18n.locations_label), _FieldLabel(text: i18n.locations_label),
const SizedBox(height: UiConstants.space2),
_EditableField( _EditableField(
controller: addressController, controller: addressController,
hint: i18n.locations_hint, hint: i18n.locations_hint,
@@ -66,6 +72,7 @@ class PersonalInfoForm extends StatelessWidget {
} }
} }
/// A label widget for form fields.
/// A label widget for form fields. /// A label widget for form fields.
class _FieldLabel extends StatelessWidget { class _FieldLabel extends StatelessWidget {
final String text; final String text;
@@ -74,16 +81,14 @@ class _FieldLabel extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Text(
padding: EdgeInsets.only(bottom: UiConstants.space2), text,
child: Text( style: UiTypography.body2m.copyWith(color: UiColors.textPrimary),
text,
style: UiTypography.body2m.copyWith(color: UiColors.textPrimary),
),
); );
} }
} }
/// A read-only field widget for displaying non-editable information.
/// A read-only field widget for displaying non-editable information. /// A read-only field widget for displaying non-editable information.
class _ReadOnlyField extends StatelessWidget { class _ReadOnlyField extends StatelessWidget {
final String value; final String value;
@@ -94,7 +99,7 @@ class _ReadOnlyField extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
width: double.infinity, width: double.infinity,
padding: EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space3, horizontal: UiConstants.space3,
vertical: UiConstants.space3, vertical: UiConstants.space3,
), ),
@@ -111,17 +116,16 @@ class _ReadOnlyField extends StatelessWidget {
} }
} }
/// An editable text field widget.
/// An editable text field widget. /// An editable text field widget.
class _EditableField extends StatelessWidget { class _EditableField extends StatelessWidget {
final TextEditingController controller; final TextEditingController controller;
final String hint; final String hint;
final int maxLines;
final bool enabled; final bool enabled;
const _EditableField({ const _EditableField({
required this.controller, required this.controller,
required this.hint, required this.hint,
this.maxLines = 1,
this.enabled = true, this.enabled = true,
}); });
@@ -129,27 +133,26 @@ class _EditableField extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextField( return TextField(
controller: controller, controller: controller,
maxLines: maxLines,
enabled: enabled, enabled: enabled,
style: UiTypography.body2r.copyWith(color: UiColors.textPrimary), style: UiTypography.body2r.copyWith(color: UiColors.textPrimary),
decoration: InputDecoration( decoration: InputDecoration(
hintText: hint, hintText: hint,
hintStyle: UiTypography.body2r.copyWith(color: UiColors.textSecondary), hintStyle: UiTypography.body2r.copyWith(color: UiColors.textSecondary),
contentPadding: EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: UiConstants.space3, horizontal: UiConstants.space3,
vertical: UiConstants.space3, vertical: UiConstants.space3,
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
borderSide: BorderSide(color: UiColors.border), borderSide: const BorderSide(color: UiColors.border),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
borderSide: BorderSide(color: UiColors.border), borderSide: const BorderSide(color: UiColors.border),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
borderSide: BorderSide(color: UiColors.primary), borderSide: const BorderSide(color: UiColors.primary),
), ),
fillColor: UiColors.bgPopup, fillColor: UiColors.bgPopup,
filled: true, filled: true,

View File

@@ -2,10 +2,12 @@ import 'package:flutter/material.dart';
import 'package:core_localization/core_localization.dart'; import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
/// A widget displaying the staff member's profile photo with an edit option. /// 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. /// Shows either the photo URL or an initial avatar if no photo is available.
/// Includes a camera icon button for changing the photo. /// Includes a camera icon button for changing the photo.
/// Uses only design system tokens for colors, typography, and spacing.
class ProfilePhotoWidget extends StatelessWidget { class ProfilePhotoWidget extends StatelessWidget {
/// The URL of the staff member's photo. /// The URL of the staff member's photo.
final String? photoUrl; final String? photoUrl;
@@ -26,14 +28,14 @@ class ProfilePhotoWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final i18n = t.staff.onboarding.personal_info; final TranslationsStaffOnboardingPersonalInfoEn i18n = t.staff.onboarding.personal_info;
return Column( return Column(
children: [ children: <Widget>[
GestureDetector( GestureDetector(
onTap: onTap, onTap: onTap,
child: Stack( child: Stack(
children: [ children: <Widget>[
Container( Container(
width: 96, width: 96,
height: 96, height: 96,
@@ -67,7 +69,7 @@ class ProfilePhotoWidget extends StatelessWidget {
color: UiColors.bgPopup, color: UiColors.bgPopup,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: UiColors.border), border: Border.all(color: UiColors.border),
boxShadow: [ boxShadow: <BoxShadow>[
BoxShadow( BoxShadow(
color: UiColors.textPrimary.withOpacity(0.1), color: UiColors.textPrimary.withOpacity(0.1),
blurRadius: UiConstants.space1, blurRadius: UiConstants.space1,
@@ -75,7 +77,7 @@ class ProfilePhotoWidget extends StatelessWidget {
), ),
], ],
), ),
child: Center( child: const Center(
child: Icon( child: Icon(
UiIcons.camera, UiIcons.camera,
size: 16, size: 16,
@@ -87,7 +89,7 @@ class ProfilePhotoWidget extends StatelessWidget {
], ],
), ),
), ),
SizedBox(height: UiConstants.space3), const SizedBox(height: UiConstants.space3),
Text( Text(
i18n.change_photo_hint, i18n.change_photo_hint,
style: UiTypography.body2r.copyWith(color: UiColors.textSecondary), style: UiTypography.body2r.copyWith(color: UiColors.textSecondary),

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
/// A save button widget for the bottom of the personal info page. /// A save button widget for the bottom of the personal info page.
/// ///
/// Displays a full-width button with a save icon and customizable label. /// Displays a full-width button with a save icon and customizable label.
/// Uses only design system tokens for colors, typography, and spacing.
class SaveButton extends StatelessWidget { class SaveButton extends StatelessWidget {
/// Callback when the button is pressed. /// Callback when the button is pressed.
final VoidCallback? onPressed; final VoidCallback? onPressed;
@@ -25,8 +27,8 @@ class SaveButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: EdgeInsets.all(UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space5),
decoration: BoxDecoration( decoration: const BoxDecoration(
color: UiColors.bgPopup, color: UiColors.bgPopup,
border: Border( border: Border(
top: BorderSide(color: UiColors.border), top: BorderSide(color: UiColors.border),
@@ -46,7 +48,7 @@ class SaveButton extends StatelessWidget {
elevation: 0, elevation: 0,
), ),
child: isLoading child: isLoading
? SizedBox( ? const SizedBox(
width: 20, width: 20,
height: 20, height: 20,
child: CircularProgressIndicator( child: CircularProgressIndicator(
@@ -58,9 +60,9 @@ class SaveButton extends StatelessWidget {
) )
: Row( : Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: <Widget>[
Icon(UiIcons.check, color: UiColors.bgPopup, size: 20), const Icon(UiIcons.check, color: UiColors.bgPopup, size: 20),
SizedBox(width: UiConstants.space2), const SizedBox(width: UiConstants.space2),
Text( Text(
label, label,
style: UiTypography.body1m.copyWith( style: UiTypography.body1m.copyWith(

View File

@@ -52,6 +52,11 @@ class StaffProfileInfoModule extends Module {
'/personal-info', '/personal-info',
child: (BuildContext context) => const PersonalInfoPage(), child: (BuildContext context) => const PersonalInfoPage(),
); );
// Alias with trailing slash to be tolerant of external deep links
r.child(
'/personal-info/',
child: (BuildContext context) => const PersonalInfoPage(),
);
// Additional routes will be added as more onboarding pages are implemented // Additional routes will be added as more onboarding pages are implemented
} }
} }