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 {
/// Navigates to the personal info page.
void pushPersonalInfo() {
pushNamed('/profile/onboarding/personal-info');
pushNamed('./onboarding/personal-info');
}
/// 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
/// including phone, bio, languages, and preferred locations.
Future<void> pushPersonalInfo() {
return pushNamed('/profile/onboarding/personal-info');
return pushNamed('./personal-info');
}
/// 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
/// including phone, bio, languages, and preferred locations.
Future<void> pushPersonalInfo() {
return pushNamed('/profile/onboarding/personal-info');
return pushNamed('./personal-info');
}
/// Navigates to the Emergency Contact page.

View File

@@ -1,3 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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 '../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.
/// including phone number and address. Full name and email are read-only as they come from authentication.
///
/// This page is a StatelessWidget that uses BLoC for state management,
/// following Clean Architecture principles.
/// following Clean Architecture and the design system guidelines.
class PersonalInfoPage extends StatelessWidget {
/// Creates a [PersonalInfoPage].
const PersonalInfoPage({super.key});
@override
Widget build(BuildContext context) {
final TranslationsStaffOnboardingPersonalInfoEn i18n = t.staff.onboarding.personal_info;
return BlocProvider<PersonalInfoBloc>(
create: (context) => Modular.get<PersonalInfoBloc>()
create: (BuildContext 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),
child: BlocListener<PersonalInfoBloc, PersonalInfoState>(
listener: (BuildContext context, PersonalInfoState 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: const Icon(UiIcons.chevronLeft, color: UiColors.textSecondary),
onPressed: () => Modular.to.pop(),
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
),
);
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),
title: Text(
i18n.title,
style: UiTypography.title1m.copyWith(color: UiColors.textPrimary),
),
);
}
},
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,
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(),
);
}
body: SafeArea(
child: BlocBuilder<PersonalInfoBloc, PersonalInfoState>(
builder: (BuildContext context, PersonalInfoState 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,
),
),
);
}
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!);
},
return PersonalInfoContent(staff: state.staff!);
},
),
),
),
),
);

View File

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

View File

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

View File

@@ -2,10 +2,12 @@ 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.
/// Uses only design system tokens for colors, typography, and spacing.
class ProfilePhotoWidget extends StatelessWidget {
/// The URL of the staff member's photo.
final String? photoUrl;
@@ -26,14 +28,14 @@ class ProfilePhotoWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final i18n = t.staff.onboarding.personal_info;
final TranslationsStaffOnboardingPersonalInfoEn i18n = t.staff.onboarding.personal_info;
return Column(
children: [
children: <Widget>[
GestureDetector(
onTap: onTap,
child: Stack(
children: [
children: <Widget>[
Container(
width: 96,
height: 96,
@@ -67,7 +69,7 @@ class ProfilePhotoWidget extends StatelessWidget {
color: UiColors.bgPopup,
shape: BoxShape.circle,
border: Border.all(color: UiColors.border),
boxShadow: [
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.textPrimary.withOpacity(0.1),
blurRadius: UiConstants.space1,
@@ -75,7 +77,7 @@ class ProfilePhotoWidget extends StatelessWidget {
),
],
),
child: Center(
child: const Center(
child: Icon(
UiIcons.camera,
size: 16,
@@ -87,7 +89,7 @@ class ProfilePhotoWidget extends StatelessWidget {
],
),
),
SizedBox(height: UiConstants.space3),
const SizedBox(height: UiConstants.space3),
Text(
i18n.change_photo_hint,
style: UiTypography.body2r.copyWith(color: UiColors.textSecondary),

View File

@@ -1,9 +1,11 @@
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.
/// Uses only design system tokens for colors, typography, and spacing.
class SaveButton extends StatelessWidget {
/// Callback when the button is pressed.
final VoidCallback? onPressed;
@@ -25,8 +27,8 @@ class SaveButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(UiConstants.space5),
decoration: BoxDecoration(
padding: const EdgeInsets.all(UiConstants.space5),
decoration: const BoxDecoration(
color: UiColors.bgPopup,
border: Border(
top: BorderSide(color: UiColors.border),
@@ -46,7 +48,7 @@ class SaveButton extends StatelessWidget {
elevation: 0,
),
child: isLoading
? SizedBox(
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
@@ -58,9 +60,9 @@ class SaveButton extends StatelessWidget {
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(UiIcons.check, color: UiColors.bgPopup, size: 20),
SizedBox(width: UiConstants.space2),
children: <Widget>[
const Icon(UiIcons.check, color: UiColors.bgPopup, size: 20),
const SizedBox(width: UiConstants.space2),
Text(
label,
style: UiTypography.body1m.copyWith(

View File

@@ -52,6 +52,11 @@ class StaffProfileInfoModule extends Module {
'/personal-info',
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
}
}