From c9c61411f3cbadf4f0d9972f4414257db6d991f2 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Feb 2026 10:14:38 -0500 Subject: [PATCH 01/18] feat: Reorganize staff queries by removing old queries and adding new profile completion queries --- .../connector/staff/{ => queries}/profile_completion.gql | 0 backend/dataconnect/connector/staff/{ => queries}/queries.gql | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename backend/dataconnect/connector/staff/{ => queries}/profile_completion.gql (100%) rename backend/dataconnect/connector/staff/{ => queries}/queries.gql (100%) diff --git a/backend/dataconnect/connector/staff/profile_completion.gql b/backend/dataconnect/connector/staff/queries/profile_completion.gql similarity index 100% rename from backend/dataconnect/connector/staff/profile_completion.gql rename to backend/dataconnect/connector/staff/queries/profile_completion.gql diff --git a/backend/dataconnect/connector/staff/queries.gql b/backend/dataconnect/connector/staff/queries/queries.gql similarity index 100% rename from backend/dataconnect/connector/staff/queries.gql rename to backend/dataconnect/connector/staff/queries/queries.gql From c48dab678629b249e7a48de0beea3ae10deb802b Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Feb 2026 10:25:00 -0500 Subject: [PATCH 02/18] feat: Implement staff navigation items with profile completion requirements --- .../widgets/staff_main_bottom_bar.dart | 58 +++++-------------- .../staff/staff_main/lib/src/utils/index.dart | 2 + .../lib/src/utils/staff_nav_item.dart | 38 ++++++++++++ .../lib/src/utils/staff_nav_items_config.dart | 44 ++++++++++++++ 4 files changed, 99 insertions(+), 43 deletions(-) create mode 100644 apps/mobile/packages/features/staff/staff_main/lib/src/utils/index.dart create mode 100644 apps/mobile/packages/features/staff/staff_main/lib/src/utils/staff_nav_item.dart create mode 100644 apps/mobile/packages/features/staff/staff_main/lib/src/utils/staff_nav_items_config.dart diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/widgets/staff_main_bottom_bar.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/widgets/staff_main_bottom_bar.dart index f4479f21..30ea3405 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/widgets/staff_main_bottom_bar.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/widgets/staff_main_bottom_bar.dart @@ -1,8 +1,8 @@ import 'dart:ui'; -import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:staff_main/src/utils/index.dart'; /// A custom bottom navigation bar for the Staff app. /// @@ -36,7 +36,6 @@ class StaffMainBottomBar extends StatelessWidget { @override Widget build(BuildContext context) { - final t = Translations.of(context); // Staff App colors from design system // Using primary (Blue) for active as per prototype const Color activeColor = UiColors.primary; @@ -73,40 +72,12 @@ class StaffMainBottomBar extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceAround, crossAxisAlignment: CrossAxisAlignment.end, children: [ - _buildNavItem( - index: 0, - icon: UiIcons.briefcase, - label: t.staff.main.tabs.shifts, - activeColor: activeColor, - inactiveColor: inactiveColor, - ), - _buildNavItem( - index: 1, - icon: UiIcons.dollar, - label: t.staff.main.tabs.payments, - activeColor: activeColor, - inactiveColor: inactiveColor, - ), - _buildNavItem( - index: 2, - icon: UiIcons.home, - label: t.staff.main.tabs.home, - activeColor: activeColor, - inactiveColor: inactiveColor, - ), - _buildNavItem( - index: 3, - icon: UiIcons.clock, - label: t.staff.main.tabs.clock_in, - activeColor: activeColor, - inactiveColor: inactiveColor, - ), - _buildNavItem( - index: 4, - icon: UiIcons.users, - label: t.staff.main.tabs.profile, - activeColor: activeColor, - inactiveColor: inactiveColor, + ...defaultStaffNavItems.map( + (item) => _buildNavItem( + item: item, + activeColor: activeColor, + inactiveColor: inactiveColor, + ), ), ], ), @@ -122,30 +93,31 @@ class StaffMainBottomBar extends StatelessWidget { /// - Spacing uses [UiConstants.space1] /// - Typography uses [UiTypography.footnote2m] /// - Colors are passed as parameters from design system + /// + /// The [item.requireProfileCompletion] flag can be used to conditionally + /// disable or style the item based on profile completion status. Widget _buildNavItem({ - required int index, - required IconData icon, - required String label, + required StaffNavItem item, required Color activeColor, required Color inactiveColor, }) { - final bool isSelected = currentIndex == index; + final bool isSelected = currentIndex == item.index; return Expanded( child: GestureDetector( - onTap: () => onTap(index), + onTap: () => onTap(item.index), behavior: HitTestBehavior.opaque, child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, children: [ Icon( - icon, + item.icon, color: isSelected ? activeColor : inactiveColor, size: UiConstants.iconLg, ), const SizedBox(height: UiConstants.space1), Text( - label, + item.label, style: UiTypography.footnote2m.copyWith( color: isSelected ? activeColor : inactiveColor, ), diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/utils/index.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/utils/index.dart new file mode 100644 index 00000000..f3ec3cae --- /dev/null +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/utils/index.dart @@ -0,0 +1,2 @@ +export 'staff_nav_item.dart'; +export 'staff_nav_items_config.dart'; diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/utils/staff_nav_item.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/utils/staff_nav_item.dart new file mode 100644 index 00000000..25750d5b --- /dev/null +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/utils/staff_nav_item.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +/// Represents a single navigation item in the staff main bottom navigation bar. +/// +/// This data class encapsulates all properties needed to define a navigation item, +/// making it easy to add, remove, or modify items in the bottom bar without +/// touching the UI code. +class StaffNavItem { + /// Creates a [StaffNavItem]. + const StaffNavItem({ + required this.index, + required this.icon, + required this.label, + required this.tabKey, + this.requireProfileCompletion = false, + }); + + /// The index of this navigation item in the bottom bar. + final int index; + + /// The icon to display for this navigation item. + final IconData icon; + + /// The label text to display below the icon. + final String label; + + /// The unique key identifying this tab in the main navigation system. + /// + /// This is used internally for routing and state management. + final String tabKey; + + /// Whether this navigation item requires the user's profile to be complete. + /// + /// If true, this item may be disabled or show a prompt until the profile + /// is fully completed. This is useful for gating access to features that + /// require profile information. + final bool requireProfileCompletion; +} diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/utils/staff_nav_items_config.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/utils/staff_nav_items_config.dart new file mode 100644 index 00000000..5c328ef2 --- /dev/null +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/utils/staff_nav_items_config.dart @@ -0,0 +1,44 @@ +import 'package:design_system/design_system.dart'; +import 'package:staff_main/src/utils/staff_nav_item.dart'; + +/// Predefined navigation items for the Staff app bottom navigation bar. +/// +/// This list defines all available navigation items. To add, remove, or modify +/// items, simply update this list. The UI will automatically adapt. +final List defaultStaffNavItems = [ + StaffNavItem( + index: 0, + icon: UiIcons.briefcase, + label: 'Shifts', + tabKey: 'shifts', + requireProfileCompletion: false, + ), + StaffNavItem( + index: 1, + icon: UiIcons.dollar, + label: 'Payments', + tabKey: 'payments', + requireProfileCompletion: true, + ), + StaffNavItem( + index: 2, + icon: UiIcons.home, + label: 'Home', + tabKey: 'home', + requireProfileCompletion: false, + ), + StaffNavItem( + index: 3, + icon: UiIcons.clock, + label: 'Clock In', + tabKey: 'clock_in', + requireProfileCompletion: true, + ), + StaffNavItem( + index: 4, + icon: UiIcons.users, + label: 'Profile', + tabKey: 'profile', + requireProfileCompletion: false, + ), +]; From a1628248878d05f00cd170c7a743df14e13cc584 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Feb 2026 10:56:04 -0500 Subject: [PATCH 03/18] feat: Implement profile completion feature with repository and use case --- .../profile_completion_repository_impl.dart | 55 +++++++++++ .../profile_completion_repository.dart | 13 +++ .../get_profile_completion_usecase.dart | 24 +++++ .../presentation/blocs/staff_main_cubit.dart | 26 ++++- .../presentation/blocs/staff_main_state.dart | 11 ++- .../widgets/staff_main_bottom_bar.dart | 96 +++++++++++-------- .../staff_main/lib/src/staff_main_module.dart | 19 +++- .../features/staff/staff_main/pubspec.yaml | 4 +- docs/MOBILE/00-agent-development-rules.md | 2 +- docs/MOBILE/01-architecture-principles.md | 2 +- 10 files changed, 205 insertions(+), 47 deletions(-) create mode 100644 apps/mobile/packages/features/staff/staff_main/lib/src/data/repositories/profile_completion_repository_impl.dart create mode 100644 apps/mobile/packages/features/staff/staff_main/lib/src/domain/repositories/profile_completion_repository.dart create mode 100644 apps/mobile/packages/features/staff/staff_main/lib/src/domain/usecases/get_profile_completion_usecase.dart diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/data/repositories/profile_completion_repository_impl.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/data/repositories/profile_completion_repository_impl.dart new file mode 100644 index 00000000..c72c30a2 --- /dev/null +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/data/repositories/profile_completion_repository_impl.dart @@ -0,0 +1,55 @@ +import 'package:krow_data_connect/krow_data_connect.dart'; + +import 'package:staff_main/src/domain/repositories/profile_completion_repository.dart'; + +/// Implementation of [ProfileCompletionRepositoryInterface]. +/// +/// Fetches profile completion status from the Data Connect backend. +class ProfileCompletionRepositoryImpl implements ProfileCompletionRepositoryInterface { + /// Creates a new [ProfileCompletionRepositoryImpl]. + /// + /// Requires a [DataConnectService] instance for backend communication. + ProfileCompletionRepositoryImpl({ + DataConnectService? service, + }) : _service = service ?? DataConnectService.instance; + + final DataConnectService _service; + + @override + Future getProfileCompletion() async { + return _service.run(() async { + final String staffId = await _service.getStaffId(); + + final response = await _service.connector + .getStaffProfileCompletion(id: staffId) + .execute(); + + final staff = response.data.staff; + final emergencyContacts = response.data.emergencyContacts; + final taxForms = response.data.taxForms; + + return _isProfileComplete(staff, emergencyContacts, taxForms); + }); + } + + /// Checks if staff has experience data (skills or industries). + bool _hasExperience(dynamic staff) { + if (staff == null) return false; + final skills = staff.skills; + final industries = staff.industries; + return (skills is List && skills.isNotEmpty) || + (industries is List && industries.isNotEmpty); + } + + /// Determines if the profile is complete based on all sections. + bool _isProfileComplete( + dynamic staff, + List emergencyContacts, + List taxForms, + ) { + return staff != null && + emergencyContacts.isNotEmpty && + taxForms.isNotEmpty && + _hasExperience(staff); + } +} diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/domain/repositories/profile_completion_repository.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/domain/repositories/profile_completion_repository.dart new file mode 100644 index 00000000..33a53169 --- /dev/null +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/domain/repositories/profile_completion_repository.dart @@ -0,0 +1,13 @@ +/// Repository interface for profile completion status queries. +/// +/// This interface defines the contract for accessing profile completion data. +/// Implementations should fetch this data from the backend via Data Connect. +abstract interface class ProfileCompletionRepositoryInterface { + /// Fetches whether the profile is complete for the current staff member. + /// + /// Returns true if all required profile sections have been completed, + /// false otherwise. + /// + /// Throws an exception if the query fails. + Future getProfileCompletion(); +} diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/domain/usecases/get_profile_completion_usecase.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/domain/usecases/get_profile_completion_usecase.dart new file mode 100644 index 00000000..6d09e6e1 --- /dev/null +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/domain/usecases/get_profile_completion_usecase.dart @@ -0,0 +1,24 @@ +import '../repositories/profile_completion_repository.dart'; + +/// Use case for retrieving profile completion status. +/// +/// This use case encapsulates the business logic for determining whether +/// a staff member's profile is complete. It delegates to the repository +/// for data access. +class GetProfileCompletionUsecase { + /// Creates a [GetProfileCompletionUsecase]. + /// + /// Requires a [ProfileCompletionRepositoryInterface] for data access. + GetProfileCompletionUsecase({ + required ProfileCompletionRepositoryInterface repository, + }) : _repository = repository; + + final ProfileCompletionRepositoryInterface _repository; + + /// Executes the use case to get profile completion status. + /// + /// Returns true if the profile is complete, false otherwise. + /// + /// Throws an exception if the operation fails. + Future call() => _repository.getProfileCompletion(); +} diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart index 9f33afb1..004cdfae 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart @@ -1,14 +1,22 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:staff_main/src/domain/usecases/get_profile_completion_usecase.dart'; import 'package:staff_main/src/presentation/blocs/staff_main_state.dart'; class StaffMainCubit extends Cubit implements Disposable { - StaffMainCubit() : super(const StaffMainState()) { + StaffMainCubit({ + required GetProfileCompletionUsecase getProfileCompletionUsecase, + }) : _getProfileCompletionUsecase = getProfileCompletionUsecase, + super(const StaffMainState()) { Modular.to.addListener(_onRouteChanged); _onRouteChanged(); + _loadProfileCompletion(); } + final GetProfileCompletionUsecase _getProfileCompletionUsecase; + void _onRouteChanged() { if (isClosed) return; final String path = Modular.to.path; @@ -32,6 +40,22 @@ class StaffMainCubit extends Cubit implements Disposable { } } + /// Loads the profile completion status. + Future _loadProfileCompletion() async { + try { + final isComplete = await _getProfileCompletionUsecase(); + if (!isClosed) { + emit(state.copyWith(isProfileComplete: isComplete)); + } + } catch (e) { + // If there's an error, allow access to all features + debugPrint('Error loading profile completion: $e'); + if (!isClosed) { + emit(state.copyWith(isProfileComplete: true)); + } + } + } + void navigateToTab(int index) { if (index == state.currentIndex) return; diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_state.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_state.dart index 68175302..0903b877 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_state.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_state.dart @@ -3,14 +3,19 @@ import 'package:equatable/equatable.dart'; class StaffMainState extends Equatable { const StaffMainState({ this.currentIndex = 2, // Default to Home + this.isProfileComplete = false, }); final int currentIndex; + final bool isProfileComplete; - StaffMainState copyWith({int? currentIndex}) { - return StaffMainState(currentIndex: currentIndex ?? this.currentIndex); + StaffMainState copyWith({int? currentIndex, bool? isProfileComplete}) { + return StaffMainState( + currentIndex: currentIndex ?? this.currentIndex, + isProfileComplete: isProfileComplete ?? this.isProfileComplete, + ); } @override - List get props => [currentIndex]; + List get props => [currentIndex, isProfileComplete]; } diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/widgets/staff_main_bottom_bar.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/widgets/staff_main_bottom_bar.dart index 30ea3405..176719ed 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/widgets/staff_main_bottom_bar.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/widgets/staff_main_bottom_bar.dart @@ -2,6 +2,9 @@ import 'dart:ui'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:staff_main/src/presentation/blocs/staff_main_cubit.dart'; +import 'package:staff_main/src/presentation/blocs/staff_main_state.dart'; import 'package:staff_main/src/utils/index.dart'; /// A custom bottom navigation bar for the Staff app. @@ -10,6 +13,10 @@ import 'package:staff_main/src/utils/index.dart'; /// and follows the KROW Design System guidelines. It displays five tabs: /// Shifts, Payments, Home, Clock In, and Profile. /// +/// Navigation items are gated by profile completion status. Items marked with +/// [StaffNavItem.requireProfileCompletion] are only visible when the profile +/// is complete. +/// /// The widget uses: /// - [UiColors] for all color values /// - [UiTypography] for text styling @@ -41,48 +48,55 @@ class StaffMainBottomBar extends StatelessWidget { const Color activeColor = UiColors.primary; const Color inactiveColor = UiColors.textInactive; - return Stack( - clipBehavior: Clip.none, - children: [ - // Glassmorphic background with blur effect - Positioned.fill( - child: ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: Container( - decoration: BoxDecoration( - color: UiColors.white.withValues(alpha: 0.85), - border: Border( - top: BorderSide( - color: UiColors.black.withValues(alpha: 0.1), + return BlocBuilder( + builder: (BuildContext context, StaffMainState state) { + final bool isProfileComplete = state.isProfileComplete; + + return Stack( + clipBehavior: Clip.none, + children: [ + // Glassmorphic background with blur effect + Positioned.fill( + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + decoration: BoxDecoration( + color: UiColors.white.withValues(alpha: 0.85), + border: Border( + top: BorderSide( + color: UiColors.black.withValues(alpha: 0.1), + ), + ), ), ), ), ), ), - ), - ), - // Navigation items - Container( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).padding.bottom + UiConstants.space2, - top: UiConstants.space4, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - ...defaultStaffNavItems.map( - (item) => _buildNavItem( - item: item, - activeColor: activeColor, - inactiveColor: inactiveColor, - ), + // Navigation items + Container( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom + UiConstants.space2, + top: UiConstants.space4, ), - ], - ), - ), - ], + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ...defaultStaffNavItems.map( + (item) => _buildNavItem( + item: item, + activeColor: activeColor, + inactiveColor: inactiveColor, + isProfileComplete: isProfileComplete, + ), + ), + ], + ), + ), + ], + ); + }, ); } @@ -94,13 +108,19 @@ class StaffMainBottomBar extends StatelessWidget { /// - Typography uses [UiTypography.footnote2m] /// - Colors are passed as parameters from design system /// - /// The [item.requireProfileCompletion] flag can be used to conditionally - /// disable or style the item based on profile completion status. + /// Items with [item.requireProfileCompletion] = true are hidden when + /// [isProfileComplete] is false. Widget _buildNavItem({ required StaffNavItem item, required Color activeColor, required Color inactiveColor, + required bool isProfileComplete, }) { + // Hide item if profile completion is required but not complete + if (item.requireProfileCompletion && !isProfileComplete) { + return const SizedBox.shrink(); + } + final bool isSelected = currentIndex == item.index; return Expanded( child: GestureDetector( diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart index fd5ddc74..27a3484f 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart @@ -8,7 +8,11 @@ import 'package:staff_certificates/staff_certificates.dart'; import 'package:staff_clock_in/staff_clock_in.dart'; import 'package:staff_documents/staff_documents.dart'; import 'package:staff_emergency_contact/staff_emergency_contact.dart'; +import 'package:staff_faqs/staff_faqs.dart'; import 'package:staff_home/staff_home.dart'; +import 'package:staff_main/src/data/repositories/profile_completion_repository_impl.dart'; +import 'package:staff_main/src/domain/repositories/profile_completion_repository.dart'; +import 'package:staff_main/src/domain/usecases/get_profile_completion_usecase.dart'; import 'package:staff_main/src/presentation/blocs/staff_main_cubit.dart'; import 'package:staff_main/src/presentation/pages/staff_main_page.dart'; import 'package:staff_payments/staff_payements.dart'; @@ -18,13 +22,24 @@ import 'package:staff_profile_experience/staff_profile_experience.dart'; import 'package:staff_profile_info/staff_profile_info.dart'; import 'package:staff_shifts/staff_shifts.dart'; import 'package:staff_tax_forms/staff_tax_forms.dart'; -import 'package:staff_faqs/staff_faqs.dart'; import 'package:staff_time_card/staff_time_card.dart'; class StaffMainModule extends Module { @override void binds(Injector i) { - i.addSingleton(StaffMainCubit.new); + i.addSingleton( + ProfileCompletionRepositoryImpl.new, + ); + i.addSingleton( + () => GetProfileCompletionUsecase( + repository: i.get(), + ), + ); + i.addSingleton( + () => StaffMainCubit( + getProfileCompletionUsecase: i.get(), + ), + ); } @override diff --git a/apps/mobile/packages/features/staff/staff_main/pubspec.yaml b/apps/mobile/packages/features/staff/staff_main/pubspec.yaml index f31d21a8..91c0b8a4 100644 --- a/apps/mobile/packages/features/staff/staff_main/pubspec.yaml +++ b/apps/mobile/packages/features/staff/staff_main/pubspec.yaml @@ -21,7 +21,9 @@ dependencies: core_localization: path: ../../../core_localization krow_core: - path: ../../../krow_core + path: ../../../core + krow_data_connect: + path: ../../../data_connect # Features staff_home: diff --git a/docs/MOBILE/00-agent-development-rules.md b/docs/MOBILE/00-agent-development-rules.md index c7322cfc..5ef0a8b7 100644 --- a/docs/MOBILE/00-agent-development-rules.md +++ b/docs/MOBILE/00-agent-development-rules.md @@ -111,7 +111,7 @@ If a user request is vague: * **DO NOT** add 3rd party packages without checking `apps/mobile/packages/core` first. * **DO NOT** add `firebase_auth` or `firebase_data_connect` to any Feature package. They belong in `data_connect` only. * **Service Locator**: Use `DataConnectService.instance` for singleton access to backend operations. -* **Dependency Injection**: Use Flutter Modular for BLoC and UseCase injection in `Module.routes()`. +* **Dependency Injection**: Use Flutter Modular for BLoC (never use `addSingleton` for Blocs, always use `add` method) and UseCase injection in `Module.routes()`. ## 8. Error Handling diff --git a/docs/MOBILE/01-architecture-principles.md b/docs/MOBILE/01-architecture-principles.md index c24a8295..40bcb623 100644 --- a/docs/MOBILE/01-architecture-principles.md +++ b/docs/MOBILE/01-architecture-principles.md @@ -64,7 +64,7 @@ graph TD ### 2.2 Features (`apps/mobile/packages/features//`) - **Role**: Vertical slices of user-facing functionality. - **Internal Structure**: - - `domain/`: Feature-specific Use Cases and Repository Interfaces. + - `domain/`: Feature-specific Use Cases(always extend the apps/mobile/packages/core/lib/src/domain/usecases/usecase.dart abstract clas) and Repository Interfaces. - `data/`: Repository Implementations. - `presentation/`: - Pages, BLoCs, Widgets. From faa04033146824a1f283f5d54f8ee01d9f497644 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Feb 2026 12:15:46 -0500 Subject: [PATCH 04/18] feat: Implement staff profile completion feature with new repository and use case --- .../data_connect/lib/krow_data_connect.dart | 5 ++ .../staff_connector_repository_impl.dart | 61 +++++++++++++++++++ .../staff_connector_repository.dart | 13 ++++ .../get_profile_completion_usecase.dart | 16 ++--- .../profile_completion_repository_impl.dart | 55 ----------------- .../profile_completion_repository.dart | 13 ---- .../presentation/blocs/staff_main_cubit.dart | 6 +- .../staff_main/lib/src/staff_main_module.dart | 20 +++--- 8 files changed, 101 insertions(+), 88 deletions(-) create mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart create mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart rename apps/mobile/packages/{features/staff/staff_main/lib/src => data_connect/lib/src/connectors/staff}/domain/usecases/get_profile_completion_usecase.dart (52%) delete mode 100644 apps/mobile/packages/features/staff/staff_main/lib/src/data/repositories/profile_completion_repository_impl.dart delete mode 100644 apps/mobile/packages/features/staff/staff_main/lib/src/domain/repositories/profile_completion_repository.dart diff --git a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart index 7afa4c97..e8d70f23 100644 --- a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart +++ b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart @@ -17,3 +17,8 @@ export 'src/services/mixins/session_handler_mixin.dart'; export 'src/session/staff_session_store.dart'; export 'src/services/mixins/data_error_handler.dart'; + +// Export Staff Connector repositories and use cases +export 'src/connectors/staff/domain/repositories/staff_connector_repository.dart'; +export 'src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart'; +export 'src/connectors/staff/data/repositories/staff_connector_repository_impl.dart'; \ No newline at end of file diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart new file mode 100644 index 00000000..d13a665c --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart @@ -0,0 +1,61 @@ +import 'package:firebase_data_connect/firebase_data_connect.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; + +import '../../domain/repositories/staff_connector_repository.dart'; + +/// Implementation of [StaffConnectorRepository]. +/// +/// Fetches staff-related data from the Data Connect backend using +/// the staff connector queries. +class StaffConnectorRepositoryImpl implements StaffConnectorRepository { + /// Creates a new [StaffConnectorRepositoryImpl]. + /// + /// Requires a [DataConnectService] instance for backend communication. + StaffConnectorRepositoryImpl({ + DataConnectService? service, + }) : _service = service ?? DataConnectService.instance; + + final DataConnectService _service; + + @override + Future getProfileCompletion() async { + return _service.run(() async { + final String staffId = await _service.getStaffId(); + + final QueryResult response = + await _service.connector + .getStaffProfileCompletion(id: staffId) + .execute(); + + final GetStaffProfileCompletionStaff? staff = response.data.staff; + final List + emergencyContacts = response.data.emergencyContacts; + final List taxForms = + response.data.taxForms; + + return _isProfileComplete(staff, emergencyContacts, taxForms); + }); + } + + /// Checks if staff has experience data (skills or industries). + bool _hasExperience(GetStaffProfileCompletionStaff? staff) { + if (staff == null) return false; + final dynamic skills = staff.skills; + final dynamic industries = staff.industries; + return (skills is List && skills.isNotEmpty) || + (industries is List && industries.isNotEmpty); + } + + /// Determines if the profile is complete based on all sections. + bool _isProfileComplete( + GetStaffProfileCompletionStaff? staff, + List emergencyContacts, + List taxForms, + ) { + return staff != null && + emergencyContacts.isNotEmpty && + taxForms.isNotEmpty && + _hasExperience(staff); + } +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart new file mode 100644 index 00000000..779e0042 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart @@ -0,0 +1,13 @@ +/// Repository interface for staff connector queries. +/// +/// This interface defines the contract for accessing staff-related data +/// from the backend via Data Connect. +abstract interface class StaffConnectorRepository { + /// Fetches whether the profile is complete for the current staff member. + /// + /// Returns true if all required profile sections have been completed, + /// false otherwise. + /// + /// Throws an exception if the query fails. + Future getProfileCompletion(); +} diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/domain/usecases/get_profile_completion_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart similarity index 52% rename from apps/mobile/packages/features/staff/staff_main/lib/src/domain/usecases/get_profile_completion_usecase.dart rename to apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart index 6d09e6e1..5aa37816 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/domain/usecases/get_profile_completion_usecase.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart @@ -1,19 +1,19 @@ -import '../repositories/profile_completion_repository.dart'; +import '../repositories/staff_connector_repository.dart'; -/// Use case for retrieving profile completion status. +/// Use case for retrieving staff profile completion status. /// /// This use case encapsulates the business logic for determining whether /// a staff member's profile is complete. It delegates to the repository /// for data access. -class GetProfileCompletionUsecase { - /// Creates a [GetProfileCompletionUsecase]. +class GetProfileCompletionUseCase { + /// Creates a [GetProfileCompletionUseCase]. /// - /// Requires a [ProfileCompletionRepositoryInterface] for data access. - GetProfileCompletionUsecase({ - required ProfileCompletionRepositoryInterface repository, + /// Requires a [StaffConnectorRepository] for data access. + GetProfileCompletionUseCase({ + required StaffConnectorRepository repository, }) : _repository = repository; - final ProfileCompletionRepositoryInterface _repository; + final StaffConnectorRepository _repository; /// Executes the use case to get profile completion status. /// diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/data/repositories/profile_completion_repository_impl.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/data/repositories/profile_completion_repository_impl.dart deleted file mode 100644 index c72c30a2..00000000 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/data/repositories/profile_completion_repository_impl.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:krow_data_connect/krow_data_connect.dart'; - -import 'package:staff_main/src/domain/repositories/profile_completion_repository.dart'; - -/// Implementation of [ProfileCompletionRepositoryInterface]. -/// -/// Fetches profile completion status from the Data Connect backend. -class ProfileCompletionRepositoryImpl implements ProfileCompletionRepositoryInterface { - /// Creates a new [ProfileCompletionRepositoryImpl]. - /// - /// Requires a [DataConnectService] instance for backend communication. - ProfileCompletionRepositoryImpl({ - DataConnectService? service, - }) : _service = service ?? DataConnectService.instance; - - final DataConnectService _service; - - @override - Future getProfileCompletion() async { - return _service.run(() async { - final String staffId = await _service.getStaffId(); - - final response = await _service.connector - .getStaffProfileCompletion(id: staffId) - .execute(); - - final staff = response.data.staff; - final emergencyContacts = response.data.emergencyContacts; - final taxForms = response.data.taxForms; - - return _isProfileComplete(staff, emergencyContacts, taxForms); - }); - } - - /// Checks if staff has experience data (skills or industries). - bool _hasExperience(dynamic staff) { - if (staff == null) return false; - final skills = staff.skills; - final industries = staff.industries; - return (skills is List && skills.isNotEmpty) || - (industries is List && industries.isNotEmpty); - } - - /// Determines if the profile is complete based on all sections. - bool _isProfileComplete( - dynamic staff, - List emergencyContacts, - List taxForms, - ) { - return staff != null && - emergencyContacts.isNotEmpty && - taxForms.isNotEmpty && - _hasExperience(staff); - } -} diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/domain/repositories/profile_completion_repository.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/domain/repositories/profile_completion_repository.dart deleted file mode 100644 index 33a53169..00000000 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/domain/repositories/profile_completion_repository.dart +++ /dev/null @@ -1,13 +0,0 @@ -/// Repository interface for profile completion status queries. -/// -/// This interface defines the contract for accessing profile completion data. -/// Implementations should fetch this data from the backend via Data Connect. -abstract interface class ProfileCompletionRepositoryInterface { - /// Fetches whether the profile is complete for the current staff member. - /// - /// Returns true if all required profile sections have been completed, - /// false otherwise. - /// - /// Throws an exception if the query fails. - Future getProfileCompletion(); -} diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart index 004cdfae..b868c7ca 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart @@ -1,13 +1,13 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:staff_main/src/domain/usecases/get_profile_completion_usecase.dart'; import 'package:staff_main/src/presentation/blocs/staff_main_state.dart'; class StaffMainCubit extends Cubit implements Disposable { StaffMainCubit({ - required GetProfileCompletionUsecase getProfileCompletionUsecase, + required GetProfileCompletionUseCase getProfileCompletionUsecase, }) : _getProfileCompletionUsecase = getProfileCompletionUsecase, super(const StaffMainState()) { Modular.to.addListener(_onRouteChanged); @@ -15,7 +15,7 @@ class StaffMainCubit extends Cubit implements Disposable { _loadProfileCompletion(); } - final GetProfileCompletionUsecase _getProfileCompletionUsecase; + final GetProfileCompletionUseCase _getProfileCompletionUsecase; void _onRouteChanged() { if (isClosed) return; diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart index 27a3484f..0fb79b75 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:staff_attire/staff_attire.dart'; import 'package:staff_availability/staff_availability.dart'; import 'package:staff_bank_account/staff_bank_account.dart'; @@ -10,9 +11,6 @@ import 'package:staff_documents/staff_documents.dart'; import 'package:staff_emergency_contact/staff_emergency_contact.dart'; import 'package:staff_faqs/staff_faqs.dart'; import 'package:staff_home/staff_home.dart'; -import 'package:staff_main/src/data/repositories/profile_completion_repository_impl.dart'; -import 'package:staff_main/src/domain/repositories/profile_completion_repository.dart'; -import 'package:staff_main/src/domain/usecases/get_profile_completion_usecase.dart'; import 'package:staff_main/src/presentation/blocs/staff_main_cubit.dart'; import 'package:staff_main/src/presentation/pages/staff_main_page.dart'; import 'package:staff_payments/staff_payements.dart'; @@ -27,17 +25,21 @@ import 'package:staff_time_card/staff_time_card.dart'; class StaffMainModule extends Module { @override void binds(Injector i) { - i.addSingleton( - ProfileCompletionRepositoryImpl.new, + // Register the StaffConnectorRepository from data_connect + i.addSingleton( + StaffConnectorRepositoryImpl.new, ); + + // Register the use case from data_connect i.addSingleton( - () => GetProfileCompletionUsecase( - repository: i.get(), + () => GetProfileCompletionUseCase( + repository: i.get(), ), ); - i.addSingleton( + + i.add( () => StaffMainCubit( - getProfileCompletionUsecase: i.get(), + getProfileCompletionUsecase: i.get(), ), ); } From d404b6604dd697287e427ce79affa72b5d2cd67d Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Feb 2026 13:20:43 -0500 Subject: [PATCH 05/18] feat: Update architecture documentation for Data Connect Connectors pattern and remove unused import in staff connector repository implementation --- .../staff_connector_repository_impl.dart | 2 - docs/MOBILE/01-architecture-principles.md | 32 +- .../03-data-connect-connectors-pattern.md | 273 ++++++++++++++++++ 3 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 docs/MOBILE/03-data-connect-connectors-pattern.md diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart index d13a665c..caebd123 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart @@ -1,8 +1,6 @@ import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; -import '../../domain/repositories/staff_connector_repository.dart'; - /// Implementation of [StaffConnectorRepository]. /// /// Fetches staff-related data from the Data Connect backend using diff --git a/docs/MOBILE/01-architecture-principles.md b/docs/MOBILE/01-architecture-principles.md index 40bcb623..b8c6f460 100644 --- a/docs/MOBILE/01-architecture-principles.md +++ b/docs/MOBILE/01-architecture-principles.md @@ -85,10 +85,18 @@ graph TD ### 2.4 Data Connect (`apps/mobile/packages/data_connect`) - **Role**: Interface Adapter for Backend Access (Datasource Layer). - **Responsibilities**: - - Implement Firebase Data Connect connector and service layer. - - Map Domain Entities to/from Data Connect generated code. - - Handle Firebase exceptions and map to domain failures. - - Provide centralized `DataConnectService` with session management. + - **Connectors**: Centralized repository implementations for each backend connector (see `03-data-connect-connectors-pattern.md`) + - One connector per backend connector domain (staff, order, user, etc.) + - Repository interfaces and use cases defined at domain level + - Repository implementations query backend and map responses + - Implement Firebase Data Connect connector and service layer + - Map Domain Entities to/from Data Connect generated code + - Handle Firebase exceptions and map to domain failures + - Provide centralized `DataConnectService` with session management +- **RESTRICTION**: + - NO feature-specific logic. Connectors are domain-neutral and reusable. + - All queries must follow Clean Architecture (domain → data layers) + - See `03-data-connect-connectors-pattern.md` for detailed pattern documentation ### 2.5 Design System (`apps/mobile/packages/design_system`) - **Role**: Visual language and component library. @@ -195,3 +203,19 @@ Each app (`staff` and `client`) has different role requirements and session patt - **Session Store**: `ClientSessionStore` with `ClientSession(user: User, business: ClientBusinessSession?)` - **Lazy Loading**: `getUserSessionData()` fetches via `getBusinessById()` if session null - **Navigation**: On auth → `Modular.to.toClientHome()`, on unauth → `Modular.to.toInitialPage()` + +## 7. Data Connect Connectors Pattern + +See **`03-data-connect-connectors-pattern.md`** for comprehensive documentation on: +- How connector repositories work +- How to add queries to existing connectors +- How to create new connectors +- Integration patterns with features +- Benefits and anti-patterns + +**Quick Reference**: +- All backend queries centralized in `apps/mobile/packages/data_connect/lib/src/connectors/` +- One connector per backend connector domain (staff, order, user, etc.) +- Each connector follows Clean Architecture (domain interfaces + data implementations) +- Features use connector repositories through dependency injection +- Results in zero query duplication and single source of truth diff --git a/docs/MOBILE/03-data-connect-connectors-pattern.md b/docs/MOBILE/03-data-connect-connectors-pattern.md new file mode 100644 index 00000000..165a30bd --- /dev/null +++ b/docs/MOBILE/03-data-connect-connectors-pattern.md @@ -0,0 +1,273 @@ +# Data Connect Connectors Pattern + +## Overview + +This document describes the **Data Connect Connectors** pattern implemented in the KROW mobile app. This pattern centralizes all backend query logic by mirroring backend connector structure in the mobile data layer. + +## Problem Statement + +**Without Connectors Pattern:** +- Each feature creates its own repository implementation +- Multiple features query the same backend connector → duplication +- When backend queries change, updates needed in multiple places +- No reusability across features + +**Example Problem:** +``` +staff_main/ + └── data/repositories/profile_completion_repository_impl.dart ← queries staff connector +profile/ + └── data/repositories/profile_repository_impl.dart ← also queries staff connector +onboarding/ + └── data/repositories/personal_info_repository_impl.dart ← also queries staff connector +``` + +## Solution: Connectors in Data Connect Package + +All backend connector queries are implemented once in a centralized location, following the backend structure. + +### Structure + +``` +apps/mobile/packages/data_connect/lib/src/connectors/ +├── staff/ +│ ├── domain/ +│ │ ├── repositories/ +│ │ │ └── staff_connector_repository.dart (interface) +│ │ └── usecases/ +│ │ └── get_profile_completion_usecase.dart +│ └── data/ +│ └── repositories/ +│ └── staff_connector_repository_impl.dart (implementation) +├── order/ +├── user/ +├── emergency_contact/ +└── ... +``` + +**Maps to backend structure:** +``` +backend/dataconnect/connector/ +├── staff/ +├── order/ +├── user/ +├── emergency_contact/ +└── ... +``` + +## Clean Architecture Layers + +Each connector follows Clean Architecture with three layers: + +### Domain Layer (`connectors/{name}/domain/`) + +**Repository Interface:** +```dart +// staff_connector_repository.dart +abstract interface class StaffConnectorRepository { + Future getProfileCompletion(); + Future getStaffById(String id); + // ... more queries +} +``` + +**Use Cases:** +```dart +// get_profile_completion_usecase.dart +class GetProfileCompletionUseCase { + GetProfileCompletionUseCase({required StaffConnectorRepository repository}); + Future call() => _repository.getProfileCompletion(); +} +``` + +**Characteristics:** +- Pure Dart, no framework dependencies +- Stable, business-focused contracts +- One interface per connector +- One use case per query or related query group + +### Data Layer (`connectors/{name}/data/`) + +**Repository Implementation:** +```dart +// staff_connector_repository_impl.dart +class StaffConnectorRepositoryImpl implements StaffConnectorRepository { + final DataConnectService _service; + + @override + Future getProfileCompletion() async { + return _service.run(() async { + final staffId = await _service.getStaffId(); + final response = await _service.connector + .getStaffProfileCompletion(id: staffId) + .execute(); + + return _isProfileComplete(response); + }); + } +} +``` + +**Characteristics:** +- Implements domain repository interface +- Uses `DataConnectService` to execute queries +- Maps backend response types to domain models +- Contains mapping/transformation logic only +- Handles type safety with generated Data Connect types + +## Integration Pattern + +### Step 1: Feature Needs Data + +Feature (e.g., `staff_main`) needs profile completion status. + +### Step 2: Use Connector Repository + +Instead of creating a local repository, feature uses the connector: + +```dart +// staff_main_module.dart +class StaffMainModule extends Module { + @override + void binds(Injector i) { + // Register connector repository from data_connect + i.addSingleton( + StaffConnectorRepositoryImpl.new, + ); + + // Feature creates its own use case wrapper if needed + i.addSingleton( + () => GetProfileCompletionUseCase( + repository: i.get(), + ), + ); + + // BLoC uses the use case + i.addSingleton( + () => StaffMainCubit( + getProfileCompletionUsecase: i.get(), + ), + ); + } +} +``` + +### Step 3: BLoC Uses It + +```dart +class StaffMainCubit extends Cubit { + StaffMainCubit({required GetProfileCompletionUseCase usecase}) { + _loadProfileCompletion(); + } + + Future _loadProfileCompletion() async { + final isComplete = await _getProfileCompletionUsecase(); + emit(state.copyWith(isProfileComplete: isComplete)); + } +} +``` + +## Export Pattern + +Connectors are exported from `krow_data_connect` for easy access: + +```dart +// lib/krow_data_connect.dart +export 'src/connectors/staff/domain/repositories/staff_connector_repository.dart'; +export 'src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart'; +export 'src/connectors/staff/data/repositories/staff_connector_repository_impl.dart'; +``` + +**Features import:** +```dart +import 'package:krow_data_connect/krow_data_connect.dart'; +``` + +## Adding New Queries to Existing Connector + +When backend adds `getStaffById()` query to staff connector: + +1. **Add to interface:** + ```dart + abstract interface class StaffConnectorRepository { + Future getStaffById(String id); + } + ``` + +2. **Implement in repository:** + ```dart + @override + Future getStaffById(String id) async { + return _service.run(() async { + final response = await _service.connector + .getStaffById(id: id) + .execute(); + return _mapToStaff(response.data.staff); + }); + } + ``` + +3. **Use in features:** + ```dart + // Any feature can now use it + final staff = await i.get().getStaffById(id); + ``` + +## Adding New Connector + +When backend adds new connector (e.g., `order`): + +1. Create directory: `apps/mobile/packages/data_connect/lib/src/connectors/order/` + +2. Create domain layer with repository interface and use cases + +3. Create data layer with repository implementation + +4. Export from `krow_data_connect.dart` + +5. Features can immediately start using it + +## Benefits + +✅ **No Duplication** - Query implemented once, used by many features +✅ **Single Source of Truth** - Backend change → update one place +✅ **Clean Separation** - Connector logic separate from feature logic +✅ **Reusability** - Any feature can request any connector data +✅ **Testability** - Mock the connector repo to test features +✅ **Scalability** - Easy to add new connectors as backend grows +✅ **Mirrors Backend** - Mobile structure mirrors backend structure + +## Anti-Patterns + +❌ **DON'T**: Implement query logic in feature repository +❌ **DON'T**: Duplicate queries across multiple repositories +❌ **DON'T**: Put mapping logic in features +❌ **DON'T**: Call `DataConnectService` directly from BLoCs + +**DO**: Use connector repositories through use cases in features. + +## Current Implementation + +### Staff Connector + +**Location**: `apps/mobile/packages/data_connect/lib/src/connectors/staff/` + +**Available Queries**: +- `getProfileCompletion()` - Returns bool indicating if profile is complete + - Checks: personal info, emergency contacts, tax forms, experience (skills/industries) + +**Used By**: +- `staff_main` - Guards bottom nav items requiring profile completion + +**Backend Queries Used**: +- `backend/dataconnect/connector/staff/queries/profile_completion.gql` + +## Future Expansion + +As the app grows, additional connectors will be added: +- `order_connector_repository` (queries from `backend/dataconnect/connector/order/`) +- `user_connector_repository` (queries from `backend/dataconnect/connector/user/`) +- `emergency_contact_connector_repository` (queries from `backend/dataconnect/connector/emergencyContact/`) +- etc. + +Each following the same Clean Architecture pattern implemented for Staff Connector. From 55344fad9015a67f6bf5016731212369789d1194 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Feb 2026 13:25:39 -0500 Subject: [PATCH 06/18] feat: Implement use cases for personal info, emergency contacts, experience, and tax forms completion --- .../data_connect/lib/krow_data_connect.dart | 4 + .../staff_connector_repository_impl.dart | 89 ++++++++++++++++++- .../staff_connector_repository.dart | 20 +++++ ...emergency_contacts_completion_usecase.dart | 27 ++++++ .../get_experience_completion_usecase.dart | 27 ++++++ .../get_personal_info_completion_usecase.dart | 27 ++++++ .../get_profile_completion_usecase.dart | 5 +- .../get_tax_forms_completion_usecase.dart | 27 ++++++ .../mobile/packages/data_connect/pubspec.yaml | 3 +- 9 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_emergency_contacts_completion_usecase.dart create mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_experience_completion_usecase.dart create mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_personal_info_completion_usecase.dart create mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_tax_forms_completion_usecase.dart diff --git a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart index e8d70f23..4123cf8b 100644 --- a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart +++ b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart @@ -21,4 +21,8 @@ export 'src/services/mixins/data_error_handler.dart'; // Export Staff Connector repositories and use cases export 'src/connectors/staff/domain/repositories/staff_connector_repository.dart'; export 'src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart'; +export 'src/connectors/staff/domain/usecases/get_personal_info_completion_usecase.dart'; +export 'src/connectors/staff/domain/usecases/get_emergency_contacts_completion_usecase.dart'; +export 'src/connectors/staff/domain/usecases/get_experience_completion_usecase.dart'; +export 'src/connectors/staff/domain/usecases/get_tax_forms_completion_usecase.dart'; export 'src/connectors/staff/data/repositories/staff_connector_repository_impl.dart'; \ No newline at end of file diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart index caebd123..45c5fd3f 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart @@ -36,8 +36,84 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { }); } + @override + Future getPersonalInfoCompletion() async { + return _service.run(() async { + final String staffId = await _service.getStaffId(); + + final QueryResult response = + await _service.connector + .getStaffPersonalInfoCompletion(id: staffId) + .execute(); + + final GetStaffPersonalInfoCompletionStaff? staff = response.data.staff; + + return _isPersonalInfoComplete(staff); + }); + } + + @override + Future getEmergencyContactsCompletion() async { + return _service.run(() async { + final String staffId = await _service.getStaffId(); + + final QueryResult response = + await _service.connector + .getStaffEmergencyProfileCompletion(id: staffId) + .execute(); + + return response.data.emergencyContacts.isNotEmpty; + }); + } + + @override + Future getExperienceCompletion() async { + return _service.run(() async { + final String staffId = await _service.getStaffId(); + + final QueryResult response = + await _service.connector + .getStaffExperienceProfileCompletion(id: staffId) + .execute(); + + final GetStaffExperienceProfileCompletionStaff? staff = + response.data.staff; + + return _hasExperience(staff); + }); + } + + @override + Future getTaxFormsCompletion() async { + return _service.run(() async { + final String staffId = await _service.getStaffId(); + + final QueryResult response = + await _service.connector + .getStaffTaxFormsProfileCompletion(id: staffId) + .execute(); + + return response.data.taxForms.isNotEmpty; + }); + } + + /// Checks if personal info is complete. + bool _isPersonalInfoComplete(GetStaffPersonalInfoCompletionStaff? staff) { + if (staff == null) return false; + final String? fullName = staff.fullName; + final String? email = staff.email; + final String? phone = staff.phone; + return (fullName?.trim().isNotEmpty ?? false) && + (email?.trim().isNotEmpty ?? false) && + (phone?.trim().isNotEmpty ?? false); + } + /// Checks if staff has experience data (skills or industries). - bool _hasExperience(GetStaffProfileCompletionStaff? staff) { + bool _hasExperience(GetStaffExperienceProfileCompletionStaff? staff) { if (staff == null) return false; final dynamic skills = staff.skills; final dynamic industries = staff.industries; @@ -51,9 +127,14 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { List emergencyContacts, List taxForms, ) { - return staff != null && - emergencyContacts.isNotEmpty && + if (staff == null) return false; + final dynamic skills = staff.skills; + final dynamic industries = staff.industries; + final bool hasExperience = + (skills is List && skills.isNotEmpty) || + (industries is List && industries.isNotEmpty); + return emergencyContacts.isNotEmpty && taxForms.isNotEmpty && - _hasExperience(staff); + hasExperience; } } diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart index 779e0042..b4dc384b 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart @@ -10,4 +10,24 @@ abstract interface class StaffConnectorRepository { /// /// Throws an exception if the query fails. Future getProfileCompletion(); + + /// Fetches personal information completion status. + /// + /// Returns true if personal info (name, email, phone, locations) is complete. + Future getPersonalInfoCompletion(); + + /// Fetches emergency contacts completion status. + /// + /// Returns true if at least one emergency contact exists. + Future getEmergencyContactsCompletion(); + + /// Fetches experience completion status. + /// + /// Returns true if staff has industries or skills defined. + Future getExperienceCompletion(); + + /// Fetches tax forms completion status. + /// + /// Returns true if at least one tax form exists. + Future getTaxFormsCompletion(); } diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_emergency_contacts_completion_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_emergency_contacts_completion_usecase.dart new file mode 100644 index 00000000..63c43dd4 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_emergency_contacts_completion_usecase.dart @@ -0,0 +1,27 @@ +import 'package:krow_core/core.dart'; + +import '../repositories/staff_connector_repository.dart'; + +/// Use case for retrieving emergency contacts completion status. +/// +/// This use case encapsulates the business logic for determining whether +/// a staff member has at least one emergency contact registered. +/// It delegates to the repository for data access. +class GetEmergencyContactsCompletionUseCase extends NoInputUseCase { + /// Creates a [GetEmergencyContactsCompletionUseCase]. + /// + /// Requires a [StaffConnectorRepository] for data access. + GetEmergencyContactsCompletionUseCase({ + required StaffConnectorRepository repository, + }) : _repository = repository; + + final StaffConnectorRepository _repository; + + /// Executes the use case to get emergency contacts completion status. + /// + /// Returns true if emergency contacts are registered, false otherwise. + /// + /// Throws an exception if the operation fails. + @override + Future call() => _repository.getEmergencyContactsCompletion(); +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_experience_completion_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_experience_completion_usecase.dart new file mode 100644 index 00000000..e744add4 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_experience_completion_usecase.dart @@ -0,0 +1,27 @@ +import 'package:krow_core/core.dart'; + +import '../repositories/staff_connector_repository.dart'; + +/// Use case for retrieving experience completion status. +/// +/// This use case encapsulates the business logic for determining whether +/// a staff member has experience data (skills or industries) defined. +/// It delegates to the repository for data access. +class GetExperienceCompletionUseCase extends NoInputUseCase { + /// Creates a [GetExperienceCompletionUseCase]. + /// + /// Requires a [StaffConnectorRepository] for data access. + GetExperienceCompletionUseCase({ + required StaffConnectorRepository repository, + }) : _repository = repository; + + final StaffConnectorRepository _repository; + + /// Executes the use case to get experience completion status. + /// + /// Returns true if experience data is defined, false otherwise. + /// + /// Throws an exception if the operation fails. + @override + Future call() => _repository.getExperienceCompletion(); +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_personal_info_completion_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_personal_info_completion_usecase.dart new file mode 100644 index 00000000..a4a3f46d --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_personal_info_completion_usecase.dart @@ -0,0 +1,27 @@ +import 'package:krow_core/core.dart'; + +import '../repositories/staff_connector_repository.dart'; + +/// Use case for retrieving personal information completion status. +/// +/// This use case encapsulates the business logic for determining whether +/// a staff member's personal information is complete (name, email, phone). +/// It delegates to the repository for data access. +class GetPersonalInfoCompletionUseCase extends NoInputUseCase { + /// Creates a [GetPersonalInfoCompletionUseCase]. + /// + /// Requires a [StaffConnectorRepository] for data access. + GetPersonalInfoCompletionUseCase({ + required StaffConnectorRepository repository, + }) : _repository = repository; + + final StaffConnectorRepository _repository; + + /// Executes the use case to get personal info completion status. + /// + /// Returns true if personal information is complete, false otherwise. + /// + /// Throws an exception if the operation fails. + @override + Future call() => _repository.getPersonalInfoCompletion(); +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart index 5aa37816..f079eb23 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_profile_completion_usecase.dart @@ -1,3 +1,5 @@ +import 'package:krow_core/core.dart'; + import '../repositories/staff_connector_repository.dart'; /// Use case for retrieving staff profile completion status. @@ -5,7 +7,7 @@ import '../repositories/staff_connector_repository.dart'; /// This use case encapsulates the business logic for determining whether /// a staff member's profile is complete. It delegates to the repository /// for data access. -class GetProfileCompletionUseCase { +class GetProfileCompletionUseCase extends NoInputUseCase { /// Creates a [GetProfileCompletionUseCase]. /// /// Requires a [StaffConnectorRepository] for data access. @@ -20,5 +22,6 @@ class GetProfileCompletionUseCase { /// Returns true if the profile is complete, false otherwise. /// /// Throws an exception if the operation fails. + @override Future call() => _repository.getProfileCompletion(); } diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_tax_forms_completion_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_tax_forms_completion_usecase.dart new file mode 100644 index 00000000..9a8fda29 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_tax_forms_completion_usecase.dart @@ -0,0 +1,27 @@ +import 'package:krow_core/core.dart'; + +import '../repositories/staff_connector_repository.dart'; + +/// Use case for retrieving tax forms completion status. +/// +/// This use case encapsulates the business logic for determining whether +/// a staff member has at least one tax form submitted. +/// It delegates to the repository for data access. +class GetTaxFormsCompletionUseCase extends NoInputUseCase { + /// Creates a [GetTaxFormsCompletionUseCase]. + /// + /// Requires a [StaffConnectorRepository] for data access. + GetTaxFormsCompletionUseCase({ + required StaffConnectorRepository repository, + }) : _repository = repository; + + final StaffConnectorRepository _repository; + + /// Executes the use case to get tax forms completion status. + /// + /// Returns true if tax forms are submitted, false otherwise. + /// + /// Throws an exception if the operation fails. + @override + Future call() => _repository.getTaxFormsCompletion(); +} diff --git a/apps/mobile/packages/data_connect/pubspec.yaml b/apps/mobile/packages/data_connect/pubspec.yaml index 48d0039b..374204e5 100644 --- a/apps/mobile/packages/data_connect/pubspec.yaml +++ b/apps/mobile/packages/data_connect/pubspec.yaml @@ -13,8 +13,9 @@ dependencies: sdk: flutter krow_domain: path: ../domain + krow_core: + path: ../core flutter_modular: ^6.3.0 firebase_data_connect: ^0.2.2+2 firebase_core: ^4.4.0 firebase_auth: ^6.1.4 - krow_core: ^0.0.1 From 7b9507b87f41affee9c0ba86ac98b28c0b6b5024 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Feb 2026 13:39:03 -0500 Subject: [PATCH 07/18] feat: Refactor staff profile page and logout button for improved state management and navigation --- .../pages/staff_profile_page.dart | 192 +++++++++--------- .../presentation/widgets/logout_button.dart | 86 +++++--- .../presentation/widgets/profile_header.dart | 34 +--- docs/MOBILE/01-architecture-principles.md | 2 +- 4 files changed, 161 insertions(+), 153 deletions(-) diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart index 96b98016..0ee25694 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart @@ -38,116 +38,112 @@ class StaffProfilePage extends StatelessWidget { } } - void _onSignOut(ProfileCubit cubit, ProfileState state) { - if (state.status != ProfileStatus.loading) { - cubit.signOut(); - } - } - @override Widget build(BuildContext context) { - final ProfileCubit cubit = Modular.get(); - - // Load profile data on first build - if (cubit.state.status == ProfileStatus.initial) { - cubit.loadProfile(); - } - return Scaffold( - body: BlocConsumer( - bloc: cubit, - listener: (BuildContext context, ProfileState state) { - if (state.status == ProfileStatus.signedOut) { - Modular.to.toGetStartedPage(); - } else if (state.status == ProfileStatus.error && - state.errorMessage != null) { - UiSnackbar.show( - context, - message: translateErrorKey(state.errorMessage!), - type: UiSnackbarType.error, - ); - } - }, - builder: (BuildContext context, ProfileState state) { - // Show loading spinner if status is loading + body: BlocProvider( + create: (_) => Modular.get()..loadProfile(), + child: BlocConsumer( + listener: (BuildContext context, ProfileState state) { + if (state.status == ProfileStatus.signedOut) { + Modular.to.toGetStartedPage(); + } else if (state.status == ProfileStatus.error && + state.errorMessage != null) { + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, + ); + } + }, + builder: (BuildContext context, ProfileState state) { + // Show loading spinner if status is loading if (state.status == ProfileStatus.loading) { return const Center(child: CircularProgressIndicator()); } if (state.status == ProfileStatus.error) { - return Center( - child: Padding( - padding: const EdgeInsets.all(UiConstants.space4), - child: Text( - state.errorMessage != null - ? translateErrorKey(state.errorMessage!) - : 'An error occurred', - textAlign: TextAlign.center, - style: UiTypography.body1r.copyWith( - color: UiColors.textSecondary, + return Center( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Text( + state.errorMessage != null + ? translateErrorKey(state.errorMessage!) + : 'An error occurred', + textAlign: TextAlign.center, + style: UiTypography.body1r.copyWith( + color: UiColors.textSecondary, + ), ), ), + ); + } + + final Staff? profile = state.profile; + if (profile == null) { + return const Center(child: CircularProgressIndicator()); + } + + return SingleChildScrollView( + padding: const EdgeInsets.only(bottom: UiConstants.space16), + child: Column( + children: [ + ProfileHeader( + fullName: profile.name, + level: _mapStatusToLevel(profile.status), + photoUrl: profile.avatar, + ), + Transform.translate( + offset: const Offset(0, -UiConstants.space6), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + child: Column( + spacing: UiConstants.space6, + children: [ + // Reliability Stats and Score + ReliabilityStatsCard( + totalShifts: profile.totalShifts, + averageRating: profile.averageRating, + onTimeRate: profile.onTimeRate, + noShowCount: profile.noShowCount, + cancellationCount: profile.cancellationCount, + ), + + // Reliability Score Bar + ReliabilityScoreBar( + reliabilityScore: profile.reliabilityScore, + ), + + // Ordered sections + const OnboardingSection(), + + // Compliance section + const ComplianceSection(), + + // Finance section + const FinanceSection(), + + // Support section + const SupportSection(), + + // Settings section + const SettingsSection(), + + // Logout button at the bottom + const LogoutButton(), + + const SizedBox(height: UiConstants.space6), + ], + ), + ), + ), + ], ), ); - } - - final Staff? profile = state.profile; - if (profile == null) { - return const Center(child: CircularProgressIndicator()); - } - - return SingleChildScrollView( - padding: const EdgeInsets.only(bottom: UiConstants.space16), - child: Column( - children: [ - ProfileHeader( - fullName: profile.name, - level: _mapStatusToLevel(profile.status), - photoUrl: profile.avatar, - onSignOutTap: () => _onSignOut(cubit, state), - ), - Transform.translate( - offset: const Offset(0, -UiConstants.space6), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space5, - ), - child: Column( - children: [ - ReliabilityStatsCard( - totalShifts: profile.totalShifts, - averageRating: profile.averageRating, - onTimeRate: profile.onTimeRate, - noShowCount: profile.noShowCount, - cancellationCount: profile.cancellationCount, - ), - const SizedBox(height: UiConstants.space6), - ReliabilityScoreBar( - reliabilityScore: profile.reliabilityScore, - ), - const SizedBox(height: UiConstants.space6), - const OnboardingSection(), - const SizedBox(height: UiConstants.space6), - const ComplianceSection(), - const SizedBox(height: UiConstants.space6), - const FinanceSection(), - const SizedBox(height: UiConstants.space6), - const SupportSection(), - const SizedBox(height: UiConstants.space6), - const SettingsSection(), - const SizedBox(height: UiConstants.space6), - LogoutButton( - onTap: () => _onSignOut(cubit, state), - ), - const SizedBox(height: UiConstants.space12), - ], - ), - ), - ), - ], - ), - ); - }, + }, + ), ), ); } diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/logout_button.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/logout_button.dart index 3a2499c6..d74e9655 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/logout_button.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/logout_button.dart @@ -1,47 +1,73 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../blocs/profile_cubit.dart'; +import '../blocs/profile_state.dart'; /// The sign-out button widget. /// /// Uses design system tokens for all colors, typography, spacing, and icons. +/// Handles logout logic when tapped and navigates to onboarding on success. class LogoutButton extends StatelessWidget { - final VoidCallback onTap; + const LogoutButton({super.key}); - const LogoutButton({super.key, required this.onTap}); + /// Handles the sign-out action. + /// + /// Checks if the profile is not currently loading, then triggers the + /// sign-out process via the ProfileCubit. + void _handleSignOut(BuildContext context, ProfileState state) { + if (state.status != ProfileStatus.loading) { + context.read().signOut(); + } + } @override Widget build(BuildContext context) { - final i18n = t.staff.profile.header; + final TranslationsStaffProfileHeaderEn i18n = t.staff.profile.header; - return Container( - width: double.infinity, - decoration: BoxDecoration( - color: UiColors.bgPopup, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), - ), - child: Material( - color: UiColors.transparent, - child: InkWell( - onTap: onTap, + return BlocListener( + listener: (BuildContext context, ProfileState state) { + if (state.status == ProfileStatus.signedOut) { + // Navigate to get started page after successful sign-out + // This will be handled by the profile page listener + } + }, + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: UiColors.bgPopup, borderRadius: UiConstants.radiusLg, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - UiIcons.logOut, - color: UiColors.destructive, - size: 20, - ), - const SizedBox(width: UiConstants.space2), - Text( - i18n.sign_out, - style: UiTypography.body1m.textError, - ), - ], + border: Border.all(color: UiColors.border), + ), + child: Material( + color: UiColors.transparent, + child: InkWell( + onTap: () { + _handleSignOut( + context, + context.read().state, + ); + }, + borderRadius: UiConstants.radiusLg, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.logOut, + color: UiColors.destructive, + size: 20, + ), + const SizedBox(width: UiConstants.space2), + Text( + i18n.sign_out, + style: UiTypography.body1m.textError, + ), + ], + ), ), ), ), diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_header.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_header.dart index bee90690..04991ba1 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_header.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_header.dart @@ -2,8 +2,7 @@ import 'package:flutter/material.dart'; import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; -/// The header section of the staff profile page, containing avatar, name, level, -/// and a sign-out button. +/// The header section of the staff profile page, containing avatar, name, and level. /// /// Uses design system tokens for all colors, typography, and spacing. class ProfileHeader extends StatelessWidget { @@ -15,9 +14,6 @@ class ProfileHeader extends StatelessWidget { /// Optional photo URL for the avatar final String? photoUrl; - - /// Callback when sign out is tapped - final VoidCallback onSignOutTap; /// Creates a [ProfileHeader]. const ProfileHeader({ @@ -25,12 +21,11 @@ class ProfileHeader extends StatelessWidget { required this.fullName, required this.level, this.photoUrl, - required this.onSignOutTap, }); @override Widget build(BuildContext context) { - final i18n = t.staff.profile.header; + final TranslationsStaffProfileHeaderEn i18n = t.staff.profile.header; return Container( width: double.infinity, @@ -49,31 +44,22 @@ class ProfileHeader extends StatelessWidget { child: SafeArea( bottom: false, child: Column( - children: [ + children: [ // Top Bar Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + mainAxisAlignment: MainAxisAlignment.start, + children: [ Text( i18n.title, style: UiTypography.headline4m.textSecondary, ), - GestureDetector( - onTap: onSignOutTap, - child: Text( - i18n.sign_out, - style: UiTypography.body2m.copyWith( - color: UiColors.primaryForeground.withValues(alpha: 0.8), - ), - ), - ), ], ), const SizedBox(height: UiConstants.space8), // Avatar Section Stack( alignment: Alignment.bottomRight, - children: [ + children: [ Container( width: 112, height: 112, @@ -83,13 +69,13 @@ class ProfileHeader extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [ + colors: [ UiColors.accent, UiColors.accent.withValues(alpha: 0.5), UiColors.primaryForeground, ], ), - boxShadow: [ + boxShadow: [ BoxShadow( color: UiColors.foreground.withValues(alpha: 0.2), blurRadius: 10, @@ -119,7 +105,7 @@ class ProfileHeader extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [ + colors: [ UiColors.accent, UiColors.accent.withValues(alpha: 0.7), ], @@ -144,7 +130,7 @@ class ProfileHeader extends StatelessWidget { color: UiColors.primaryForeground, shape: BoxShape.circle, border: Border.all(color: UiColors.primary, width: 2), - boxShadow: [ + boxShadow: [ BoxShadow( color: UiColors.foreground.withValues(alpha: 0.1), blurRadius: 4, diff --git a/docs/MOBILE/01-architecture-principles.md b/docs/MOBILE/01-architecture-principles.md index b8c6f460..f151673a 100644 --- a/docs/MOBILE/01-architecture-principles.md +++ b/docs/MOBILE/01-architecture-principles.md @@ -68,7 +68,7 @@ graph TD - `data/`: Repository Implementations. - `presentation/`: - Pages, BLoCs, Widgets. - - For performance make the pages as `StatelessWidget` and move the state management to the BLoC or `StatefulWidget` to an external separate widget file. + - For performance make the pages as `StatelessWidget` and move the state management to the BLoC (always use a BlocProvider when providing the BLoC to the widget tree) or `StatefulWidget` to an external separate widget file. - **Responsibilities**: - **Presentation**: UI Pages, Modular Routes. - **State Management**: BLoCs / Cubits. From d50e09b67aab732622b0524992477078d596d1b1 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Feb 2026 14:27:11 -0500 Subject: [PATCH 08/18] feat: Implement staff profile retrieval and sign-out use cases; refactor profile management in the client app --- .../data_connect/lib/krow_data_connect.dart | 2 + .../staff_connector_repository_impl.dart | 49 ++++++++++++++ .../staff_connector_repository.dart | 16 +++++ .../usecases/get_staff_profile_usecase.dart | 28 ++++++++ .../usecases/sign_out_staff_usecase.dart | 25 ++++++++ .../repositories/profile_repository_impl.dart | 64 ------------------- .../repositories/profile_repository.dart | 26 -------- .../domain/usecases/get_profile_usecase.dart | 25 -------- .../src/domain/usecases/sign_out_usecase.dart | 25 -------- .../src/presentation/blocs/profile_cubit.dart | 11 ++-- .../pages/staff_profile_page.dart | 11 +++- .../profile/lib/src/staff_profile_module.dart | 35 +++++----- 12 files changed, 153 insertions(+), 164 deletions(-) create mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_staff_profile_usecase.dart create mode 100644 apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/sign_out_staff_usecase.dart delete mode 100644 apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart delete mode 100644 apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository.dart delete mode 100644 apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_profile_usecase.dart delete mode 100644 apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/sign_out_usecase.dart diff --git a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart index 4123cf8b..82d0bfb8 100644 --- a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart +++ b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart @@ -25,4 +25,6 @@ export 'src/connectors/staff/domain/usecases/get_personal_info_completion_usecas export 'src/connectors/staff/domain/usecases/get_emergency_contacts_completion_usecase.dart'; export 'src/connectors/staff/domain/usecases/get_experience_completion_usecase.dart'; export 'src/connectors/staff/domain/usecases/get_tax_forms_completion_usecase.dart'; +export 'src/connectors/staff/domain/usecases/get_staff_profile_usecase.dart'; +export 'src/connectors/staff/domain/usecases/sign_out_staff_usecase.dart'; export 'src/connectors/staff/data/repositories/staff_connector_repository_impl.dart'; \ No newline at end of file diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart index 45c5fd3f..52e66b98 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart @@ -1,5 +1,6 @@ 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'; /// Implementation of [StaffConnectorRepository]. /// @@ -137,4 +138,52 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { taxForms.isNotEmpty && hasExperience; } + + @override + Future getStaffProfile() async { + return _service.run(() async { + final String staffId = await _service.getStaffId(); + + final QueryResult response = + await _service.connector + .getStaffById(id: staffId) + .execute(); + + if (response.data.staff == null) { + throw const ServerException( + technicalMessage: 'Staff not found', + ); + } + + final GetStaffByIdStaff rawStaff = response.data.staff!; + + // Map the raw data connect object to the Domain Entity + return Staff( + id: rawStaff.id, + authProviderId: rawStaff.userId, + name: rawStaff.fullName, + email: rawStaff.email ?? '', + phone: rawStaff.phone, + avatar: rawStaff.photoUrl, + status: StaffStatus.active, + address: rawStaff.addres, + totalShifts: rawStaff.totalShifts, + averageRating: rawStaff.averageRating, + onTimeRate: rawStaff.onTimeRate, + noShowCount: rawStaff.noShowCount, + cancellationCount: rawStaff.cancellationCount, + reliabilityScore: rawStaff.reliabilityScore, + ); + }); + } + + @override + Future signOut() async { + try { + await _service.auth.signOut(); + _service.clearCache(); + } catch (e) { + throw Exception('Error signing out: ${e.toString()}'); + } + } } diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart index b4dc384b..abd25156 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart @@ -1,3 +1,5 @@ +import 'package:krow_domain/krow_domain.dart'; + /// Repository interface for staff connector queries. /// /// This interface defines the contract for accessing staff-related data @@ -30,4 +32,18 @@ abstract interface class StaffConnectorRepository { /// /// Returns true if at least one tax form exists. Future getTaxFormsCompletion(); + + /// Fetches the full staff profile for the current authenticated user. + /// + /// Returns a [Staff] entity containing all profile information. + /// + /// Throws an exception if the profile cannot be retrieved. + Future getStaffProfile(); + + /// Signs out the current user. + /// + /// Clears the user's session and authentication state. + /// + /// Throws an exception if the sign-out fails. + Future signOut(); } diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_staff_profile_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_staff_profile_usecase.dart new file mode 100644 index 00000000..3889bd49 --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/get_staff_profile_usecase.dart @@ -0,0 +1,28 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../repositories/staff_connector_repository.dart'; + +/// Use case for fetching a staff member's full profile information. +/// +/// This use case encapsulates the business logic for retrieving the complete +/// staff profile including personal info, ratings, and reliability scores. +/// It delegates to the repository for data access. +class GetStaffProfileUseCase extends UseCase { + /// Creates a [GetStaffProfileUseCase]. + /// + /// Requires a [StaffConnectorRepository] for data access. + GetStaffProfileUseCase({ + required StaffConnectorRepository repository, + }) : _repository = repository; + + final StaffConnectorRepository _repository; + + /// Executes the use case to get the staff profile. + /// + /// Returns a [Staff] entity containing all profile information. + /// + /// Throws an exception if the operation fails. + @override + Future call([void params]) => _repository.getStaffProfile(); +} diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/sign_out_staff_usecase.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/sign_out_staff_usecase.dart new file mode 100644 index 00000000..4331006c --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/usecases/sign_out_staff_usecase.dart @@ -0,0 +1,25 @@ +import 'package:krow_core/core.dart'; + +import '../repositories/staff_connector_repository.dart'; + +/// Use case for signing out the current staff user. +/// +/// This use case encapsulates the business logic for signing out, +/// including clearing authentication state and cache. +/// It delegates to the repository for data access. +class SignOutStaffUseCase extends NoInputUseCase { + /// Creates a [SignOutStaffUseCase]. + /// + /// Requires a [StaffConnectorRepository] for data access. + SignOutStaffUseCase({ + required StaffConnectorRepository repository, + }) : _repository = repository; + + final StaffConnectorRepository _repository; + + /// Executes the use case to sign out the user. + /// + /// Throws an exception if the operation fails. + @override + Future call() => _repository.signOut(); +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart b/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart deleted file mode 100644 index 42aa3a17..00000000 --- a/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:krow_data_connect/krow_data_connect.dart'; -import 'package:krow_domain/krow_domain.dart'; - -import '../../domain/repositories/profile_repository.dart'; - -/// Implementation of [ProfileRepositoryInterface] that delegates to data_connect. -/// -/// This implementation follows Clean Architecture by: -/// - Implementing the domain layer's repository interface -/// - Delegating all data access to the data_connect package -/// - Not containing any business logic -/// - Only performing data transformation/mapping if needed -/// -/// Currently uses [ProfileRepositoryMock] from data_connect. -/// When Firebase Data Connect is ready, this will be swapped with a real implementation. -class ProfileRepositoryImpl - implements ProfileRepositoryInterface { - /// Creates a [ProfileRepositoryImpl]. - ProfileRepositoryImpl() : _service = DataConnectService.instance; - - final DataConnectService _service; - - @override - Future getStaffProfile() async { - return _service.run(() async { - final staffId = await _service.getStaffId(); - final response = await _service.connector.getStaffById(id: staffId).execute(); - - if (response.data.staff == null) { - throw const ServerException(technicalMessage: 'Staff not found'); - } - - final GetStaffByIdStaff rawStaff = response.data.staff!; - - // Map the raw data connect object to the Domain Entity - return Staff( - id: rawStaff.id, - authProviderId: rawStaff.userId, - name: rawStaff.fullName, - email: rawStaff.email ?? '', - phone: rawStaff.phone, - avatar: rawStaff.photoUrl, - status: StaffStatus.active, - address: rawStaff.addres, - totalShifts: rawStaff.totalShifts, - averageRating: rawStaff.averageRating, - onTimeRate: rawStaff.onTimeRate, - noShowCount: rawStaff.noShowCount, - cancellationCount: rawStaff.cancellationCount, - reliabilityScore: rawStaff.reliabilityScore, - ); - }); - } - - @override - Future signOut() async { - try { - await _service.auth.signOut(); - _service.clearCache(); - } catch (e) { - throw Exception('Error signing out: ${e.toString()}'); - } - } -} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository.dart deleted file mode 100644 index 05868bbb..00000000 --- a/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:krow_domain/krow_domain.dart'; - -/// Repository interface for staff profile operations. -/// -/// Defines the contract for accessing and managing staff profile data. -/// This interface lives in the domain layer and is implemented by the data layer. -/// -/// Following Clean Architecture principles, this interface: -/// - Returns domain entities (Staff from shared domain package) -/// - Defines business requirements without implementation details -/// - Allows the domain layer to be independent of data sources -abstract interface class ProfileRepositoryInterface { - /// Fetches the staff profile for the current authenticated user. - /// - /// Returns a [Staff] entity from the shared domain package containing - /// all profile information. - /// - /// Throws an exception if the profile cannot be retrieved. - Future getStaffProfile(); - - /// Signs out the current user. - /// - /// Clears the user's session and authentication state. - /// Should be followed by navigation to the authentication flow. - Future signOut(); -} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_profile_usecase.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_profile_usecase.dart deleted file mode 100644 index bb1a96d8..00000000 --- a/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_profile_usecase.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; - -import '../repositories/profile_repository.dart'; - -/// Use case for fetching a staff member's extended profile information. -/// -/// This use case: -/// 1. Fetches the [Staff] object from the repository -/// 2. Returns it directly to the presentation layer -/// -class GetProfileUseCase implements UseCase { - final ProfileRepositoryInterface _repository; - - /// Creates a [GetProfileUseCase]. - /// - /// Requires a [ProfileRepositoryInterface] to interact with the profile data source. - const GetProfileUseCase(this._repository); - - @override - Future call([void params]) async { - // Fetch staff object from repository and return directly - return await _repository.getStaffProfile(); - } -} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/sign_out_usecase.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/sign_out_usecase.dart deleted file mode 100644 index 621d85a8..00000000 --- a/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/sign_out_usecase.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:krow_core/core.dart'; - -import '../repositories/profile_repository.dart'; - -/// Use case for signing out the current user. -/// -/// This use case delegates the sign-out logic to the [ProfileRepositoryInterface]. -/// -/// Following Clean Architecture principles, this use case: -/// - Encapsulates the sign-out business rule -/// - Depends only on the repository interface -/// - Keeps the domain layer independent of external frameworks -class SignOutUseCase implements NoInputUseCase { - final ProfileRepositoryInterface _repository; - - /// Creates a [SignOutUseCase]. - /// - /// Requires a [ProfileRepositoryInterface] to perform the sign-out operation. - const SignOutUseCase(this._repository); - - @override - Future call() { - return _repository.signOut(); - } -} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart index f4cba322..e1591ede 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart @@ -1,7 +1,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; -import '../../domain/usecases/get_profile_usecase.dart'; -import '../../domain/usecases/sign_out_usecase.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_domain/krow_domain.dart'; + import 'profile_state.dart'; /// Cubit for managing the Profile feature state. @@ -9,8 +10,8 @@ import 'profile_state.dart'; /// Handles loading profile data and user sign-out actions. class ProfileCubit extends Cubit with BlocErrorHandler { - final GetProfileUseCase _getProfileUseCase; - final SignOutUseCase _signOutUseCase; + final GetStaffProfileUseCase _getProfileUseCase; + final SignOutStaffUseCase _signOutUseCase; /// Creates a [ProfileCubit] with the required use cases. ProfileCubit(this._getProfileUseCase, this._signOutUseCase) @@ -27,7 +28,7 @@ class ProfileCubit extends Cubit await handleError( emit: emit, action: () async { - final profile = await _getProfileUseCase(); + final Staff profile = await _getProfileUseCase(); emit(state.copyWith(status: ProfileStatus.loaded, profile: profile)); }, onError: diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart index 0ee25694..36427da0 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart @@ -40,9 +40,16 @@ class StaffProfilePage extends StatelessWidget { @override Widget build(BuildContext context) { + final ProfileCubit cubit = Modular.get(); + + // Load profile data on first build if not already loaded + if (cubit.state.status == ProfileStatus.initial) { + cubit.loadProfile(); + } + return Scaffold( - body: BlocProvider( - create: (_) => Modular.get()..loadProfile(), + body: BlocProvider.value( + value: cubit, child: BlocConsumer( listener: (BuildContext context, ProfileState state) { if (state.status == ProfileStatus.signedOut) { diff --git a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart index 88f56cc5..ff52e308 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart @@ -1,11 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; -import 'data/repositories/profile_repository_impl.dart'; -import 'domain/repositories/profile_repository.dart'; -import 'domain/usecases/get_profile_usecase.dart'; -import 'domain/usecases/sign_out_usecase.dart'; import 'presentation/blocs/profile_cubit.dart'; import 'presentation/pages/staff_profile_page.dart'; @@ -15,28 +12,32 @@ import 'presentation/pages/staff_profile_page.dart'; /// following Clean Architecture principles. /// /// Dependency flow: -/// - Repository implementation (ProfileRepositoryImpl) delegates to data_connect -/// - Use cases depend on repository interface +/// - Use cases from data_connect layer (StaffConnectorRepository) /// - Cubit depends on use cases class StaffProfileModule extends Module { @override void binds(Injector i) { - // Repository implementation - delegates to data_connect - i.addLazySingleton( - ProfileRepositoryImpl.new, + // StaffConnectorRepository intialization + i.addLazySingleton( + () => StaffConnectorRepositoryImpl(), ); - // Use cases - depend on repository interface - i.addLazySingleton( - () => GetProfileUseCase(i.get()), + // Use cases from data_connect - depend on StaffConnectorRepository + i.addLazySingleton( + () => + GetStaffProfileUseCase(repository: i.get()), ); - i.addLazySingleton( - () => SignOutUseCase(i.get()), + i.addLazySingleton( + () => SignOutStaffUseCase(repository: i.get()), ); - // Presentation layer - Cubit depends on use cases - i.add( - () => ProfileCubit(i.get(), i.get()), + // Presentation layer - Cubit as singleton to avoid recreation + // BlocProvider will use this same instance, preventing state emission after close + i.addSingleton( + () => ProfileCubit( + i.get(), + i.get(), + ), ); } From 3640bfafa3c77f29d5c20781a25480c1cdd66901 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Feb 2026 14:41:44 -0500 Subject: [PATCH 09/18] feat: Implement completion status tracking for personal info, emergency contacts, experience, and tax forms in profile management --- .../src/presentation/blocs/profile_cubit.dart | 70 ++++++++++++++++++- .../src/presentation/blocs/profile_state.dart | 34 ++++++++- .../pages/staff_profile_page.dart | 9 +++ .../widgets/profile_menu_item.dart | 31 ++++---- .../widgets/sections/compliance_section.dart | 33 +++++---- .../widgets/sections/onboarding_section.dart | 54 ++++++++------ .../profile/lib/src/staff_profile_module.dart | 24 +++++++ 7 files changed, 203 insertions(+), 52 deletions(-) diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart index e1591ede..12072cfd 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart @@ -12,10 +12,20 @@ class ProfileCubit extends Cubit with BlocErrorHandler { final GetStaffProfileUseCase _getProfileUseCase; final SignOutStaffUseCase _signOutUseCase; + final GetPersonalInfoCompletionUseCase _getPersonalInfoCompletionUseCase; + final GetEmergencyContactsCompletionUseCase _getEmergencyContactsCompletionUseCase; + final GetExperienceCompletionUseCase _getExperienceCompletionUseCase; + final GetTaxFormsCompletionUseCase _getTaxFormsCompletionUseCase; /// Creates a [ProfileCubit] with the required use cases. - ProfileCubit(this._getProfileUseCase, this._signOutUseCase) - : super(const ProfileState()); + ProfileCubit( + this._getProfileUseCase, + this._signOutUseCase, + this._getPersonalInfoCompletionUseCase, + this._getEmergencyContactsCompletionUseCase, + this._getExperienceCompletionUseCase, + this._getTaxFormsCompletionUseCase, + ) : super(const ProfileState()); /// Loads the staff member's profile. /// @@ -64,5 +74,61 @@ class ProfileCubit extends Cubit }, ); } + + /// Loads personal information completion status. + Future loadPersonalInfoCompletion() async { + await handleError( + emit: emit, + action: () async { + final bool isComplete = await _getPersonalInfoCompletionUseCase(); + emit(state.copyWith(personalInfoComplete: isComplete)); + }, + onError: (String _) { + return state.copyWith(personalInfoComplete: false); + }, + ); + } + + /// Loads emergency contacts completion status. + Future loadEmergencyContactsCompletion() async { + await handleError( + emit: emit, + action: () async { + final bool isComplete = await _getEmergencyContactsCompletionUseCase(); + emit(state.copyWith(emergencyContactsComplete: isComplete)); + }, + onError: (String _) { + return state.copyWith(emergencyContactsComplete: false); + }, + ); + } + + /// Loads experience completion status. + Future loadExperienceCompletion() async { + await handleError( + emit: emit, + action: () async { + final bool isComplete = await _getExperienceCompletionUseCase(); + emit(state.copyWith(experienceComplete: isComplete)); + }, + onError: (String _) { + return state.copyWith(experienceComplete: false); + }, + ); + } + + /// Loads tax forms completion status. + Future loadTaxFormsCompletion() async { + await handleError( + emit: emit, + action: () async { + final bool isComplete = await _getTaxFormsCompletionUseCase(); + emit(state.copyWith(taxFormsComplete: isComplete)); + }, + onError: (String _) { + return state.copyWith(taxFormsComplete: false); + }, + ); + } } diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart index 05668656..0b9dca53 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart @@ -32,11 +32,27 @@ class ProfileState extends Equatable { /// Error message if status is error final String? errorMessage; + + /// Whether personal information is complete + final bool? personalInfoComplete; + + /// Whether emergency contacts are complete + final bool? emergencyContactsComplete; + + /// Whether experience information is complete + final bool? experienceComplete; + + /// Whether tax forms are complete + final bool? taxFormsComplete; const ProfileState({ this.status = ProfileStatus.initial, this.profile, this.errorMessage, + this.personalInfoComplete, + this.emergencyContactsComplete, + this.experienceComplete, + this.taxFormsComplete, }); /// Creates a copy of this state with updated values. @@ -44,14 +60,30 @@ class ProfileState extends Equatable { ProfileStatus? status, Staff? profile, String? errorMessage, + bool? personalInfoComplete, + bool? emergencyContactsComplete, + bool? experienceComplete, + bool? taxFormsComplete, }) { return ProfileState( status: status ?? this.status, profile: profile ?? this.profile, errorMessage: errorMessage ?? this.errorMessage, + personalInfoComplete: personalInfoComplete ?? this.personalInfoComplete, + emergencyContactsComplete: emergencyContactsComplete ?? this.emergencyContactsComplete, + experienceComplete: experienceComplete ?? this.experienceComplete, + taxFormsComplete: taxFormsComplete ?? this.taxFormsComplete, ); } @override - List get props => [status, profile, errorMessage]; + List get props => [ + status, + profile, + errorMessage, + personalInfoComplete, + emergencyContactsComplete, + experienceComplete, + taxFormsComplete, + ]; } diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart index 36427da0..8ffeefc3 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart @@ -52,6 +52,15 @@ class StaffProfilePage extends StatelessWidget { value: cubit, child: BlocConsumer( listener: (BuildContext context, ProfileState state) { + // Load completion statuses when profile loads successfully + if (state.status == ProfileStatus.loaded && + state.personalInfoComplete == null) { + cubit.loadPersonalInfoCompletion(); + cubit.loadEmergencyContactsCompletion(); + cubit.loadExperienceCompletion(); + cubit.loadTaxFormsCompletion(); + } + if (state.status == ProfileStatus.signedOut) { Modular.to.toGetStartedPage(); } else if (state.status == ProfileStatus.error && diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_menu_item.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_menu_item.dart index d61fac6f..76f2b30b 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_menu_item.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_menu_item.dart @@ -1,15 +1,10 @@ -import 'package:flutter/material.dart'; import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; /// An individual item within the profile menu grid. /// /// Uses design system tokens for all colors, typography, spacing, and borders. class ProfileMenuItem extends StatelessWidget { - final IconData icon; - final String label; - final bool? completed; - final VoidCallback? onTap; - const ProfileMenuItem({ super.key, required this.icon, @@ -18,6 +13,11 @@ class ProfileMenuItem extends StatelessWidget { this.onTap, }); + final IconData icon; + final String label; + final bool? completed; + final VoidCallback? onTap; + @override Widget build(BuildContext context) { return GestureDetector( @@ -32,12 +32,12 @@ class ProfileMenuItem extends StatelessWidget { child: AspectRatio( aspectRatio: 1.0, child: Stack( - children: [ + children: [ Align( alignment: Alignment.center, child: Column( mainAxisAlignment: MainAxisAlignment.center, - children: [ + children: [ Container( width: 36, height: 36, @@ -73,21 +73,22 @@ class ProfileMenuItem extends StatelessWidget { height: 16, decoration: BoxDecoration( shape: BoxShape.circle, + border: Border.all( + color: completed! ? UiColors.primary : UiColors.error, + width: 0.5, + ), color: completed! - ? UiColors.primary - : UiColors.primary.withValues(alpha: 0.1), + ? UiColors.primary.withValues(alpha: 0.1) + : UiColors.error.withValues(alpha: 0.15), ), alignment: Alignment.center, child: completed! ? const Icon( UiIcons.check, size: 10, - color: UiColors.primaryForeground, + color: UiColors.primary, ) - : Text( - "!", - style: UiTypography.footnote2b.primary, - ), + : Text("!", style: UiTypography.footnote2b.textError), ), ), ], diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/compliance_section.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/compliance_section.dart index a3a5211a..11d303df 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/compliance_section.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/compliance_section.dart @@ -1,9 +1,12 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import '../../blocs/profile_cubit.dart'; +import '../../blocs/profile_state.dart'; import '../profile_menu_grid.dart'; import '../profile_menu_item.dart'; import '../section_title.dart'; @@ -11,6 +14,7 @@ import '../section_title.dart'; /// Widget displaying the compliance section of the staff profile. /// /// This section contains menu items for tax forms and other compliance-related documents. +/// Displays completion status for each item. class ComplianceSection extends StatelessWidget { /// Creates a [ComplianceSection]. const ComplianceSection({super.key}); @@ -19,21 +23,26 @@ class ComplianceSection extends StatelessWidget { Widget build(BuildContext context) { final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionTitle(i18n.sections.compliance), - ProfileMenuGrid( - crossAxisCount: 3, + return BlocBuilder( + builder: (BuildContext context, ProfileState state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ProfileMenuItem( - icon: UiIcons.file, - label: i18n.menu_items.tax_forms, - onTap: () => Modular.to.toTaxForms(), + SectionTitle(i18n.sections.compliance), + ProfileMenuGrid( + crossAxisCount: 3, + children: [ + ProfileMenuItem( + icon: UiIcons.file, + label: i18n.menu_items.tax_forms, + completed: state.taxFormsComplete, + onTap: () => Modular.to.toTaxForms(), + ), + ], ), ], - ), - ], + ); + }, ); } } diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart index 2d9201e3..02927cd4 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart @@ -1,9 +1,12 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import '../../blocs/profile_cubit.dart'; +import '../../blocs/profile_state.dart'; import '../profile_menu_grid.dart'; import '../profile_menu_item.dart'; import '../section_title.dart'; @@ -11,7 +14,7 @@ import '../section_title.dart'; /// Widget displaying the onboarding section of the staff profile. /// /// This section contains menu items for personal information, emergency contact, -/// and work experience setup. +/// and work experience setup. Displays completion status for each item. class OnboardingSection extends StatelessWidget { /// Creates an [OnboardingSection]. const OnboardingSection({super.key}); @@ -20,30 +23,37 @@ class OnboardingSection extends StatelessWidget { Widget build(BuildContext context) { final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile; - return Column( - children: [ - SectionTitle(i18n.sections.onboarding), - ProfileMenuGrid( - crossAxisCount: 3, + return BlocBuilder( + builder: (BuildContext context, ProfileState state) { + return Column( children: [ - ProfileMenuItem( - icon: UiIcons.user, - label: i18n.menu_items.personal_info, - onTap: () => Modular.to.toPersonalInfo(), - ), - ProfileMenuItem( - icon: UiIcons.phone, - label: i18n.menu_items.emergency_contact, - onTap: () => Modular.to.toEmergencyContact(), - ), - ProfileMenuItem( - icon: UiIcons.briefcase, - label: i18n.menu_items.experience, - onTap: () => Modular.to.toExperience(), + SectionTitle(i18n.sections.onboarding), + ProfileMenuGrid( + crossAxisCount: 3, + children: [ + ProfileMenuItem( + icon: UiIcons.user, + label: i18n.menu_items.personal_info, + completed: state.personalInfoComplete, + onTap: () => Modular.to.toPersonalInfo(), + ), + ProfileMenuItem( + icon: UiIcons.phone, + label: i18n.menu_items.emergency_contact, + completed: true, + onTap: () => Modular.to.toEmergencyContact(), + ), + ProfileMenuItem( + icon: UiIcons.briefcase, + label: i18n.menu_items.experience, + completed: state.experienceComplete, + onTap: () => Modular.to.toExperience(), + ), + ], ), ], - ), - ], + ); + }, ); } } diff --git a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart index ff52e308..06b38c53 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart @@ -30,6 +30,26 @@ class StaffProfileModule extends Module { i.addLazySingleton( () => SignOutStaffUseCase(repository: i.get()), ); + i.addLazySingleton( + () => GetPersonalInfoCompletionUseCase( + repository: i.get(), + ), + ); + i.addLazySingleton( + () => GetEmergencyContactsCompletionUseCase( + repository: i.get(), + ), + ); + i.addLazySingleton( + () => GetExperienceCompletionUseCase( + repository: i.get(), + ), + ); + i.addLazySingleton( + () => GetTaxFormsCompletionUseCase( + repository: i.get(), + ), + ); // Presentation layer - Cubit as singleton to avoid recreation // BlocProvider will use this same instance, preventing state emission after close @@ -37,6 +57,10 @@ class StaffProfileModule extends Module { () => ProfileCubit( i.get(), i.get(), + i.get(), + i.get(), + i.get(), + i.get(), ), ); } From 4959ec1da4dc424931a64e6b1aeda1e9b0f6e740 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Feb 2026 14:42:20 -0500 Subject: [PATCH 10/18] feat: Update emergency contact completion status in onboarding section --- .../src/presentation/widgets/sections/onboarding_section.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart index 02927cd4..ece3bc18 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart @@ -40,7 +40,7 @@ class OnboardingSection extends StatelessWidget { ProfileMenuItem( icon: UiIcons.phone, label: i18n.menu_items.emergency_contact, - completed: true, + completed: state.emergencyContactsComplete, onTap: () => Modular.to.toEmergencyContact(), ), ProfileMenuItem( From 5fb9d75c58629300bdb581aa09b0f00c501bce1f Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Feb 2026 14:54:46 -0500 Subject: [PATCH 11/18] feat: Implement profile completion check in shifts management --- .../blocs/shifts/shifts_bloc.dart | 23 ++++++++ .../blocs/shifts/shifts_event.dart | 7 +++ .../blocs/shifts/shifts_state.dart | 5 ++ .../src/presentation/pages/shifts_page.dart | 56 +++++++++++++------ .../shifts/lib/src/staff_shifts_module.dart | 22 +++++++- 5 files changed, 94 insertions(+), 19 deletions(-) diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart index 6a8c1c43..83640a13 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_bloc.dart @@ -1,6 +1,7 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:meta/meta.dart'; @@ -22,6 +23,7 @@ class ShiftsBloc extends Bloc final GetPendingAssignmentsUseCase getPendingAssignments; final GetCancelledShiftsUseCase getCancelledShifts; final GetHistoryShiftsUseCase getHistoryShifts; + final GetProfileCompletionUseCase getProfileCompletion; ShiftsBloc({ required this.getMyShifts, @@ -29,6 +31,7 @@ class ShiftsBloc extends Bloc required this.getPendingAssignments, required this.getCancelledShifts, required this.getHistoryShifts, + required this.getProfileCompletion, }) : super(ShiftsInitial()) { on(_onLoadShifts); on(_onLoadHistoryShifts); @@ -36,6 +39,7 @@ class ShiftsBloc extends Bloc on(_onLoadFindFirst); on(_onLoadShiftsForRange); on(_onFilterAvailableShifts); + on(_onCheckProfileCompletion); } Future _onLoadShifts( @@ -268,6 +272,25 @@ class ShiftsBloc extends Bloc } } + Future _onCheckProfileCompletion( + CheckProfileCompletionEvent event, + Emitter emit, + ) async { + final currentState = state; + if (currentState is! ShiftsLoaded) return; + + await handleError( + emit: emit, + action: () async { + final bool isComplete = await getProfileCompletion(); + emit(currentState.copyWith(profileComplete: isComplete)); + }, + onError: (String errorKey) { + return currentState.copyWith(profileComplete: false); + }, + ); + } + List _getCalendarDaysForOffset(int weekOffset) { final now = DateTime.now(); final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart index d25866e0..e076c6bc 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_event.dart @@ -54,3 +54,10 @@ class DeclineShiftEvent extends ShiftsEvent { @override List get props => [shiftId]; } + +class CheckProfileCompletionEvent extends ShiftsEvent { + const CheckProfileCompletionEvent(); + + @override + List get props => []; +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart index d32e3fba..48e2eefe 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/blocs/shifts/shifts_state.dart @@ -25,6 +25,7 @@ class ShiftsLoaded extends ShiftsState { final bool myShiftsLoaded; final String searchQuery; final String jobType; + final bool? profileComplete; const ShiftsLoaded({ required this.myShifts, @@ -39,6 +40,7 @@ class ShiftsLoaded extends ShiftsState { required this.myShiftsLoaded, required this.searchQuery, required this.jobType, + this.profileComplete, }); ShiftsLoaded copyWith({ @@ -54,6 +56,7 @@ class ShiftsLoaded extends ShiftsState { bool? myShiftsLoaded, String? searchQuery, String? jobType, + bool? profileComplete, }) { return ShiftsLoaded( myShifts: myShifts ?? this.myShifts, @@ -68,6 +71,7 @@ class ShiftsLoaded extends ShiftsState { myShiftsLoaded: myShiftsLoaded ?? this.myShiftsLoaded, searchQuery: searchQuery ?? this.searchQuery, jobType: jobType ?? this.jobType, + profileComplete: profileComplete ?? this.profileComplete, ); } @@ -85,6 +89,7 @@ class ShiftsLoaded extends ShiftsState { myShiftsLoaded, searchQuery, jobType, + profileComplete ?? '', ]; } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart index 32ffc356..6d707901 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart @@ -43,6 +43,8 @@ class _ShiftsPageState extends State { _bloc.add(LoadAvailableShiftsEvent()); } } + // Check profile completion + _bloc.add(const CheckProfileCompletionEvent()); } @override @@ -138,15 +140,23 @@ class _ShiftsPageState extends State { // Tabs Row( children: [ - _buildTab( - "myshifts", - t.staff_shifts.tabs.my_shifts, - UiIcons.calendar, - myShifts.length, - showCount: myShiftsLoaded, - enabled: !blockTabsForFind, - ), - const SizedBox(width: UiConstants.space2), + if (state is ShiftsLoaded && state.profileComplete != false) + Expanded( + child: _buildTab( + "myshifts", + t.staff_shifts.tabs.my_shifts, + UiIcons.calendar, + myShifts.length, + showCount: myShiftsLoaded, + enabled: !blockTabsForFind && (state.profileComplete ?? false), + ), + ) + else + const SizedBox.shrink(), + if (state is ShiftsLoaded && state.profileComplete != false) + const SizedBox(width: UiConstants.space2) + else + const SizedBox.shrink(), _buildTab( "find", t.staff_shifts.tabs.find_work, @@ -155,15 +165,25 @@ class _ShiftsPageState extends State { showCount: availableLoaded, enabled: baseLoaded, ), - const SizedBox(width: UiConstants.space2), - _buildTab( - "history", - t.staff_shifts.tabs.history, - UiIcons.clock, - historyShifts.length, - showCount: historyLoaded, - enabled: !blockTabsForFind && baseLoaded, - ), + if (state is ShiftsLoaded && state.profileComplete != false) + const SizedBox(width: UiConstants.space2) + else + const SizedBox.shrink(), + if (state is ShiftsLoaded && state.profileComplete != false) + Expanded( + child: _buildTab( + "history", + t.staff_shifts.tabs.history, + UiIcons.clock, + historyShifts.length, + showCount: historyLoaded, + enabled: !blockTabsForFind && + baseLoaded && + (state.profileComplete ?? false), + ), + ) + else + const SizedBox.shrink(), ], ), ], diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart b/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart index 02bade2c..7d5b72a8 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/staff_shifts_module.dart @@ -1,4 +1,5 @@ import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; import 'domain/repositories/shifts_repository_interface.dart'; import 'data/repositories_impl/shifts_repository_impl.dart'; import 'domain/usecases/get_my_shifts_usecase.dart'; @@ -17,6 +18,18 @@ import 'presentation/pages/shifts_page.dart'; class StaffShiftsModule extends Module { @override void binds(Injector i) { + // StaffConnectorRepository for profile completion + i.addLazySingleton( + () => StaffConnectorRepositoryImpl(), + ); + + // Profile completion use case + i.addLazySingleton( + () => GetProfileCompletionUseCase( + repository: i.get(), + ), + ); + // Repository i.add(ShiftsRepositoryImpl.new); @@ -32,7 +45,14 @@ class StaffShiftsModule extends Module { i.add(GetShiftDetailsUseCase.new); // Bloc - i.add(ShiftsBloc.new); + i.add(() => ShiftsBloc( + getMyShifts: i.get(), + getAvailableShifts: i.get(), + getPendingAssignments: i.get(), + getCancelledShifts: i.get(), + getHistoryShifts: i.get(), + getProfileCompletion: i.get(), + )); i.add(ShiftDetailsBloc.new); } From d54979ceedaab1cc90f948201093c881426de8f6 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Feb 2026 15:11:54 -0500 Subject: [PATCH 12/18] feat: Refactor ProfileHeader and introduce ProfileLevelBadge for improved structure and functionality --- .../pages/staff_profile_page.dart | 16 +- .../widgets/header/profile_header.dart | 116 ++++++++++++ .../widgets/header/profile_level_badge.dart | 56 ++++++ .../presentation/widgets/profile_header.dart | 173 ------------------ 4 files changed, 173 insertions(+), 188 deletions(-) create mode 100644 apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_header.dart create mode 100644 apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_level_badge.dart delete mode 100644 apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_header.dart diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart index 8ffeefc3..23dbc84c 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart @@ -9,7 +9,7 @@ import 'package:krow_domain/krow_domain.dart'; import '../blocs/profile_cubit.dart'; import '../blocs/profile_state.dart'; import '../widgets/logout_button.dart'; -import '../widgets/profile_header.dart'; +import '../widgets/header/profile_header.dart'; import '../widgets/reliability_score_bar.dart'; import '../widgets/reliability_stats_card.dart'; import '../widgets/sections/index.dart'; @@ -25,19 +25,6 @@ class StaffProfilePage extends StatelessWidget { /// Creates a [StaffProfilePage]. const StaffProfilePage({super.key}); - String _mapStatusToLevel(StaffStatus status) { - switch (status) { - case StaffStatus.active: - case StaffStatus.verified: - return 'Krower I'; - case StaffStatus.pending: - case StaffStatus.completedProfile: - return 'Pending'; - default: - return 'New'; - } - } - @override Widget build(BuildContext context) { final ProfileCubit cubit = Modular.get(); @@ -106,7 +93,6 @@ class StaffProfilePage extends StatelessWidget { children: [ ProfileHeader( fullName: profile.name, - level: _mapStatusToLevel(profile.status), photoUrl: profile.avatar, ), Transform.translate( diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_header.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_header.dart new file mode 100644 index 00000000..33eead3a --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_header.dart @@ -0,0 +1,116 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'profile_level_badge.dart'; + +/// The header section of the staff profile page, containing avatar, name, and level. +/// +/// Uses design system tokens for all colors, typography, and spacing. +class ProfileHeader extends StatelessWidget { + /// Creates a [ProfileHeader]. + const ProfileHeader({ + super.key, + required this.fullName, + this.photoUrl, + }); + + /// The staff member's full name + final String fullName; + + /// Optional photo URL for the avatar + final String? photoUrl; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space5, + UiConstants.space5, + UiConstants.space16, + ), + decoration: const BoxDecoration( + color: UiColors.primary, + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(UiConstants.space6), + ), + ), + child: SafeArea( + bottom: false, + child: Column( + children: [ + // Avatar Section + Container( + width: 112, + height: 112, + padding: const EdgeInsets.all(UiConstants.space1), + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + UiColors.accent, + UiColors.accent.withValues(alpha: 0.5), + UiColors.primaryForeground, + ], + ), + boxShadow: [ + BoxShadow( + color: UiColors.foreground.withValues(alpha: 0.2), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: UiColors.primaryForeground.withValues(alpha: 0.2), + width: 4, + ), + ), + child: CircleAvatar( + backgroundColor: UiColors.background, + backgroundImage: photoUrl != null + ? NetworkImage(photoUrl!) + : null, + child: photoUrl == null + ? Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + UiColors.accent, + UiColors.accent.withValues(alpha: 0.7), + ], + ), + ), + alignment: Alignment.center, + child: Text( + fullName.isNotEmpty + ? fullName[0].toUpperCase() + : 'K', + style: UiTypography.displayM.primary, + ), + ) + : null, + ), + ), + ), + const SizedBox(height: UiConstants.space4), + Text(fullName, style: UiTypography.headline2m.white), + const SizedBox(height: UiConstants.space1), + const ProfileLevelBadge(), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_level_badge.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_level_badge.dart new file mode 100644 index 00000000..3661e192 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_level_badge.dart @@ -0,0 +1,56 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../../blocs/profile_cubit.dart'; +import '../../blocs/profile_state.dart'; + +/// A widget that displays the staff member's level badge. +/// +/// The level is calculated based on the staff status from ProfileCubit and displayed +/// in a styled container with the design system tokens. +class ProfileLevelBadge extends StatelessWidget { + /// Creates a [ProfileLevelBadge]. + const ProfileLevelBadge({super.key}); + + /// Maps staff status to a user-friendly level string. + String _mapStatusToLevel(StaffStatus status) { + switch (status) { + case StaffStatus.active: + case StaffStatus.verified: + return 'Krower I'; + case StaffStatus.pending: + case StaffStatus.completedProfile: + return 'Pending'; + default: + return 'New'; + } + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (BuildContext context, ProfileState state) { + final Staff? profile = state.profile; + if (profile == null) { + return const SizedBox.shrink(); + } + + final String level = _mapStatusToLevel(profile.status); + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: UiConstants.space1, + ), + decoration: BoxDecoration( + color: UiColors.accent.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(UiConstants.space5), + ), + child: Text(level, style: UiTypography.footnote1b.accent), + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_header.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_header.dart deleted file mode 100644 index 04991ba1..00000000 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_header.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; - -/// The header section of the staff profile page, containing avatar, name, and level. -/// -/// Uses design system tokens for all colors, typography, and spacing. -class ProfileHeader extends StatelessWidget { - /// The staff member's full name - final String fullName; - - /// The staff member's level (e.g., "Krower I") - final String level; - - /// Optional photo URL for the avatar - final String? photoUrl; - - /// Creates a [ProfileHeader]. - const ProfileHeader({ - super.key, - required this.fullName, - required this.level, - this.photoUrl, - }); - - @override - Widget build(BuildContext context) { - final TranslationsStaffProfileHeaderEn i18n = t.staff.profile.header; - - return Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB( - UiConstants.space5, - UiConstants.space5, - UiConstants.space5, - UiConstants.space16, - ), - decoration: const BoxDecoration( - color: UiColors.primary, - borderRadius: BorderRadius.vertical( - bottom: Radius.circular(UiConstants.space6), - ), - ), - child: SafeArea( - bottom: false, - child: Column( - children: [ - // Top Bar - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text( - i18n.title, - style: UiTypography.headline4m.textSecondary, - ), - ], - ), - const SizedBox(height: UiConstants.space8), - // Avatar Section - Stack( - alignment: Alignment.bottomRight, - children: [ - Container( - width: 112, - height: 112, - padding: const EdgeInsets.all(UiConstants.space1), - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - UiColors.accent, - UiColors.accent.withValues(alpha: 0.5), - UiColors.primaryForeground, - ], - ), - boxShadow: [ - BoxShadow( - color: UiColors.foreground.withValues(alpha: 0.2), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: UiColors.primaryForeground.withValues(alpha: 0.2), - width: 4, - ), - ), - child: CircleAvatar( - backgroundColor: UiColors.background, - backgroundImage: photoUrl != null - ? NetworkImage(photoUrl!) - : null, - child: photoUrl == null - ? Container( - width: double.infinity, - height: double.infinity, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - UiColors.accent, - UiColors.accent.withValues(alpha: 0.7), - ], - ), - ), - alignment: Alignment.center, - child: Text( - fullName.isNotEmpty - ? fullName[0].toUpperCase() - : 'K', - style: UiTypography.displayM.primary, - ), - ) - : null, - ), - ), - ), - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: UiColors.primaryForeground, - shape: BoxShape.circle, - border: Border.all(color: UiColors.primary, width: 2), - boxShadow: [ - BoxShadow( - color: UiColors.foreground.withValues(alpha: 0.1), - blurRadius: 4, - ), - ], - ), - child: const Icon( - UiIcons.camera, - size: 16, - color: UiColors.primary, - ), - ), - ], - ), - const SizedBox(height: UiConstants.space4), - Text( - fullName, - style: UiTypography.headline3m.textPlaceholder, - ), - const SizedBox(height: UiConstants.space1), - Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space3, - vertical: UiConstants.space1, - ), - decoration: BoxDecoration( - color: UiColors.accent.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(UiConstants.space5), - ), - child: Text( - level, - style: UiTypography.footnote1b.accent, - ), - ), - ], - ), - ), - ); - } -} From 5cf0c91ebe42af4ec85c34772715c3afdc178b4e Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Feb 2026 15:22:33 -0500 Subject: [PATCH 13/18] feat: Add guidelines for prop drilling prevention and BLoC lifecycle management in architecture principles --- docs/MOBILE/01-architecture-principles.md | 157 ++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/docs/MOBILE/01-architecture-principles.md b/docs/MOBILE/01-architecture-principles.md index f151673a..b37833ca 100644 --- a/docs/MOBILE/01-architecture-principles.md +++ b/docs/MOBILE/01-architecture-principles.md @@ -219,3 +219,160 @@ See **`03-data-connect-connectors-pattern.md`** for comprehensive documentation - Each connector follows Clean Architecture (domain interfaces + data implementations) - Features use connector repositories through dependency injection - Results in zero query duplication and single source of truth + +## 8. Prop Drilling Prevention & Direct BLoC Access + +### 8.1 The Problem: Prop Drilling + +Passing data through intermediate widgets creates maintenance headaches: +- Every intermediate widget must accept and forward props +- Changes to data structure ripple through multiple widget constructors +- Reduces code clarity and increases cognitive load + +**Anti-Pattern Example**: +```dart +// ❌ BAD: Drilling status through 3 levels +ProfilePage(status: status) + → ProfileHeader(status: status) + → ProfileLevelBadge(status: status) // Only widget that needs it! +``` + +### 8.2 The Solution: Direct BLoC Access with BlocBuilder + +Use `BlocBuilder` to access BLoC state directly in leaf widgets: + +**Correct Pattern**: +```dart +// ✅ GOOD: ProfileLevelBadge accesses ProfileCubit directly +class ProfileLevelBadge extends StatelessWidget { + const ProfileLevelBadge({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final Staff? profile = state.profile; + if (profile == null) return const SizedBox.shrink(); + + final level = _mapStatusToLevel(profile.status); + return LevelBadgeUI(level: level); + }, + ); + } +} +``` + +### 8.3 Guidelines for Avoiding Prop Drilling + +1. **Leaf Widgets Get Data from BLoC**: Widgets that need specific data should access it directly via BlocBuilder +2. **Container Widgets Stay Simple**: Parent widgets like `ProfileHeader` only manage layout and positioning +3. **No Unnecessary Props**: Don't pass data to intermediate widgets unless they need it for UI construction +4. **Single Responsibility**: Each widget should have one reason to exist + +**Decision Tree**: +``` +Does this widget need data? +├─ YES, and it's a leaf widget → Use BlocBuilder +├─ YES, and it's a container → Use BlocBuilder in child, not parent +└─ NO → Don't add prop to constructor +``` + +## 9. BLoC Lifecycle & State Emission Safety + +### 9.1 The Problem: StateError After Dispose + +When async operations complete after a BLoC is closed, attempting to emit state causes: +``` +StateError: Cannot emit new states after calling close +``` + +**Root Causes**: +1. **Transient BLoCs**: `BlocProvider(create:)` creates new instance on every rebuild → disposed prematurely +2. **Singleton Disposal**: Multiple BlocProviders disposing same singleton instance +3. **Navigation During Async**: User navigates away while `loadProfile()` is still running + +### 9.2 The Solution: Singleton BLoCs + Error Handler Defensive Wrapping + +#### Step 1: Register as Singleton + +```dart +// ✅ GOOD: ProfileCubit as singleton +i.addSingleton( + () => ProfileCubit(useCase1, useCase2), +); + +// ❌ BAD: Creates new instance each time +i.add(ProfileCubit.new); +``` + +#### Step 2: Use BlocProvider.value() for Singletons + +```dart +// ✅ GOOD: Use singleton instance +ProfileCubit cubit = Modular.get(); +BlocProvider.value( + value: cubit, // Reuse same instance + child: MyWidget(), +) + +// ❌ BAD: Creates duplicate instance +BlocProvider( + create: (_) => Modular.get(), // Wrong! + child: MyWidget(), +) +``` + +#### Step 3: Defensive Error Handling in BlocErrorHandler Mixin + +The `BlocErrorHandler` mixin provides `_safeEmit()` wrapper: + +**Location**: `apps/mobile/packages/core/lib/src/presentation/mixins/bloc_error_handler.dart` + +```dart +void _safeEmit(void Function(S) emit, S state) { + try { + emit(state); + } on StateError catch (e) { + // Bloc was closed before emit - log and continue gracefully + developer.log( + 'Could not emit state: ${e.message}. Bloc may have been disposed.', + name: runtimeType.toString(), + ); + } +} +``` + +**Usage in Cubits/Blocs**: +```dart +Future loadProfile() async { + emit(state.copyWith(status: ProfileStatus.loading)); + + await handleError( + emit: emit, + action: () async { + final profile = await getProfile(); + emit(state.copyWith(status: ProfileStatus.loaded, profile: profile)); + // ✅ If BLoC disposed before emit, _safeEmit catches StateError gracefully + }, + onError: (errorKey) { + return state.copyWith(status: ProfileStatus.error); + }, + ); +} +``` + +### 9.3 Pattern Summary + +| Pattern | When to Use | Risk | +|---------|------------|------| +| Singleton + BlocProvider.value() | Long-lived features (Profile, Shifts, etc.) | Low - instance persists | +| Transient + BlocProvider(create:) | Temporary widgets (Dialogs, Overlays) | Medium - requires careful disposal | +| Direct BlocBuilder | Leaf widgets needing data | Low - no registration needed | + +**Remember**: +- Use **singletons** for feature-level cubits accessed from multiple pages +- Use **transient** only for temporary UI states +- Always wrap emit() in `_safeEmit()` via `BlocErrorHandler` mixin +- Test navigation away during async operations to verify graceful handling + +``` From 4d935cd80c698161b55ebef336684ff81dcbb007 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Feb 2026 15:45:24 -0500 Subject: [PATCH 14/18] feat: Implement language selection feature in staff profile onboarding --- .../lib/src/routing/staff/route_paths.dart | 26 ++-- .../pages/staff_profile_page.dart | 3 - .../presentation/widgets/sections/index.dart | 2 +- .../pages/language_selection_page.dart | 113 ++++++++++++++++++ .../widgets/personal_info_form.dart | 64 ++++++++++ .../lib/src/staff_profile_info_module.dart | 18 ++- .../staff_main/lib/src/staff_main_module.dart | 2 +- 7 files changed, 209 insertions(+), 19 deletions(-) create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart diff --git a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart index 97badf3c..bcb0a472 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart @@ -16,14 +16,14 @@ class StaffPaths { /// Generate child route based on the given route and parent route /// /// This is useful for creating nested routes within modules. - static String childRoute(String parent, String child) { + static String childRoute(String parent, String child) { final String childPath = child.replaceFirst(parent, ''); - + // check if the child path is empty if (childPath.isEmpty) { return '/'; - } - + } + // ensure the child path starts with a '/' if (!childPath.startsWith('/')) { return '/$childPath'; @@ -31,7 +31,7 @@ class StaffPaths { return childPath; } - + // ========================================================================== // AUTHENTICATION // ========================================================================== @@ -107,8 +107,7 @@ class StaffPaths { /// Path format: `/worker-main/shift-details/{shiftId}` /// /// Example: `/worker-main/shift-details/shift123` - static String shiftDetails(String shiftId) => - '$shiftDetailsRoute/$shiftId'; + static String shiftDetails(String shiftId) => '$shiftDetailsRoute/$shiftId'; // ========================================================================== // ONBOARDING & PROFILE SECTIONS @@ -117,8 +116,17 @@ class StaffPaths { /// Personal information onboarding. /// /// Collect basic personal information during staff onboarding. - static const String onboardingPersonalInfo = - '/worker-main/onboarding/personal-info/'; + static const String onboardingPersonalInfo = '/worker-main/personal-info/'; + + // ========================================================================== + // PERSONAL INFORMATION & PREFERENCES + // ========================================================================== + + /// Language selection page. + /// + /// Allows staff to select their preferred language for the app interface. + static const String languageSelection = + '/worker-main/personal-info/language-selection/'; /// Emergency contact information. /// diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart index 23dbc84c..49767da9 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart @@ -130,9 +130,6 @@ class StaffProfilePage extends StatelessWidget { // Support section const SupportSection(), - // Settings section - const SettingsSection(), - // Logout button at the bottom const LogoutButton(), diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/index.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/index.dart index 967a4dac..6295bcba 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/index.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/index.dart @@ -1,5 +1,5 @@ export 'compliance_section.dart'; export 'finance_section.dart'; export 'onboarding_section.dart'; -export 'settings_section.dart'; export 'support_section.dart'; + diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart new file mode 100644 index 00000000..3c157413 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart @@ -0,0 +1,113 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; + +/// Language selection page for staff profile. +/// +/// Displays available languages and allows the user to select their preferred +/// language. Changes are applied immediately via [LocaleBloc] and persisted. +/// Shows a snackbar when the language is successfully changed. +class LanguageSelectionPage extends StatefulWidget { + /// Creates a [LanguageSelectionPage]. + const LanguageSelectionPage({super.key}); + + @override + State createState() => _LanguageSelectionPageState(); +} + +class _LanguageSelectionPageState extends State { + void _showLanguageChangedSnackbar(String languageName) { + UiSnackbar.show( + context, + message: 'Language changed to $languageName', + type: UiSnackbarType.success, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: UiAppBar( + title: 'Select Language', + showBackButton: true, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1.0), + child: Container(color: UiColors.border, height: 1.0), + ), + ), + body: SafeArea( + child: BlocBuilder( + builder: (BuildContext context, LocaleState state) { + return ListView( + padding: const EdgeInsets.all(UiConstants.space5), + children: [ + _buildLanguageOption( + context, + label: 'English', + locale: AppLocale.en, + ), + const SizedBox(height: UiConstants.space4), + _buildLanguageOption( + context, + label: 'Español', + locale: AppLocale.es, + ), + ], + ); + }, + ), + ), + ); + } + + Widget _buildLanguageOption( + BuildContext context, { + required String label, + required AppLocale locale, + }) { + // Check if this option is currently selected. + final AppLocale currentLocale = LocaleSettings.currentLocale; + final bool isSelected = currentLocale == locale; + + return InkWell( + onTap: () { + // Only proceed if selecting a different language + if (currentLocale != locale) { + Modular.get().add(ChangeLocale(locale.flutterLocale)); + _showLanguageChangedSnackbar(label); + } + }, + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + child: Container( + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space4, + horizontal: UiConstants.space4, + ), + decoration: BoxDecoration( + color: isSelected + ? UiColors.primary.withValues(alpha: 0.1) + : UiColors.background, + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + border: Border.all( + color: isSelected ? UiColors.primary : UiColors.border, + width: isSelected ? 2 : 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: isSelected + ? UiTypography.body1b.copyWith(color: UiColors.primary) + : UiTypography.body1r, + ), + if (isSelected) const Icon(UiIcons.check, color: UiColors.primary), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart index 6ae1fc46..06f145fb 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_form.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; /// A form widget containing all personal information fields. @@ -77,11 +79,73 @@ class PersonalInfoForm extends StatelessWidget { hint: i18n.locations_hint, enabled: enabled, ), + const SizedBox(height: UiConstants.space4), + + _FieldLabel(text: 'Language'), + const SizedBox(height: UiConstants.space2), + _LanguageSelector( + enabled: enabled, + ), ], ); } } +/// A language selector widget that displays the current language and navigates to language selection page. +class _LanguageSelector extends StatelessWidget { + const _LanguageSelector({ + this.enabled = true, + }); + + final bool enabled; + + String _getLanguageLabel(AppLocale locale) { + switch (locale) { + case AppLocale.en: + return 'English'; + case AppLocale.es: + return 'Español'; + } + } + + @override + Widget build(BuildContext context) { + final AppLocale currentLocale = LocaleSettings.currentLocale; + final String currentLanguage = _getLanguageLabel(currentLocale); + + return GestureDetector( + onTap: enabled + ? () => Modular.to.pushNamed(StaffPaths.languageSelection) + : null, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + vertical: UiConstants.space3, + ), + decoration: BoxDecoration( + color: UiColors.bgPopup, + borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), + border: Border.all(color: UiColors.border), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + currentLanguage, + style: UiTypography.body2r.textPrimary, + ), + Icon( + UiIcons.chevronRight, + color: UiColors.textSecondary, + ), + ], + ), + ), + ); + } +} + /// A label widget for form fields. /// A label widget for form fields. class _FieldLabel extends StatelessWidget { diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/staff_profile_info_module.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/staff_profile_info_module.dart index 47c80748..f949fa72 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/staff_profile_info_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/staff_profile_info_module.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import 'data/repositories/personal_info_repository_impl.dart'; import 'domain/repositories/personal_info_repository_interface.dart'; @@ -7,6 +8,7 @@ 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'; +import 'presentation/pages/language_selection_page.dart'; /// The entry module for the Staff Profile Info feature. /// @@ -23,7 +25,8 @@ class StaffProfileInfoModule extends Module { void binds(Injector i) { // Repository i.addLazySingleton( - PersonalInfoRepositoryImpl.new); + PersonalInfoRepositoryImpl.new, + ); // Use Cases - delegate business logic to repository i.addLazySingleton( @@ -45,13 +48,18 @@ class StaffProfileInfoModule extends Module { @override void routes(RouteManager r) { r.child( - '/personal-info', + StaffPaths.childRoute( + StaffPaths.onboardingPersonalInfo, + StaffPaths.onboardingPersonalInfo, + ), 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(), + StaffPaths.childRoute( + StaffPaths.onboardingPersonalInfo, + StaffPaths.languageSelection, + ), + child: (BuildContext context) => const LanguageSelectionPage(), ); } } diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart index 0fb79b75..21493654 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/staff_main_module.dart @@ -73,7 +73,7 @@ class StaffMainModule extends Module { ], ); r.module( - StaffPaths.childRoute(StaffPaths.main, StaffPaths.onboardingPersonalInfo).replaceFirst('/personal-info', ''), + StaffPaths.childRoute(StaffPaths.main, StaffPaths.onboardingPersonalInfo), module: StaffProfileInfoModule(), ); r.module( From b9c4e12aea2bf891f5a17534b097b11aa34b1024 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Feb 2026 15:45:53 -0500 Subject: [PATCH 15/18] feat: Close language selection page after showing success snackbar --- .../lib/src/presentation/pages/language_selection_page.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart index 3c157413..95ec18b1 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart @@ -24,6 +24,9 @@ class _LanguageSelectionPageState extends State { message: 'Language changed to $languageName', type: UiSnackbarType.success, ); + + Modular.to + .pop(); // Close the language selection page after showing the snackbar } @override From b85ea5fb7f64da71d16329cc41ff70e6508e638e Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Feb 2026 15:49:56 -0500 Subject: [PATCH 16/18] feat: Refactor LanguageSelectionPage to use StatelessWidget and improve localization handling --- .../client/reports/analysis_output.txt | Bin 75444 -> 0 bytes .../pages/language_selection_page.dart | 36 ++++++++---------- 2 files changed, 16 insertions(+), 20 deletions(-) delete mode 100644 apps/mobile/packages/features/client/reports/analysis_output.txt diff --git a/apps/mobile/packages/features/client/reports/analysis_output.txt b/apps/mobile/packages/features/client/reports/analysis_output.txt deleted file mode 100644 index e9cdc3824ef709b45d8cdbad68533c51b133156b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 75444 zcmeI5U2o&Y6^40Tp#MR=@&-kX*z)Jz6hXfbG(~`R)2jfE*7oi?^;)iE$8d}O^|tRb zN5_;Lt)w|jY!68ownd5@&U@y}`JUnb{`=SJyqZ?y>azNwx~Q(I_tl^})46N?UZqc0 z)v3PWszJJ9P<>bZp?Y0?Tm8A3=+3+9H@a)ApZ99zT<8DsaQ;qbCpvzY{-36AM{4_8 zt$ka4)Y-Rcle@mFw$slqt6x;V*ZDg={Zju&>Vx;|T1Tg9ZKNmBpZbo0`gWqHoa^sM zcaC)Lef2LLPjqHlebAYSj?Q%5akZm6KTp4d>XnZ7=8i|A!zlG>q`uL!&no)F6CSrb zsE%|eckk%iSNdzMZdbeM>QyaGgye&s^Mjr+sD^6e;GrGw&+(d8=-*iFSU*6ur_Ugw z2VkVnpYDak(Mp);Mu9{4v$fxmu((Mr>z>8jOr$%^j;-oQk%C zG;TFF9j5ly)BY?WyGh)6BVL8mj1PGJF!cnk;0U^{>d8BC{)MhF%G&j>($OYd@J`P@ zN!$pn;Q`PbBwX;ZQDl?xqNQ84U}JTX=FcL39;Ei1$JUKMvmD7p8zZ%fEMfd@%#45N zozItbvu;^?CNIv^@>n!PKF}I-(Xj996VHpaeJ2EO)h6R$qXRv8f{*g5aiza-$c6q6 zG$Wan##x)s@p;LNgdA&L8s}SEjuS~{bY0DBi|u{XxFE}Ed2#F=Ic-8iM(NixzUG`u zyVdw>t-^CI(vwDs$37%Zuy$}b`ff$S#96O;c&595B*D)VQKG>+?AqooU7XOhZf0-7CZgZ;n^aT~>2 zcX{S4Wr2Fn1=47Xj* z# zW+pVhm5i)4d_AXVbscBUTd*hU8MZ9eF8{I5vEF`61(R)HW|o;nz!)I0R`bYIBgY8h z4RD8#SFOElK5yCzw!o+C!=I-8Mg7yP*JJe+`<(eVO?+@AI@k=Zd7wUC#|5=@%{{wG z_nC!f$1bN=%`ki%m{W(P z?j`H}vFAESo{jl__(@nxNEyx`b4-pxKO{aQ^)c}wlF7W%f$o7W*g-$`*Y9B@nwH47ZS-8hF$g4Q z3w`H)IM*Rdxa&IIjSXu4JD&B=@q^dyp1aYJo(>eP7*t;+Izktt13mqyzCm%$F)Pfb z;y=2Q@1_se(*{<@TSYNW(myq*WX?Jx8A}Hptx6`O|nAS3vtlBj=k^GG-~CcMXrMuc?_M~jdmzG z+$FSd@-W27Cfi(68gFbpNbj%H%wZP5|6Af4X0*$6q>05oEOu#D^*~qPh2CmDooS|S z>E40l2J`+_-*N4>K7W%w5A@k}#orTG-s*~}^!#VK2HVs$sK-A{9?&D#L&!R`IsJNC z39_g8^jcEHC5UOdMXH=6$^9|mH=hs+lqoUylf)b*n;}<*z-L$wcpTUg-2bJxgZM9c zh1gbY6TnGujeQG8JoVTf$SYw^;hWG`W=P#vyj-_mtgo)iA7gxzY|cT-33%*rpgD1; z^USJqN#xHTW|q$uIEkn!+8xTcZG%MxEnFMX(l@&9cj^tZ0PBod`n2=FXtnpCTd*G% z5u7aGFO&O^8*qPl_5wcjVbY=aY`L?&M7%@ILuUN26L_(E&1%4l&K=+0YQRQ``5jQ6 znYEwdTu?sGZs=*>g!a2Lo6Yrl9_`DMYv6_3Vpdy;cn>wd@b0^zw{~Trf;Z;bFOwzM zk{+46m7VLEZDtD5BqC6C90spz-rsXi4}Z_BR_s+`Jy+5)wbe`&#wKk)ttE>MuqSFd z?~uhLS~PF(&-dJN%NNP+zA4nJBW{4NV|ka}67b0B&mVytCYsO7=C*4i5W{O-kEYQ9>bZ8`iH-g2|}F}&sG@uPVA zOn%c$evI-Z=kjBSOU>rT&~`PS--KEvW%Oe>kBiNX#PE{UKTaNx$56_t1B@ajYDA2c z8t*>39gA+qX-~Ab%nG?EX1vb%n11_b7owC;Y9?q;?RH%iQ&Ma{%5PYIW#(Svh>9DT|QU;<~&N<3)N69MamS#FiK6;5&LGe+xI7~!NKX2waiugELAF(>s5)qSa zr*fxXmucS3Q`+}rbExd7*YCC8zTXpKs}$>sOxcsxV3b);lkY*7DN_z+@q1r$TuyY+ zR|BwOoP9qwtM9N~3%Y^US6r6I&5^Mk&T8+vg4VK75l&tfG4A3X4G}IgomgfDDuQ=^ zmhFtL>V3R_!Bg$g5W#A?u(t8KvPVOlRwY$*L{J_#;Qr^e2P0^S=iACXk59JU8pM~z zt1HjLiH+}mvi7@!*H_&1wL0;2&oNH3O1SRWNvw7*wN588!t4V<_V+-4ON*66@#1mS zyR5WZHlhbw`|yM`u4sML&=`>_I3>Gbu2#~qZrji)J3{2G5^kv`Ms{@KQHck<@?<} z-n+i-lw*GPl(RQ16_-jnX6XU(TsBC#kcWjZDmn3oAp>a^;y%+ z>Mo!>^@)2AYjT?F)SmX6(*96%CvxRnpGW%==lS6*&*6yM?dwyWh(s^3-Z^4#QiEX{ zEE1bsW}mlZkJ=bD_mfuioNsBXC1aQmQ*^A~qxPwNMXYwKq;}cdc5Sq$nu=1_Nyg@u zt-g+}lI*4P;vTj5YOLz+l62megs^y0zmolF>o%bLqKDje>5NxoYd(C2wcA$!uf@%} zQS(Oqveh(3e1-CS-+iHlpJUp$JiG_ejzpG9T(iYnOn5UaZ@0YLaQ)I^o~0aFm5;UD zy=n8YHi>6E@0^WbwK_@9KIHK5GVh>^ao~Kam%JZsKH5_EqRmIz)qQBAl&b6hUjAEU z^D+8*2am1g$kx*INXc)M%^%sFSsbXuwLAG}iOVnUruUK#VP82$#Q0RX}W4x<4P2�-68Y@5W~mdTK||SX}VivjPB*;ej<2x5}$QLCbOy`p7-fx^5T1A?LB~9lF0|XOm4h`Z$w5t zu2|3W$4ax4S(999C2M4K4`;Kgyj*+)t-pEN03o*DN(Z9mia5 zo;r@X+0MyK*)` zZF@@e?^$iKLaQ15i5Hf+gJToi?3gwGCchjnby=LWoE=P>QO!I*ud4$WtTF2MP_4M;Sur`fW_59aF>aqH!IscU_^=%h{*dBeQ zqUId|W+9cAwTScjQ1z(xu7F5w^)e0B#dRyq=uc*FU*qso?aJ7XK#sO#2B0mw<`I(X zfb&YMRBbZf4pY5QiPcd}P#?{zq}-2f>ixH+ay3pb=fA zUMDgCZi5`__vNEQ^MWqoDZYGkR(aN5be65{_aKUExlJSh@6d@y43}A_UG#uAp|q{7 zVBge8bo}MZBVtJA=eSkgE^SipX46i6!u~bLg1XkDUE{}&HSNY{?+cBMUoXjRUzklK zIWbGCr8{b=EC-?4Uy*l{_PmtGOGLn8zs;6-pCtWc~jlXk3ZL^t?F+&np7A1=Dm*psOxyQJ(x)?Bb*B5T6bJaIbEi7~x_VoEudBH4KhF14pC+UjiJ^ig(-q!a`YUVc^6=F76Gxew}Qu9Q8 zwLYE**H}j<3rI&oeggoA44f0r(oM zKQO;0hnHTyZ$TE^;$5D*Xd6DOn|B^3ZM979eCrRf*Lv=J{VKZnN2n zOS7BGlVo`~z3$s}QaKVW59cuPZHZMPxj5n5KKIomu9`=2e5`oo$n#n~c}Pva)zOX| zS%G!%IKKa-;>|xO)_kdGEYbAS6tSNw0!`$dxVwFOt2>`=JiEHDR~|1Il}g&nD-WOh zYUNq_c{uB8o`}s~lJqM1y;Qkl>U(h?%;aXSq#j5jUnxU2p55QYm`*;;$-0!e<~$dx zt+F3BfrGoItiS&{5OS^V!v+dtC{=|O=}`WW}JwxjLR8&3@DqwR6LUCAPl5{GX8m{Lz!;+}1J z^VBP24_dEfki6(qGNtY2UXT)rW{={0q^s;ntFHYZ+mM*yR;uZ}maRBa{P4W`LZ2?z z+KhO%R-OAH#TLB+mi8W35K{~MR5m6a)#8eZWj5wK1+XZ)OD%ubo3B6Vsl_Vy<@G02 zfamj8NjCgY>kq8LChEiXaj$RXX|u9o(>yse#~!OQ0eMwT$gZC1`Bb+$_AW#liM`pn zsmQD@Nq+ghU|3H1Hd9$h4^q~={dGjhMXQO7c;?HbV_RzK^7IVVvv|WEohz*tI}a^# zojN2DDAHvz zqnny$UpfYj-3?8{o~-?9(WY;U&6}xY-WK1`(OyjhR^)H{Eq$^Vars-4^#D>^H5>71 Z%0jw7v%@3nXUjs$Or^Rdf|M-8{{arThN}Po diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart index 95ec18b1..01b902c5 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/language_selection_page.dart @@ -9,19 +9,23 @@ import 'package:flutter_modular/flutter_modular.dart'; /// Displays available languages and allows the user to select their preferred /// language. Changes are applied immediately via [LocaleBloc] and persisted. /// Shows a snackbar when the language is successfully changed. -class LanguageSelectionPage extends StatefulWidget { +class LanguageSelectionPage extends StatelessWidget { /// Creates a [LanguageSelectionPage]. const LanguageSelectionPage({super.key}); - @override - State createState() => _LanguageSelectionPageState(); -} + String _getLocalizedLanguageName(AppLocale locale) { + switch (locale) { + case AppLocale.en: + return 'English'; + case AppLocale.es: + return 'Español'; + } + } -class _LanguageSelectionPageState extends State { - void _showLanguageChangedSnackbar(String languageName) { + void _showLanguageChangedSnackbar(BuildContext context, String languageName) { UiSnackbar.show( context, - message: 'Language changed to $languageName', + message: '${t.settings.change_language}: $languageName', type: UiSnackbarType.success, ); @@ -33,7 +37,7 @@ class _LanguageSelectionPageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: UiAppBar( - title: 'Select Language', + title: t.settings.change_language, showBackButton: true, bottom: PreferredSize( preferredSize: const Size.fromHeight(1.0), @@ -46,17 +50,9 @@ class _LanguageSelectionPageState extends State { return ListView( padding: const EdgeInsets.all(UiConstants.space5), children: [ - _buildLanguageOption( - context, - label: 'English', - locale: AppLocale.en, - ), + _buildLanguageOption(context, locale: AppLocale.en), const SizedBox(height: UiConstants.space4), - _buildLanguageOption( - context, - label: 'Español', - locale: AppLocale.es, - ), + _buildLanguageOption(context, locale: AppLocale.es), ], ); }, @@ -67,9 +63,9 @@ class _LanguageSelectionPageState extends State { Widget _buildLanguageOption( BuildContext context, { - required String label, required AppLocale locale, }) { + final String label = _getLocalizedLanguageName(locale); // Check if this option is currently selected. final AppLocale currentLocale = LocaleSettings.currentLocale; final bool isSelected = currentLocale == locale; @@ -79,7 +75,7 @@ class _LanguageSelectionPageState extends State { // Only proceed if selecting a different language if (currentLocale != locale) { Modular.get().add(ChangeLocale(locale.flutterLocale)); - _showLanguageChangedSnackbar(label); + _showLanguageChangedSnackbar(context, label); } }, borderRadius: BorderRadius.circular(UiConstants.radiusMdValue), From 889bf90e71e9c2d008936af8afb4a76ad8b3a164 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Feb 2026 16:14:43 -0500 Subject: [PATCH 17/18] feat: Implement reorder functionality in ClientCreateOrderRepository and update related interfaces and use cases --- .../features/client/client_main/pubspec.yaml | 17 +- .../client_create_order_repository_impl.dart | 6 + ...ent_create_order_repository_interface.dart | 6 + .../src/domain/usecases/reorder_usecase.dart | 2 +- .../widgets/dashboard_widget_builder.dart | 2 +- .../pages/coverage_report_page.dart | 464 ------------------ .../src/presentation/pages/reports_page.dart | 27 +- .../reports/lib/src/reports_module.dart | 8 +- .../client_settings_page/settings_logout.dart | 3 +- .../constants/staff_main_routes.dart | 16 - 10 files changed, 37 insertions(+), 514 deletions(-) delete mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart delete mode 100644 apps/mobile/packages/features/staff/staff_main/lib/src/presentation/constants/staff_main_routes.dart diff --git a/apps/mobile/packages/features/client/client_main/pubspec.yaml b/apps/mobile/packages/features/client/client_main/pubspec.yaml index 4420cdcd..139eaca1 100644 --- a/apps/mobile/packages/features/client/client_main/pubspec.yaml +++ b/apps/mobile/packages/features/client/client_main/pubspec.yaml @@ -5,17 +5,14 @@ publish_to: none resolution: workspace environment: - sdk: '>=3.10.0 <4.0.0' + sdk: ">=3.10.0 <4.0.0" flutter: ">=3.0.0" dependencies: flutter: sdk: flutter - flutter_bloc: ^8.1.0 - flutter_modular: ^6.3.0 - equatable: ^2.0.5 - - # Architecture Packages + + # Architecture Packages design_system: path: ../../../design_system core_localization: @@ -30,10 +27,12 @@ dependencies: path: ../view_orders billing: path: ../billing + krow_core: + path: ../../../core - # Intentionally commenting these out as they might not exist yet - # client_settings: - # path: ../settings + flutter_bloc: ^8.1.0 + flutter_modular: ^6.3.0 + equatable: ^2.0.5 dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart b/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart index fff9a19c..cd6c15fb 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart @@ -390,6 +390,12 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte throw UnimplementedError('Rapid order IA is not connected yet.'); } + @override + Future reorder(String previousOrderId, DateTime newDate) async { + // TODO: Implement reorder functionality to fetch the previous order and create a new one with the updated date. + throw UnimplementedError('Reorder functionality is not yet implemented.'); + } + double _calculateShiftCost(domain.OneTimeOrder order) { double total = 0; for (final domain.OneTimeOrderPosition position in order.positions) { diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart index 0fe29f6b..d7eed014 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart @@ -27,4 +27,10 @@ abstract interface class ClientCreateOrderRepositoryInterface { /// /// [description] is the text message (or transcribed voice) describing the need. Future createRapidOrder(String description); + + /// Reorders an existing staffing order with a new date. + /// + /// [previousOrderId] is the ID of the order to reorder. + /// [newDate] is the new date for the order. + Future reorder(String previousOrderId, DateTime newDate); } diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/reorder_usecase.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/reorder_usecase.dart index 296816cf..ddd90f2c 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/reorder_usecase.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/reorder_usecase.dart @@ -13,7 +13,7 @@ class ReorderArguments { } /// Use case for reordering an existing staffing order. -class ReorderUseCase implements UseCase, ReorderArguments> { +class ReorderUseCase implements UseCase { const ReorderUseCase(this._repository); final ClientCreateOrderRepositoryInterface _repository; diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/dashboard_widget_builder.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/dashboard_widget_builder.dart index 488a9bb3..0964f2ee 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/dashboard_widget_builder.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/dashboard_widget_builder.dart @@ -85,7 +85,7 @@ class DashboardWidgetBuilder extends StatelessWidget { return; } Modular.to.navigate( - '/client-main/orders/', + ClientPaths.orders, arguments: { 'initialDate': initialDate.toIso8601String(), }, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart deleted file mode 100644 index 24a0bef4..00000000 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart +++ /dev/null @@ -1,464 +0,0 @@ -import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart'; -import 'package:client_reports/src/presentation/blocs/coverage/coverage_event.dart'; -import 'package:client_reports/src/presentation/blocs/coverage/coverage_state.dart'; -import 'package:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_modular/flutter_modular.dart'; -import 'package:intl/intl.dart'; - -class CoverageReportPage extends StatefulWidget { - const CoverageReportPage({super.key}); - - @override - State createState() => _CoverageReportPageState(); -} - -class _CoverageReportPageState extends State { - DateTime _startDate = DateTime.now(); - DateTime _endDate = DateTime.now().add(const Duration(days: 6)); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => Modular.get() - ..add(LoadCoverageReport(startDate: _startDate, endDate: _endDate)), - child: Scaffold( - backgroundColor: UiColors.bgMenu, - body: BlocBuilder( - builder: (context, state) { - if (state is CoverageLoading) { - return const Center(child: CircularProgressIndicator()); - } - - if (state is CoverageError) { - return Center(child: Text(state.message)); - } - - if (state is CoverageLoaded) { - final report = state.report; - - // Compute "Full" and "Needs Help" counts from daily coverage - final fullDays = report.dailyCoverage - .where((d) => d.percentage >= 100) - .length; - final needsHelpDays = report.dailyCoverage - .where((d) => d.percentage < 80) - .length; - - return SingleChildScrollView( - child: Column( - children: [ - // ── Header ─────────────────────────────────────────── - Container( - padding: const EdgeInsets.only( - top: 60, - left: 20, - right: 20, - bottom: 80, // Increased bottom padding for overlap background - ), - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [ - UiColors.primary, - UiColors.buttonPrimaryHover, - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: Column( - children: [ - // Title row - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - GestureDetector( - onTap: () => Navigator.of(context).pop(), - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: UiColors.white.withOpacity(0.2), - shape: BoxShape.circle, - ), - child: const Icon( - UiIcons.arrowLeft, - color: UiColors.white, - size: 20, - ), - ), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - context.t.client_reports.coverage_report - .title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: UiColors.white, - ), - ), - Text( - context.t.client_reports.coverage_report - .subtitle, - style: TextStyle( - fontSize: 12, - color: - UiColors.white.withOpacity(0.7), - ), - ), - ], - ), - ], - ), - // Export button -/* - GestureDetector( - onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.t.client_reports.coverage_report - .placeholders.export_message, - ), - duration: const Duration(seconds: 2), - ), - ); - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(8), - ), - child: const Row( - children: [ - Icon(UiIcons.download, - size: 14, color: UiColors.primary), - SizedBox(width: 6), - Text( - 'Export', - style: TextStyle( - color: UiColors.primary, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), -*/ - ], - ), - ], - ), - ), - - // ── 3 summary stat chips (Moved here for overlap) ── - Transform.translate( - offset: const Offset(0, -60), // Pull up to overlap header - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - children: [ - _CoverageStatCard( - icon: UiIcons.trendingUp, - label: context.t.client_reports.coverage_report.metrics.avg_coverage, - value: '${report.overallCoverage.toStringAsFixed(0)}%', - iconColor: UiColors.primary, - ), - const SizedBox(width: 12), - _CoverageStatCard( - icon: UiIcons.checkCircle, - label: context.t.client_reports.coverage_report.metrics.full, - value: fullDays.toString(), - iconColor: UiColors.success, - ), - const SizedBox(width: 12), - _CoverageStatCard( - icon: UiIcons.warning, - label: context.t.client_reports.coverage_report.metrics.needs_help, - value: needsHelpDays.toString(), - iconColor: UiColors.error, - ), - ], - ), - ), - ), - - // ── Content ────────────────────────────────────────── - Transform.translate( - offset: const Offset(0, -60), // Pull up to overlap header - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 32), - - // Section label - Text( - context.t.client_reports.coverage_report.next_7_days, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - letterSpacing: 0.5, - ), - ), - const SizedBox(height: 16), - - if (report.dailyCoverage.isEmpty) - Container( - padding: const EdgeInsets.all(40), - alignment: Alignment.center, - child: Text( - context.t.client_reports.coverage_report.empty_state, - style: const TextStyle( - color: UiColors.textSecondary, - ), - ), - ) - else - ...report.dailyCoverage.map( - (day) => _DayCoverageCard( - date: DateFormat('EEE, MMM d').format(day.date), - filled: day.filled, - needed: day.needed, - percentage: day.percentage, - ), - ), - - const SizedBox(height: 100), - ], - ), - ), - ), - ], - ), - ); - } - return const SizedBox.shrink(); - }, - ), - ), - ); - } -} - -// ── Header stat chip (inside the blue header) ───────────────────────────────── -// ── Header stat card (boxes inside the blue header overlap) ─────────────────── -class _CoverageStatCard extends StatelessWidget { - final IconData icon; - final String label; - final String value; - final Color iconColor; - - const _CoverageStatCard({ - required this.icon, - required this.label, - required this.value, - required this.iconColor, - }); - - @override - Widget build(BuildContext context) { - return Expanded( - child: Container( - padding: const EdgeInsets.all(16), // Increased padding - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(16), // More rounded - boxShadow: [ - BoxShadow( - color: UiColors.black.withOpacity(0.04), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon( - icon, - size: 14, - color: iconColor, - ), - const SizedBox(width: 6), - Expanded( - child: Text( - label, - style: const TextStyle( - fontSize: 11, - color: UiColors.textSecondary, - fontWeight: FontWeight.w500, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - value, - style: const TextStyle( - fontSize: 20, // Slightly smaller to fit if needed - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), - ), - ], - ), - ), - ); - } -} - -// ── Day coverage card ───────────────────────────────────────────────────────── -class _DayCoverageCard extends StatelessWidget { - final String date; - final int filled; - final int needed; - final double percentage; - - const _DayCoverageCard({ - required this.date, - required this.filled, - required this.needed, - required this.percentage, - }); - - @override - Widget build(BuildContext context) { - final isFullyStaffed = percentage >= 100; - final spotsRemaining = (needed - filled).clamp(0, needed); - - final barColor = percentage >= 95 - ? UiColors.success - : percentage >= 80 - ? UiColors.primary - : UiColors.error; - - final badgeColor = percentage >= 95 - ? UiColors.success - : percentage >= 80 - ? UiColors.primary - : UiColors.error; - - final badgeBg = percentage >= 95 - ? UiColors.tagSuccess - : percentage >= 80 - ? UiColors.primary.withOpacity(0.1) // Blue tint - : UiColors.tagError; - - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: UiColors.black.withOpacity(0.03), - blurRadius: 6, - ), - ], - ), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - date, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - color: UiColors.textPrimary, - ), - ), - const SizedBox(height: 2), - Text( - context.t.client_reports.coverage_report.shift_item.confirmed_workers(confirmed: filled.toString(), needed: needed.toString()), - style: const TextStyle( - fontSize: 12, - color: UiColors.textSecondary, - ), - ), - ], - ), - // Percentage badge - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 5, - ), - decoration: BoxDecoration( - color: badgeBg, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - '${percentage.toStringAsFixed(0)}%', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: badgeColor, - ), - ), - ), - ], - ), - const SizedBox(height: 12), - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: (percentage / 100).clamp(0.0, 1.0), - backgroundColor: UiColors.bgSecondary, - valueColor: AlwaysStoppedAnimation(barColor), - minHeight: 6, - ), - ), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerRight, - child: Text( - isFullyStaffed - ? context.t.client_reports.coverage_report.shift_item.fully_staffed - : spotsRemaining == 1 - ? context.t.client_reports.coverage_report.shift_item.one_spot_remaining - : context.t.client_reports.coverage_report.shift_item.spots_remaining(count: spotsRemaining.toString()), - style: TextStyle( - fontSize: 11, - color: isFullyStaffed - ? UiColors.success - : UiColors.textSecondary, - fontWeight: isFullyStaffed - ? FontWeight.w500 - : FontWeight.normal, - ), - ), - ), - ], - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart index 6c3f538e..fbc60def 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:intl/intl.dart'; +import 'package:krow_core/core.dart'; class ReportsPage extends StatefulWidget { const ReportsPage({super.key}); @@ -36,8 +37,8 @@ class _ReportsPageState extends State DateTime.now(), ), ( - DateTime(DateTime.now().year, ((DateTime.now().month - 1) ~/ 3) * 3 + 1, - 1), + DateTime( + DateTime.now().year, ((DateTime.now().month - 1) ~/ 3) * 3 + 1, 1), DateTime.now(), ), ]; @@ -102,8 +103,7 @@ class _ReportsPageState extends State Row( children: [ GestureDetector( - onTap: () => - Modular.to.navigate('/client-main/home'), + onTap: () => Modular.to.toClientHome(), child: Container( width: 40, height: 40, @@ -209,8 +209,8 @@ class _ReportsPageState extends State } final summary = (state as ReportsSummaryLoaded).summary; - final currencyFmt = - NumberFormat.currency(symbol: '\$', decimalDigits: 0); + final currencyFmt = NumberFormat.currency( + symbol: '\$', decimalDigits: 0); return GridView.count( crossAxisCount: 2, @@ -261,8 +261,7 @@ class _ReportsPageState extends State icon: UiIcons.trendingUp, label: context .t.client_reports.metrics.fill_rate.label, - value: - '${summary.fillRate.toStringAsFixed(0)}%', + value: '${summary.fillRate.toStringAsFixed(0)}%', badgeText: context .t.client_reports.metrics.fill_rate.badge, badgeColor: UiColors.tagInProgress, @@ -271,12 +270,12 @@ class _ReportsPageState extends State ), _MetricCard( icon: UiIcons.clock, - label: context.t.client_reports.metrics - .avg_fill_time.label, + label: context + .t.client_reports.metrics.avg_fill_time.label, value: '${summary.avgFillTimeHours.toStringAsFixed(1)} hrs', - badgeText: context.t.client_reports.metrics - .avg_fill_time.badge, + badgeText: context + .t.client_reports.metrics.avg_fill_time.badge, badgeColor: UiColors.tagInProgress, badgeTextColor: UiColors.textLink, iconColor: UiColors.iconActive, @@ -474,8 +473,7 @@ class _MetricCard extends StatelessWidget { ), const SizedBox(height: 4), Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: badgeColor, borderRadius: BorderRadius.circular(10), @@ -580,4 +578,3 @@ class _ReportCard extends StatelessWidget { ); } } - diff --git a/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart b/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart index 959ad51f..d1dc3387 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart @@ -1,13 +1,11 @@ import 'package:client_reports/src/data/repositories_impl/reports_repository_impl.dart'; import 'package:client_reports/src/domain/repositories/reports_repository.dart'; import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_bloc.dart'; -import 'package:client_reports/src/presentation/blocs/spend/spend_bloc.dart'; -import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart'; import 'package:client_reports/src/presentation/blocs/forecast/forecast_bloc.dart'; -import 'package:client_reports/src/presentation/blocs/performance/performance_bloc.dart'; import 'package:client_reports/src/presentation/blocs/no_show/no_show_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/performance/performance_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/spend/spend_bloc.dart'; import 'package:client_reports/src/presentation/blocs/summary/reports_summary_bloc.dart'; -import 'package:client_reports/src/presentation/pages/coverage_report_page.dart'; import 'package:client_reports/src/presentation/pages/daily_ops_report_page.dart'; import 'package:client_reports/src/presentation/pages/forecast_report_page.dart'; import 'package:client_reports/src/presentation/pages/no_show_report_page.dart'; @@ -26,7 +24,6 @@ class ReportsModule extends Module { i.addLazySingleton(ReportsRepositoryImpl.new); i.add(DailyOpsBloc.new); i.add(SpendBloc.new); - i.add(CoverageBloc.new); i.add(ForecastBloc.new); i.add(PerformanceBloc.new); i.add(NoShowBloc.new); @@ -41,6 +38,5 @@ class ReportsModule extends Module { r.child('/forecast', child: (_) => const ForecastReportPage()); r.child('/performance', child: (_) => const PerformanceReportPage()); r.child('/no-show', child: (_) => const NoShowReportPage()); - r.child('/coverage', child: (_) => const CoverageReportPage()); } } diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart index ea359254..1efc5139 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart @@ -3,7 +3,6 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; import '../../blocs/client_settings_bloc.dart'; /// A widget that displays the log out button. @@ -59,7 +58,7 @@ class SettingsLogout extends StatelessWidget { style: UiTypography.headline3m.textPrimary, ), content: Text( - t.client_settings.profile.log_out_confirmation, + 'Are you sure you want to log out?', style: UiTypography.body2r.textSecondary, ), actions: [ diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/constants/staff_main_routes.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/constants/staff_main_routes.dart deleted file mode 100644 index db753d22..00000000 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/constants/staff_main_routes.dart +++ /dev/null @@ -1,16 +0,0 @@ -abstract class StaffMainRoutes { - static const String modulePath = '/worker-main'; - - static const String shifts = '/shifts'; - static const String payments = '/payments'; - static const String home = '/home'; - static const String clockIn = '/clock-in'; - static const String profile = '/profile'; - - // Full paths - static const String shiftsFull = '$modulePath$shifts'; - static const String paymentsFull = '$modulePath$payments'; - static const String homeFull = '$modulePath$home'; - static const String clockInFull = '$modulePath$clockIn'; - static const String profileFull = '$modulePath$profile'; -} From e650af87c062f8df563709396b9635052a0ec299 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 19 Feb 2026 16:28:06 -0500 Subject: [PATCH 18/18] feat: Add mobile CI workflow for change detection, compilation, and linting --- .github/workflows/mobile-ci.yml | 237 ++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 .github/workflows/mobile-ci.yml diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml new file mode 100644 index 00000000..5f18d948 --- /dev/null +++ b/.github/workflows/mobile-ci.yml @@ -0,0 +1,237 @@ +name: Mobile CI + +on: + pull_request: + paths: + - 'apps/mobile/**' + - '.github/workflows/mobile-ci.yml' + push: + branches: + - main + paths: + - 'apps/mobile/**' + - '.github/workflows/mobile-ci.yml' + +jobs: + detect-changes: + name: 🔍 Detect Mobile Changes + runs-on: ubuntu-latest + outputs: + mobile-changed: ${{ steps.detect.outputs.mobile-changed }} + changed-files: ${{ steps.detect.outputs.changed-files }} + steps: + - name: 📥 Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 🔎 Detect changes in apps/mobile + id: detect + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + # For PR, compare with base branch + BASE_REF="${{ github.event.pull_request.base.ref }}" + HEAD_REF="${{ github.event.pull_request.head.ref }}" + CHANGED_FILES=$(git diff --name-only origin/$BASE_REF..origin/$HEAD_REF 2>/dev/null || echo "") + else + # For push, compare with previous commit + if [[ "${{ github.event.before }}" == "0000000000000000000000000000000000000000" ]]; then + # Initial commit, check all files + CHANGED_FILES=$(git ls-tree -r --name-only HEAD) + else + CHANGED_FILES=$(git diff --name-only ${{ github.event.before }} ${{ github.event.after }}) + fi + fi + + # Filter for files in apps/mobile + MOBILE_CHANGED=$(echo "$CHANGED_FILES" | grep -c "^apps/mobile/" || echo "0") + + if [[ $MOBILE_CHANGED -gt 0 ]]; then + echo "mobile-changed=true" >> $GITHUB_OUTPUT + # Get list of changed Dart files in apps/mobile + MOBILE_FILES=$(echo "$CHANGED_FILES" | grep "^apps/mobile/" | grep "\.dart$" || echo "") + echo "changed-files<> $GITHUB_OUTPUT + echo "$MOBILE_FILES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "✅ Changes detected in apps/mobile/" + echo "📝 Changed files:" + echo "$MOBILE_FILES" + else + echo "mobile-changed=false" >> $GITHUB_OUTPUT + echo "changed-files=" >> $GITHUB_OUTPUT + echo "⏭️ No changes detected in apps/mobile/ - skipping checks" + fi + + compile: + name: 🏗️ Compile Mobile App + runs-on: macos-latest + needs: detect-changes + if: needs.detect-changes.outputs.mobile-changed == 'true' + steps: + - name: 📥 Checkout repository + uses: actions/checkout@v4 + + - name: 🦋 Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.19.x' + channel: 'stable' + cache: true + + - name: 📦 Get Flutter dependencies + run: | + cd apps/mobile + flutter pub get + + - name: 🔨 Run compilation check + run: | + cd apps/mobile + echo "⚙️ Running build_runner..." + flutter pub run build_runner build --delete-conflicting-outputs 2>&1 || true + + echo "" + echo "🔬 Running flutter analyze on all files..." + flutter analyze lib/ --no-fatal-infos 2>&1 | tee analyze_output.txt || true + + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # Check for actual errors (not just warnings) + if grep -E "^\s*(error|SEVERE):" analyze_output.txt > /dev/null; then + echo "❌ COMPILATION ERRORS FOUND:" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + grep -B 2 -A 1 -E "^\s*(error|SEVERE):" analyze_output.txt | sed 's/^/ /' + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + exit 1 + else + echo "✅ Compilation check PASSED - No errors found" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + fi + + lint: + name: 🧹 Lint Changed Files + runs-on: macos-latest + needs: detect-changes + if: needs.detect-changes.outputs.mobile-changed == 'true' && needs.detect-changes.outputs.changed-files != '' + steps: + - name: 📥 Checkout repository + uses: actions/checkout@v4 + + - name: 🦋 Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.19.x' + channel: 'stable' + cache: true + + - name: 📦 Get Flutter dependencies + run: | + cd apps/mobile + flutter pub get + + - name: 🔍 Lint changed Dart files + run: | + cd apps/mobile + + # Get the list of changed files + CHANGED_FILES="${{ needs.detect-changes.outputs.changed-files }}" + + if [[ -z "$CHANGED_FILES" ]]; then + echo "⏭️ No Dart files changed, skipping lint" + exit 0 + fi + + echo "🎯 Running lint on changed files:" + echo "$CHANGED_FILES" + echo "" + + # Run dart analyze on each changed file + HAS_ERRORS=false + FAILED_FILES=() + + while IFS= read -r file; do + if [[ -n "$file" && "$file" == *.dart ]]; then + echo "📝 Analyzing: $file" + + if ! flutter analyze "$file" --no-fatal-infos 2>&1 | tee -a lint_output.txt; then + HAS_ERRORS=true + FAILED_FILES+=("$file") + fi + echo "" + fi + done <<< "$CHANGED_FILES" + + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # Check if there were any errors + if [[ "$HAS_ERRORS" == "true" ]]; then + echo "❌ LINT ERRORS FOUND IN ${#FAILED_FILES[@]} FILE(S):" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + for file in "${FAILED_FILES[@]}"; do + echo " ❌ $file" + done + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "See details above for each file" + exit 1 + else + echo "✅ Lint check PASSED for all changed files" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + fi + + status-check: + name: 📊 CI Status Check + runs-on: ubuntu-latest + needs: [detect-changes, compile, lint] + if: always() + steps: + - name: 🔍 Check mobile changes detected + run: | + if [[ "${{ needs.detect-changes.outputs.mobile-changed }}" == "true" ]]; then + echo "✅ Mobile changes detected - running full checks" + else + echo "⏭️ No mobile changes detected - skipping checks" + fi + + - name: 🏗️ Report compilation status + if: needs.detect-changes.outputs.mobile-changed == 'true' + run: | + if [[ "${{ needs.compile.result }}" == "success" ]]; then + echo "✅ Compilation check: PASSED" + else + echo "❌ Compilation check: FAILED" + exit 1 + fi + + - name: 🧹 Report lint status + if: needs.detect-changes.outputs.mobile-changed == 'true' && needs.detect-changes.outputs.changed-files != '' + run: | + if [[ "${{ needs.lint.result }}" == "success" ]]; then + echo "✅ Lint check: PASSED" + else + echo "❌ Lint check: FAILED" + exit 1 + fi + + - name: 🎉 Final status + if: always() + run: | + echo "" + echo "╔════════════════════════════════════╗" + echo "║ 📊 Mobile CI Pipeline Summary ║" + echo "╚════════════════════════════════════╝" + echo "" + echo "🔍 Change Detection: ${{ needs.detect-changes.result }}" + echo "🏗️ Compilation: ${{ needs.compile.result }}" + echo "🧹 Lint Check: ${{ needs.lint.result }}" + echo "" + + if [[ "${{ needs.detect-changes.result }}" != "success" || \ + ("${{ needs.detect-changes.outputs.mobile-changed }}" == "true" && \ + ("${{ needs.compile.result }}" != "success" || "${{ needs.lint.result }}" != "success")) ]]; then + echo "❌ Pipeline FAILED" + exit 1 + else + echo "✅ Pipeline PASSED" + fi +