diff --git a/.claude/agents/architecture-reviewer.md b/.claude/agents/architecture-reviewer.md index 887a4f0b..ebbffb75 100644 --- a/.claude/agents/architecture-reviewer.md +++ b/.claude/agents/architecture-reviewer.md @@ -223,7 +223,9 @@ A PR is approved ONLY when ALL of these are true: - Zero CRITICAL violations - Zero HIGH violations - MODERATE violations have a documented plan or justification -- All automated checks pass (tests, linting) +- All automated checks pass + - defined tests + - defined lints including the dart analyzer with no warnings or errors - Test coverage ≥ 70% for business logic - Design system fully compliant - Architecture boundaries fully respected diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 9be43245..7178240d 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -325,6 +325,8 @@ "client_create_order": { "title": "Create Order", "section_title": "ORDER TYPE", + "no_vendors_title": "No Vendors Available", + "no_vendors_description": "There are no staffing vendors associated with your account.", "types": { "rapid": "RAPID", "rapid_desc": "URGENT same-day Coverage", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 9f99b499..5fce4a09 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -325,6 +325,8 @@ "client_create_order": { "title": "Crear Orden", "section_title": "TIPO DE ORDEN", + "no_vendors_title": "No Hay Proveedores Disponibles", + "no_vendors_description": "No hay proveedores de personal asociados con su cuenta.", "types": { "rapid": "R\u00c1PIDO", "rapid_desc": "Cobertura URGENTE mismo d\u00eda", diff --git a/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer_presets.dart b/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer_presets.dart index 867542b0..c8478cfc 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer_presets.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer_presets.dart @@ -12,26 +12,25 @@ class UiShimmerListItem extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric( + return const Padding( + padding: EdgeInsets.symmetric( vertical: UiConstants.space2, ), child: Row( - children: [ - const UiShimmerCircle(size: UiConstants.space10), - const SizedBox(width: UiConstants.space3), + spacing: UiConstants.space3, + children: [ + UiShimmerCircle(size: UiConstants.space10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const UiShimmerLine(width: 160), - const SizedBox(height: UiConstants.space2), - const UiShimmerLine(width: 100, height: 12), + spacing: UiConstants.space2, + children: [ + UiShimmerLine(width: 160), + UiShimmerLine(width: 100, height: 12), ], ), ), - const SizedBox(width: UiConstants.space3), - const UiShimmerBox(width: 48, height: 24), + UiShimmerBox(width: 48, height: 24), ], ), ); @@ -56,14 +55,14 @@ class UiShimmerStatsCard extends StatelessWidget { borderRadius: UiConstants.radiusLg, color: UiColors.cardViewBackground, ), - child: Column( + child: const Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const UiShimmerCircle(size: UiConstants.space8), - const SizedBox(height: UiConstants.space3), - const UiShimmerLine(width: 80, height: 12), - const SizedBox(height: UiConstants.space2), - const UiShimmerLine(width: 120, height: 20), + children: [ + UiShimmerCircle(size: UiConstants.space8), + SizedBox(height: UiConstants.space3), + UiShimmerLine(width: 80, height: 12), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 120, height: 20), ], ), ); @@ -110,9 +109,9 @@ class UiShimmerList extends StatelessWidget { @override Widget build(BuildContext context) { - final gap = spacing ?? UiConstants.space3; + final double gap = spacing ?? UiConstants.space3; return Column( - children: List.generate(itemCount, (index) { + children: List.generate(itemCount, (int index) { return Padding( padding: EdgeInsets.only(bottom: index < itemCount - 1 ? gap : 0), child: itemBuilder(index), diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_body.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_body.dart index 06e65c95..1da1bbdc 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_body.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_body.dart @@ -8,10 +8,12 @@ import '../blocs/client_home_state.dart'; import 'client_home_edit_mode_body.dart'; import 'client_home_error_state.dart'; import 'client_home_normal_mode_body.dart'; +import 'client_home_page_skeleton.dart'; /// Main body widget for the client home page. /// -/// Manages the state transitions between error, edit mode, and normal mode views. +/// Manages the state transitions between loading, error, edit mode, +/// and normal mode views. class ClientHomeBody extends StatelessWidget { /// Creates a [ClientHomeBody]. const ClientHomeBody({super.key}); @@ -31,6 +33,11 @@ class ClientHomeBody extends StatelessWidget { } }, builder: (BuildContext context, ClientHomeState state) { + return const ClientHomePageSkeleton(); + if (state.status == ClientHomeStatus.initial || + state.status == ClientHomeStatus.loading) { + return const ClientHomePageSkeleton(); + } if (state.status == ClientHomeStatus.error) { return ClientHomeErrorState(state: state); } diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton.dart new file mode 100644 index 00000000..806ca6da --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton.dart @@ -0,0 +1,329 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer loading skeleton for the client home page. +/// +/// Mimics the loaded dashboard layout with action cards, reorder cards, +/// coverage metrics, spending card, and live activity sections. +class ClientHomePageSkeleton extends StatelessWidget { + /// Creates a [ClientHomePageSkeleton]. + const ClientHomePageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: ListView( + children: const [ + // Actions section + Padding( + padding: EdgeInsets.symmetric(horizontal: UiConstants.space4), + child: _ActionsSectionSkeleton(), + ), + SizedBox(height: UiConstants.space8), + Divider(color: UiColors.border, height: 0.1), + SizedBox(height: UiConstants.space8), + + // Reorder section + Padding( + padding: EdgeInsets.symmetric(horizontal: UiConstants.space4), + child: _ReorderSectionSkeleton(), + ), + SizedBox(height: UiConstants.space8), + Divider(color: UiColors.border, height: 0.1), + SizedBox(height: UiConstants.space8), + + // Coverage section + Padding( + padding: EdgeInsets.symmetric(horizontal: UiConstants.space4), + child: _CoverageSectionSkeleton(), + ), + SizedBox(height: UiConstants.space8), + Divider(color: UiColors.border, height: 0.1), + SizedBox(height: UiConstants.space8), + + // Spending section + Padding( + padding: EdgeInsets.symmetric(horizontal: UiConstants.space4), + child: _SpendingSectionSkeleton(), + ), + SizedBox(height: UiConstants.space8), + Divider(color: UiColors.border, height: 0.1), + SizedBox(height: UiConstants.space8), + + // Live activity section + Padding( + padding: EdgeInsets.symmetric(horizontal: UiConstants.space4), + child: _LiveActivitySectionSkeleton(), + ), + SizedBox(height: UiConstants.space8), + ], + ), + ); + } +} + +/// Skeleton for the two side-by-side action cards. +class _ActionsSectionSkeleton extends StatelessWidget { + const _ActionsSectionSkeleton(); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space2), + Row( + children: [ + Expanded(child: _ActionCardSkeleton()), + const SizedBox(width: UiConstants.space4), + Expanded(child: _ActionCardSkeleton()), + ], + ), + ], + ); + } +} + +/// Skeleton for a single action card with icon, title, and subtitle. +class _ActionCardSkeleton extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border, width: 0.5), + borderRadius: UiConstants.radiusLg, + ), + child: const Column( + children: [ + UiShimmerBox(width: 36, height: 36), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 60, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 100, height: 10), + ], + ), + ); + } +} + +/// Skeleton for the horizontal reorder cards list. +class _ReorderSectionSkeleton extends StatelessWidget { + const _ReorderSectionSkeleton(); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space2), + SizedBox( + height: 164, + child: Row( + children: [ + _ReorderCardSkeleton(), + const SizedBox(width: UiConstants.space3), + _ReorderCardSkeleton(), + ], + ), + ), + ], + ); + } +} + +/// Skeleton for a single reorder card. +class _ReorderCardSkeleton extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + width: 260, + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border, width: 0.6), + borderRadius: UiConstants.radiusLg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + UiShimmerBox(width: 36, height: 36), + SizedBox(width: UiConstants.space2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 100, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 80, height: 10), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + UiShimmerLine(width: 40, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 60, height: 10), + ], + ), + ], + ), + const SizedBox(height: UiConstants.space3), + const Row( + children: [ + UiShimmerBox(width: 60, height: 22), + SizedBox(width: UiConstants.space2), + UiShimmerBox(width: 36, height: 22), + ], + ), + const Spacer(), + UiShimmerBox( + width: double.infinity, + height: 32, + borderRadius: UiConstants.radiusLg, + ), + ], + ), + ); + } +} + +/// Skeleton for the coverage metric cards row. +class _CoverageSectionSkeleton extends StatelessWidget { + const _CoverageSectionSkeleton(); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerSectionHeader(), + SizedBox(height: UiConstants.space2), + Row( + children: [ + Expanded(child: _MetricCardSkeleton()), + SizedBox(width: UiConstants.space2), + Expanded(child: _MetricCardSkeleton()), + SizedBox(width: UiConstants.space2), + Expanded(child: _MetricCardSkeleton()), + ], + ), + ], + ); + } +} + +/// Skeleton for a single coverage metric card. +class _MetricCardSkeleton extends StatelessWidget { + const _MetricCardSkeleton(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space2), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border, width: 0.5), + borderRadius: UiConstants.radiusLg, + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + UiShimmerCircle(size: 14), + SizedBox(width: UiConstants.space1), + UiShimmerLine(width: 40, height: 10), + ], + ), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 32, height: 20), + ], + ), + ); + } +} + +/// Skeleton for the spending gradient card. +class _SpendingSectionSkeleton extends StatelessWidget { + const _SpendingSectionSkeleton(); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerSectionHeader(), + SizedBox(height: UiConstants.space2), + _SpendingCardSkeleton(), + ], + ); + } +} + +/// Skeleton mimicking the spending card layout. +class _SpendingCardSkeleton extends StatelessWidget { + const _SpendingCardSkeleton(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + ), + child: const Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 60, height: 10), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 80, height: 22), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 50, height: 10), + ], + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + UiShimmerLine(width: 60, height: 10), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 70, height: 18), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 50, height: 10), + ], + ), + ), + ], + ), + ); + } +} + +/// Skeleton for the live activity section. +class _LiveActivitySectionSkeleton extends StatelessWidget { + const _LiveActivitySectionSkeleton(); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerSectionHeader(), + SizedBox(height: UiConstants.space2), + UiShimmerStatsCard(), + SizedBox(height: UiConstants.space3), + UiShimmerListItem(), + UiShimmerListItem(), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart index 8ebfb27c..1f4ceb17 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart @@ -149,7 +149,11 @@ class OneTimeOrderBloc extends Bloc ? event.vendors.first : null; emit( - state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor), + state.copyWith( + vendors: event.vendors, + selectedVendor: selectedVendor, + isDataLoaded: true, + ), ); if (selectedVendor != null) { await _loadRolesForVendor(selectedVendor.id, emit); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart index 3a504e25..b8e3201b 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart @@ -21,6 +21,7 @@ class OneTimeOrderState extends Equatable { this.managers = const [], this.selectedManager, this.isRapidDraft = false, + this.isDataLoaded = false, }); factory OneTimeOrderState.initial() { @@ -52,6 +53,9 @@ class OneTimeOrderState extends Equatable { final OneTimeOrderManagerOption? selectedManager; final bool isRapidDraft; + /// Whether initial data (vendors, hubs) has been fetched from the backend. + final bool isDataLoaded; + OneTimeOrderState copyWith({ DateTime? date, String? location, @@ -67,6 +71,7 @@ class OneTimeOrderState extends Equatable { List? managers, OneTimeOrderManagerOption? selectedManager, bool? isRapidDraft, + bool? isDataLoaded, }) { return OneTimeOrderState( date: date ?? this.date, @@ -83,6 +88,7 @@ class OneTimeOrderState extends Equatable { managers: managers ?? this.managers, selectedManager: selectedManager ?? this.selectedManager, isRapidDraft: isRapidDraft ?? this.isRapidDraft, + isDataLoaded: isDataLoaded ?? this.isDataLoaded, ); } @@ -187,6 +193,7 @@ class OneTimeOrderState extends Equatable { managers, selectedManager, isRapidDraft, + isDataLoaded, ]; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart index 1f43713a..928d248c 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart @@ -136,7 +136,11 @@ class PermanentOrderBloc extends Bloc ? event.vendors.first : null; emit( - state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor), + state.copyWith( + vendors: event.vendors, + selectedVendor: selectedVendor, + isDataLoaded: true, + ), ); if (selectedVendor != null) { await _loadRolesForVendor(selectedVendor.id, emit); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart index c024994b..0ffea2ff 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart @@ -21,6 +21,7 @@ class PermanentOrderState extends Equatable { this.roles = const [], this.managers = const [], this.selectedManager, + this.isDataLoaded = false, }); factory PermanentOrderState.initial() { @@ -68,6 +69,9 @@ class PermanentOrderState extends Equatable { final List managers; final PermanentOrderManagerOption? selectedManager; + /// Whether initial data (vendors, hubs) has been fetched from the backend. + final bool isDataLoaded; + PermanentOrderState copyWith({ DateTime? startDate, List? permanentDays, @@ -84,6 +88,7 @@ class PermanentOrderState extends Equatable { List? roles, List? managers, PermanentOrderManagerOption? selectedManager, + bool? isDataLoaded, }) { return PermanentOrderState( startDate: startDate ?? this.startDate, @@ -101,6 +106,7 @@ class PermanentOrderState extends Equatable { roles: roles ?? this.roles, managers: managers ?? this.managers, selectedManager: selectedManager ?? this.selectedManager, + isDataLoaded: isDataLoaded ?? this.isDataLoaded, ); } @@ -186,6 +192,7 @@ class PermanentOrderState extends Equatable { roles, managers, selectedManager, + isDataLoaded, ]; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart index 37e4f5cf..972db182 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart @@ -149,7 +149,11 @@ class RecurringOrderBloc extends Bloc ? event.vendors.first : null; emit( - state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor), + state.copyWith( + vendors: event.vendors, + selectedVendor: selectedVendor, + isDataLoaded: true, + ), ); if (selectedVendor != null) { await _loadRolesForVendor(selectedVendor.id, emit); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart index 522a9c35..fc9706b7 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart @@ -23,6 +23,7 @@ class RecurringOrderState extends Equatable { this.roles = const [], this.managers = const [], this.selectedManager, + this.isDataLoaded = false, }); factory RecurringOrderState.initial() { @@ -72,6 +73,9 @@ class RecurringOrderState extends Equatable { final List managers; final RecurringOrderManagerOption? selectedManager; + /// Whether initial data (vendors, hubs) has been fetched from the backend. + final bool isDataLoaded; + RecurringOrderState copyWith({ DateTime? startDate, DateTime? endDate, @@ -89,6 +93,7 @@ class RecurringOrderState extends Equatable { List? roles, List? managers, RecurringOrderManagerOption? selectedManager, + bool? isDataLoaded, }) { return RecurringOrderState( startDate: startDate ?? this.startDate, @@ -107,6 +112,7 @@ class RecurringOrderState extends Equatable { roles: roles ?? this.roles, managers: managers ?? this.managers, selectedManager: selectedManager ?? this.selectedManager, + isDataLoaded: isDataLoaded ?? this.isDataLoaded, ); } @@ -214,6 +220,7 @@ class RecurringOrderState extends Equatable { roles, managers, selectedManager, + isDataLoaded, ]; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart index 8e272bb9..e77caf39 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart @@ -44,6 +44,7 @@ class OneTimeOrderPage extends StatelessWidget { ); return OneTimeOrderView( + isDataLoaded: state.isDataLoaded, status: _mapStatus(state.status), errorMessage: state.errorMessage, eventName: state.eventName, diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart index 331c76b6..c018bfe9 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart @@ -44,6 +44,7 @@ class PermanentOrderPage extends StatelessWidget { ); return PermanentOrderView( + isDataLoaded: state.isDataLoaded, status: _mapStatus(state.status), errorMessage: state.errorMessage, eventName: state.eventName, diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart index c092b12e..0da250ed 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart @@ -43,6 +43,7 @@ class RecurringOrderPage extends StatelessWidget { ); return RecurringOrderView( + isDataLoaded: state.isDataLoaded, status: _mapStatus(state.status), errorMessage: state.errorMessage, eventName: state.eventName, diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart index cec30ce5..28fe45ee 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart @@ -3,6 +3,7 @@ export 'src/presentation/widgets/order_ui_models.dart'; // Shared Widgets export 'src/presentation/widgets/order_bottom_action_button.dart'; +export 'src/presentation/widgets/order_form_skeleton.dart'; // One Time Order Widgets export 'src/presentation/widgets/one_time_order/one_time_order_date_picker.dart'; diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart index 3f2050f5..3e66e2fa 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart'; import '../order_bottom_action_button.dart'; +import '../order_form_skeleton.dart'; import '../order_ui_models.dart'; import 'one_time_order_form.dart'; import 'one_time_order_success_view.dart'; @@ -37,6 +38,7 @@ class OneTimeOrderView extends StatelessWidget { required this.onBack, this.title, this.subtitle, + this.isDataLoaded = true, super.key, }); @@ -56,6 +58,9 @@ class OneTimeOrderView extends StatelessWidget { final String? title; final String? subtitle; + /// Whether initial data (vendors, hubs) has been fetched from the backend. + final bool isDataLoaded; + final ValueChanged onEventNameChanged; final ValueChanged onVendorChanged; final ValueChanged onDateChanged; @@ -81,7 +86,12 @@ class OneTimeOrderView extends StatelessWidget { context, message: translateErrorKey(errorMessage!), type: UiSnackbarType.error, - margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16), + // bottom: 140 clears the bottom navigation bar area + margin: const EdgeInsets.only( + bottom: 140, + left: UiConstants.space4, + right: UiConstants.space4, + ), ); }); } @@ -111,6 +121,10 @@ class OneTimeOrderView extends StatelessWidget { BuildContext context, TranslationsClientCreateOrderOneTimeEn labels, ) { + if (!isDataLoaded) { + return const OrderFormSkeleton(); + } + if (vendors.isEmpty && status != OrderFormStatus.loading) { return Column( children: [ @@ -126,12 +140,12 @@ class OneTimeOrderView extends StatelessWidget { ), const SizedBox(height: UiConstants.space4), Text( - 'No Vendors Available', + t.client_create_order.no_vendors_title, style: UiTypography.headline3m.textPrimary, ), const SizedBox(height: UiConstants.space2), Text( - 'There are no staffing vendors associated with your account.', + t.client_create_order.no_vendors_description, style: UiTypography.body2r.textSecondary, textAlign: TextAlign.center, ), diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_form_skeleton.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_form_skeleton.dart new file mode 100644 index 00000000..291fcf59 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_form_skeleton.dart @@ -0,0 +1,144 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer skeleton that mimics the order creation form layout. +/// +/// Displayed while initial data (vendors, hubs, roles) is being fetched. +/// Renders placeholder shapes for the text input, dropdowns, date picker, +/// hub manager section, and one position card. +class OrderFormSkeleton extends StatelessWidget { + /// Creates an [OrderFormSkeleton]. + const OrderFormSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: ListView( + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.all(UiConstants.space5), + children: [ + _buildLabelPlaceholder(), + const SizedBox(height: UiConstants.space2), + _buildTextFieldPlaceholder(), + const SizedBox(height: UiConstants.space4), + _buildLabelPlaceholder(), + const SizedBox(height: UiConstants.space2), + _buildDropdownPlaceholder(), + const SizedBox(height: UiConstants.space4), + _buildLabelPlaceholder(), + const SizedBox(height: UiConstants.space2), + _buildDropdownPlaceholder(), + const SizedBox(height: UiConstants.space4), + _buildLabelPlaceholder(), + const SizedBox(height: UiConstants.space2), + _buildDropdownPlaceholder(), + const SizedBox(height: UiConstants.space4), + _buildHubManagerPlaceholder(), + const SizedBox(height: UiConstants.space6), + _buildSectionHeaderPlaceholder(), + const SizedBox(height: UiConstants.space3), + _buildPositionCardPlaceholder(), + ], + ), + ); + } + + /// Small label placeholder above each field. + Widget _buildLabelPlaceholder() { + return const Align( + alignment: Alignment.centerLeft, + child: UiShimmerLine(width: 100, height: 12), + ); + } + + /// Full-width text input placeholder. + Widget _buildTextFieldPlaceholder() { + return const UiShimmerBox(width: double.infinity, height: 48); + } + + /// Full-width dropdown selector placeholder. + Widget _buildDropdownPlaceholder() { + return const UiShimmerBox(width: double.infinity, height: 48); + } + + /// Hub manager section with label and description lines. + Widget _buildHubManagerPlaceholder() { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 140, height: 12), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 220, height: 10), + SizedBox(height: UiConstants.space2), + UiShimmerBox(width: double.infinity, height: 48), + ], + ); + } + + /// Section header placeholder with title and action button. + Widget _buildSectionHeaderPlaceholder() { + return const Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + UiShimmerLine(width: 100, height: 16), + UiShimmerBox(width: 90, height: 28), + ], + ); + } + + /// Position card placeholder mimicking role, worker count, and time fields. + Widget _buildPositionCardPlaceholder() { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + UiShimmerLine(width: 80, height: 14), + UiShimmerCircle(size: 24), + ], + ), + const SizedBox(height: UiConstants.space3), + const UiShimmerBox(width: double.infinity, height: 44), + const SizedBox(height: UiConstants.space3), + const UiShimmerLine(width: 60, height: 12), + const SizedBox(height: UiConstants.space2), + const UiShimmerBox(width: double.infinity, height: 44), + const SizedBox(height: UiConstants.space3), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerLine(width: 50, height: 12), + SizedBox(height: UiConstants.space2), + UiShimmerBox(width: double.infinity, height: 44), + ], + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerLine(width: 50, height: 12), + SizedBox(height: UiConstants.space2), + UiShimmerBox(width: double.infinity, height: 44), + ], + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart index 8c1bbf80..5a253eb0 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart' show Vendor; import '../order_bottom_action_button.dart'; +import '../order_form_skeleton.dart'; import '../order_ui_models.dart'; import 'permanent_order_form.dart'; import 'permanent_order_success_view.dart'; @@ -37,9 +38,12 @@ class PermanentOrderView extends StatelessWidget { required this.onSubmit, required this.onDone, required this.onBack, + this.isDataLoaded = true, super.key, }); + /// Whether initial data (vendors, hubs) has been fetched from the backend. + final bool isDataLoaded; final OrderFormStatus status; final String? errorMessage; final String eventName; @@ -82,7 +86,12 @@ class PermanentOrderView extends StatelessWidget { context, message: translateErrorKey(errorMessage!), type: UiSnackbarType.error, - margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16), + // bottom: 140 clears the bottom navigation bar area + margin: const EdgeInsets.only( + bottom: 140, + left: UiConstants.space4, + right: UiConstants.space4, + ), ); }); } @@ -113,6 +122,10 @@ class PermanentOrderView extends StatelessWidget { TranslationsClientCreateOrderPermanentEn labels, TranslationsClientCreateOrderOneTimeEn oneTimeLabels, ) { + if (!isDataLoaded) { + return const OrderFormSkeleton(); + } + if (vendors.isEmpty && status != OrderFormStatus.loading) { return Column( children: [ @@ -128,12 +141,12 @@ class PermanentOrderView extends StatelessWidget { ), const SizedBox(height: UiConstants.space4), Text( - 'No Vendors Available', + t.client_create_order.no_vendors_title, style: UiTypography.headline3m.textPrimary, ), const SizedBox(height: UiConstants.space2), Text( - 'There are no staffing vendors associated with your account.', + t.client_create_order.no_vendors_description, style: UiTypography.body2r.textSecondary, textAlign: TextAlign.center, ), diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart index ffd3ad51..d5d2e469 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart' show Vendor; import '../order_bottom_action_button.dart'; +import '../order_form_skeleton.dart'; import '../order_ui_models.dart'; import 'recurring_order_form.dart'; import 'recurring_order_success_view.dart'; @@ -39,9 +40,12 @@ class RecurringOrderView extends StatelessWidget { required this.onSubmit, required this.onDone, required this.onBack, + this.isDataLoaded = true, super.key, }); + /// Whether initial data (vendors, hubs) has been fetched from the backend. + final bool isDataLoaded; final OrderFormStatus status; final String? errorMessage; final String eventName; @@ -89,7 +93,12 @@ class RecurringOrderView extends StatelessWidget { context, message: message, type: UiSnackbarType.error, - margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16), + // bottom: 140 clears the bottom navigation bar area + margin: const EdgeInsets.only( + bottom: 140, + left: UiConstants.space4, + right: UiConstants.space4, + ), ); }); } @@ -120,6 +129,10 @@ class RecurringOrderView extends StatelessWidget { TranslationsClientCreateOrderRecurringEn labels, TranslationsClientCreateOrderOneTimeEn oneTimeLabels, ) { + if (!isDataLoaded) { + return const OrderFormSkeleton(); + } + if (vendors.isEmpty && status != OrderFormStatus.loading) { return Column( children: [ @@ -135,12 +148,12 @@ class RecurringOrderView extends StatelessWidget { ), const SizedBox(height: UiConstants.space4), Text( - 'No Vendors Available', + t.client_create_order.no_vendors_title, style: UiTypography.headline3m.textPrimary, ), const SizedBox(height: UiConstants.space2), Text( - 'There are no staffing vendors associated with your account.', + t.client_create_order.no_vendors_description, style: UiTypography.body2r.textSecondary, textAlign: TextAlign.center, ), diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/pages/view_orders_page.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/pages/view_orders_page.dart index 6c0a8923..32e317e7 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/pages/view_orders_page.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/pages/view_orders_page.dart @@ -11,6 +11,7 @@ import '../widgets/view_orders_header.dart'; import '../widgets/view_orders_empty_state.dart'; import '../widgets/view_orders_error_state.dart'; import '../widgets/view_orders_list.dart'; +import '../widgets/view_orders_page_skeleton.dart'; /// The main page for viewing client orders. /// @@ -101,20 +102,26 @@ class _ViewOrdersViewState extends State { // Content List Expanded( - child: state.status == ViewOrdersStatus.failure - ? ViewOrdersErrorState( - errorMessage: state.errorMessage, - selectedDate: state.selectedDate, - onRetry: () => BlocProvider.of( - context, - ).jumpToDate(state.selectedDate ?? DateTime.now()), - ) - : filteredOrders.isEmpty - ? ViewOrdersEmptyState(selectedDate: state.selectedDate) - : ViewOrdersList( - orders: filteredOrders, - filterTab: state.filterTab, - ), + child: switch (state.status) { + ViewOrdersStatus.loading || + ViewOrdersStatus.initial => + const ViewOrdersPageSkeleton(), + ViewOrdersStatus.failure => ViewOrdersErrorState( + errorMessage: state.errorMessage, + selectedDate: state.selectedDate, + onRetry: () => BlocProvider.of( + context, + ).jumpToDate(state.selectedDate ?? DateTime.now()), + ), + ViewOrdersStatus.success => filteredOrders.isEmpty + ? ViewOrdersEmptyState( + selectedDate: state.selectedDate, + ) + : ViewOrdersList( + orders: filteredOrders, + filterTab: state.filterTab, + ), + }, ), ], ), diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton.dart new file mode 100644 index 00000000..3ae3ab64 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton.dart @@ -0,0 +1,211 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer loading skeleton for the View Orders page. +/// +/// Mimics the loaded layout: a section header followed by a list of order +/// card placeholders, each containing badge, title, location, stats, time +/// boxes, and a coverage progress bar. +class ViewOrdersPageSkeleton extends StatelessWidget { + /// Creates a [ViewOrdersPageSkeleton]. + const ViewOrdersPageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: ListView( + padding: const EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space4, + UiConstants.space5, + // Extra bottom padding for bottom navigation clearance. + UiConstants.space24, + ), + children: [ + // Section header placeholder (dot + title + count) + const _SectionHeaderSkeleton(), + // Order card placeholders + ...List.generate(3, (int index) { + return const Padding( + padding: EdgeInsets.only(bottom: UiConstants.space3), + child: _OrderCardSkeleton(), + ); + }), + ], + ), + ); + } +} + +/// Shimmer placeholder for the section header row. +class _SectionHeaderSkeleton extends StatelessWidget { + const _SectionHeaderSkeleton(); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.only(bottom: UiConstants.space3), + child: Row( + children: [ + UiShimmerCircle(size: 8), + SizedBox(width: UiConstants.space2), + UiShimmerLine(width: 100, height: 14), + SizedBox(width: UiConstants.space1), + UiShimmerLine(width: 24, height: 14), + ], + ), + ); + } +} + +/// Shimmer placeholder for a single order card. +class _OrderCardSkeleton extends StatelessWidget { + const _OrderCardSkeleton(); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + border: Border.all(color: UiColors.border, width: 0.5), + borderRadius: UiConstants.radiusLg, + ), + child: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Status and type badges + Row( + children: [ + UiShimmerBox( + width: 80, + height: 22, + borderRadius: UiConstants.radiusSm, + ), + const SizedBox(width: UiConstants.space2), + UiShimmerBox( + width: 72, + height: 22, + borderRadius: UiConstants.radiusSm, + ), + ], + ), + const SizedBox(height: UiConstants.space3), + + // Title line + const UiShimmerLine(width: 200, height: 18), + const SizedBox(height: UiConstants.space2), + + // Event name line + const UiShimmerLine(width: 160, height: 14), + const SizedBox(height: UiConstants.space4), + + // Location lines + const Row( + children: [ + UiShimmerCircle(size: 14), + SizedBox(width: UiConstants.space2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 180, height: 12), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 140, height: 10), + ], + ), + ), + ], + ), + + const SizedBox(height: UiConstants.space4), + const Divider(height: 1, color: UiColors.border), + const SizedBox(height: UiConstants.space4), + + // Stats row (cost / hours / workers) + const Padding( + padding: EdgeInsets.symmetric( + horizontal: UiConstants.space4, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _StatItemSkeleton(), + _StatDividerSkeleton(), + _StatItemSkeleton(), + _StatDividerSkeleton(), + _StatItemSkeleton(), + ], + ), + ), + + const SizedBox(height: UiConstants.space5), + + // Time boxes (clock in / clock out) + Row( + children: [ + Expanded(child: _timeBoxSkeleton()), + const SizedBox(width: UiConstants.space3), + Expanded(child: _timeBoxSkeleton()), + ], + ), + + const SizedBox(height: UiConstants.space4), + + // Coverage progress bar + const UiShimmerLine(height: 8), + ], + ), + ), + ); + } + + /// Builds a placeholder for a time display box (clock-in / clock-out). + Widget _timeBoxSkeleton() { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border, width: 0.5), + borderRadius: UiConstants.radiusLg, + ), + child: const Column( + children: [ + UiShimmerLine(width: 60, height: 10), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 80, height: 16), + ], + ), + ); + } +} + +/// Shimmer placeholder for a single stat item (icon + value + label). +class _StatItemSkeleton extends StatelessWidget { + const _StatItemSkeleton(); + + @override + Widget build(BuildContext context) { + return const Column( + spacing: UiConstants.space1, + children: [ + UiShimmerCircle(size: 14), + UiShimmerLine(width: 32, height: 16), + UiShimmerLine(width: 40, height: 10), + ], + ); + } +} + +/// Shimmer placeholder for the vertical stat divider. +class _StatDividerSkeleton extends StatelessWidget { + const _StatDividerSkeleton(); + + @override + Widget build(BuildContext context) { + return const UiShimmerBox( + width: 1, + height: 24, + borderRadius: BorderRadius.zero, + ); + } +}