feat: Update navigation paths and enhance personal info page with design system compliance
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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!);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user