feat: Add shimmer loading skeletons for various pages and components

- Implemented `ClientHomePageSkeleton` for the client home page to display a loading state with shimmer effects.
- Created `OrderFormSkeleton` to mimic the layout of the order creation form while data is being fetched.
- Added `ViewOrdersPageSkeleton` to represent the loading state of the view orders page with placeholders for order cards.
- Updated `ClientHomeBody` to show the skeleton during loading states.
- Enhanced shimmer effects in `UiShimmerListItem`, `UiShimmerStatsCard`, and other UI components for consistency.
- Introduced `isDataLoaded` state in order-related BLoCs to manage loading states effectively.
This commit is contained in:
Achintha Isuru
2026-03-10 14:19:49 -04:00
parent e6ebae60e4
commit 2d6133aba8
22 changed files with 828 additions and 48 deletions

View File

@@ -223,7 +223,9 @@ A PR is approved ONLY when ALL of these are true:
- Zero CRITICAL violations - Zero CRITICAL violations
- Zero HIGH violations - Zero HIGH violations
- MODERATE violations have a documented plan or justification - 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 - Test coverage ≥ 70% for business logic
- Design system fully compliant - Design system fully compliant
- Architecture boundaries fully respected - Architecture boundaries fully respected

View File

@@ -325,6 +325,8 @@
"client_create_order": { "client_create_order": {
"title": "Create Order", "title": "Create Order",
"section_title": "ORDER TYPE", "section_title": "ORDER TYPE",
"no_vendors_title": "No Vendors Available",
"no_vendors_description": "There are no staffing vendors associated with your account.",
"types": { "types": {
"rapid": "RAPID", "rapid": "RAPID",
"rapid_desc": "URGENT same-day Coverage", "rapid_desc": "URGENT same-day Coverage",

View File

@@ -325,6 +325,8 @@
"client_create_order": { "client_create_order": {
"title": "Crear Orden", "title": "Crear Orden",
"section_title": "TIPO DE 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": { "types": {
"rapid": "R\u00c1PIDO", "rapid": "R\u00c1PIDO",
"rapid_desc": "Cobertura URGENTE mismo d\u00eda", "rapid_desc": "Cobertura URGENTE mismo d\u00eda",

View File

@@ -12,26 +12,25 @@ class UiShimmerListItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return const Padding(
padding: const EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
vertical: UiConstants.space2, vertical: UiConstants.space2,
), ),
child: Row( child: Row(
children: [ spacing: UiConstants.space3,
const UiShimmerCircle(size: UiConstants.space10), children: <Widget>[
const SizedBox(width: UiConstants.space3), UiShimmerCircle(size: UiConstants.space10),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ spacing: UiConstants.space2,
const UiShimmerLine(width: 160), children: <Widget>[
const SizedBox(height: UiConstants.space2), UiShimmerLine(width: 160),
const UiShimmerLine(width: 100, height: 12), UiShimmerLine(width: 100, height: 12),
], ],
), ),
), ),
const SizedBox(width: UiConstants.space3), UiShimmerBox(width: 48, height: 24),
const UiShimmerBox(width: 48, height: 24),
], ],
), ),
); );
@@ -56,14 +55,14 @@ class UiShimmerStatsCard extends StatelessWidget {
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusLg,
color: UiColors.cardViewBackground, color: UiColors.cardViewBackground,
), ),
child: Column( child: const Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: <Widget>[
const UiShimmerCircle(size: UiConstants.space8), UiShimmerCircle(size: UiConstants.space8),
const SizedBox(height: UiConstants.space3), SizedBox(height: UiConstants.space3),
const UiShimmerLine(width: 80, height: 12), UiShimmerLine(width: 80, height: 12),
const SizedBox(height: UiConstants.space2), SizedBox(height: UiConstants.space2),
const UiShimmerLine(width: 120, height: 20), UiShimmerLine(width: 120, height: 20),
], ],
), ),
); );
@@ -110,9 +109,9 @@ class UiShimmerList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final gap = spacing ?? UiConstants.space3; final double gap = spacing ?? UiConstants.space3;
return Column( return Column(
children: List.generate(itemCount, (index) { children: List<Widget>.generate(itemCount, (int index) {
return Padding( return Padding(
padding: EdgeInsets.only(bottom: index < itemCount - 1 ? gap : 0), padding: EdgeInsets.only(bottom: index < itemCount - 1 ? gap : 0),
child: itemBuilder(index), child: itemBuilder(index),

View File

@@ -8,10 +8,12 @@ import '../blocs/client_home_state.dart';
import 'client_home_edit_mode_body.dart'; import 'client_home_edit_mode_body.dart';
import 'client_home_error_state.dart'; import 'client_home_error_state.dart';
import 'client_home_normal_mode_body.dart'; import 'client_home_normal_mode_body.dart';
import 'client_home_page_skeleton.dart';
/// Main body widget for the client home page. /// 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 { class ClientHomeBody extends StatelessWidget {
/// Creates a [ClientHomeBody]. /// Creates a [ClientHomeBody].
const ClientHomeBody({super.key}); const ClientHomeBody({super.key});
@@ -31,6 +33,11 @@ class ClientHomeBody extends StatelessWidget {
} }
}, },
builder: (BuildContext context, ClientHomeState state) { 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) { if (state.status == ClientHomeStatus.error) {
return ClientHomeErrorState(state: state); return ClientHomeErrorState(state: state);
} }

View File

@@ -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 <Widget>[
// 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: <Widget>[
const UiShimmerSectionHeader(),
const SizedBox(height: UiConstants.space2),
Row(
children: <Widget>[
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: <Widget>[
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: <Widget>[
const UiShimmerSectionHeader(),
const SizedBox(height: UiConstants.space2),
SizedBox(
height: 164,
child: Row(
children: <Widget>[
_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: <Widget>[
const Row(
children: <Widget>[
UiShimmerBox(width: 36, height: 36),
SizedBox(width: UiConstants.space2),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
UiShimmerLine(width: 100, height: 14),
SizedBox(height: UiConstants.space1),
UiShimmerLine(width: 80, height: 10),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
UiShimmerLine(width: 40, height: 14),
SizedBox(height: UiConstants.space1),
UiShimmerLine(width: 60, height: 10),
],
),
],
),
const SizedBox(height: UiConstants.space3),
const Row(
children: <Widget>[
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: <Widget>[
UiShimmerSectionHeader(),
SizedBox(height: UiConstants.space2),
Row(
children: <Widget>[
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: <Widget>[
Row(
children: <Widget>[
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: <Widget>[
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: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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: <Widget>[
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: <Widget>[
UiShimmerSectionHeader(),
SizedBox(height: UiConstants.space2),
UiShimmerStatsCard(),
SizedBox(height: UiConstants.space3),
UiShimmerListItem(),
UiShimmerListItem(),
],
);
}
}

View File

@@ -149,7 +149,11 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
? event.vendors.first ? event.vendors.first
: null; : null;
emit( emit(
state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor), state.copyWith(
vendors: event.vendors,
selectedVendor: selectedVendor,
isDataLoaded: true,
),
); );
if (selectedVendor != null) { if (selectedVendor != null) {
await _loadRolesForVendor(selectedVendor.id, emit); await _loadRolesForVendor(selectedVendor.id, emit);

View File

@@ -21,6 +21,7 @@ class OneTimeOrderState extends Equatable {
this.managers = const <OneTimeOrderManagerOption>[], this.managers = const <OneTimeOrderManagerOption>[],
this.selectedManager, this.selectedManager,
this.isRapidDraft = false, this.isRapidDraft = false,
this.isDataLoaded = false,
}); });
factory OneTimeOrderState.initial() { factory OneTimeOrderState.initial() {
@@ -52,6 +53,9 @@ class OneTimeOrderState extends Equatable {
final OneTimeOrderManagerOption? selectedManager; final OneTimeOrderManagerOption? selectedManager;
final bool isRapidDraft; final bool isRapidDraft;
/// Whether initial data (vendors, hubs) has been fetched from the backend.
final bool isDataLoaded;
OneTimeOrderState copyWith({ OneTimeOrderState copyWith({
DateTime? date, DateTime? date,
String? location, String? location,
@@ -67,6 +71,7 @@ class OneTimeOrderState extends Equatable {
List<OneTimeOrderManagerOption>? managers, List<OneTimeOrderManagerOption>? managers,
OneTimeOrderManagerOption? selectedManager, OneTimeOrderManagerOption? selectedManager,
bool? isRapidDraft, bool? isRapidDraft,
bool? isDataLoaded,
}) { }) {
return OneTimeOrderState( return OneTimeOrderState(
date: date ?? this.date, date: date ?? this.date,
@@ -83,6 +88,7 @@ class OneTimeOrderState extends Equatable {
managers: managers ?? this.managers, managers: managers ?? this.managers,
selectedManager: selectedManager ?? this.selectedManager, selectedManager: selectedManager ?? this.selectedManager,
isRapidDraft: isRapidDraft ?? this.isRapidDraft, isRapidDraft: isRapidDraft ?? this.isRapidDraft,
isDataLoaded: isDataLoaded ?? this.isDataLoaded,
); );
} }
@@ -187,6 +193,7 @@ class OneTimeOrderState extends Equatable {
managers, managers,
selectedManager, selectedManager,
isRapidDraft, isRapidDraft,
isDataLoaded,
]; ];
} }

View File

@@ -136,7 +136,11 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
? event.vendors.first ? event.vendors.first
: null; : null;
emit( emit(
state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor), state.copyWith(
vendors: event.vendors,
selectedVendor: selectedVendor,
isDataLoaded: true,
),
); );
if (selectedVendor != null) { if (selectedVendor != null) {
await _loadRolesForVendor(selectedVendor.id, emit); await _loadRolesForVendor(selectedVendor.id, emit);

View File

@@ -21,6 +21,7 @@ class PermanentOrderState extends Equatable {
this.roles = const <PermanentOrderRoleOption>[], this.roles = const <PermanentOrderRoleOption>[],
this.managers = const <PermanentOrderManagerOption>[], this.managers = const <PermanentOrderManagerOption>[],
this.selectedManager, this.selectedManager,
this.isDataLoaded = false,
}); });
factory PermanentOrderState.initial() { factory PermanentOrderState.initial() {
@@ -68,6 +69,9 @@ class PermanentOrderState extends Equatable {
final List<PermanentOrderManagerOption> managers; final List<PermanentOrderManagerOption> managers;
final PermanentOrderManagerOption? selectedManager; final PermanentOrderManagerOption? selectedManager;
/// Whether initial data (vendors, hubs) has been fetched from the backend.
final bool isDataLoaded;
PermanentOrderState copyWith({ PermanentOrderState copyWith({
DateTime? startDate, DateTime? startDate,
List<String>? permanentDays, List<String>? permanentDays,
@@ -84,6 +88,7 @@ class PermanentOrderState extends Equatable {
List<PermanentOrderRoleOption>? roles, List<PermanentOrderRoleOption>? roles,
List<PermanentOrderManagerOption>? managers, List<PermanentOrderManagerOption>? managers,
PermanentOrderManagerOption? selectedManager, PermanentOrderManagerOption? selectedManager,
bool? isDataLoaded,
}) { }) {
return PermanentOrderState( return PermanentOrderState(
startDate: startDate ?? this.startDate, startDate: startDate ?? this.startDate,
@@ -101,6 +106,7 @@ class PermanentOrderState extends Equatable {
roles: roles ?? this.roles, roles: roles ?? this.roles,
managers: managers ?? this.managers, managers: managers ?? this.managers,
selectedManager: selectedManager ?? this.selectedManager, selectedManager: selectedManager ?? this.selectedManager,
isDataLoaded: isDataLoaded ?? this.isDataLoaded,
); );
} }
@@ -186,6 +192,7 @@ class PermanentOrderState extends Equatable {
roles, roles,
managers, managers,
selectedManager, selectedManager,
isDataLoaded,
]; ];
} }

View File

@@ -149,7 +149,11 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
? event.vendors.first ? event.vendors.first
: null; : null;
emit( emit(
state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor), state.copyWith(
vendors: event.vendors,
selectedVendor: selectedVendor,
isDataLoaded: true,
),
); );
if (selectedVendor != null) { if (selectedVendor != null) {
await _loadRolesForVendor(selectedVendor.id, emit); await _loadRolesForVendor(selectedVendor.id, emit);

View File

@@ -23,6 +23,7 @@ class RecurringOrderState extends Equatable {
this.roles = const <RecurringOrderRoleOption>[], this.roles = const <RecurringOrderRoleOption>[],
this.managers = const <RecurringOrderManagerOption>[], this.managers = const <RecurringOrderManagerOption>[],
this.selectedManager, this.selectedManager,
this.isDataLoaded = false,
}); });
factory RecurringOrderState.initial() { factory RecurringOrderState.initial() {
@@ -72,6 +73,9 @@ class RecurringOrderState extends Equatable {
final List<RecurringOrderManagerOption> managers; final List<RecurringOrderManagerOption> managers;
final RecurringOrderManagerOption? selectedManager; final RecurringOrderManagerOption? selectedManager;
/// Whether initial data (vendors, hubs) has been fetched from the backend.
final bool isDataLoaded;
RecurringOrderState copyWith({ RecurringOrderState copyWith({
DateTime? startDate, DateTime? startDate,
DateTime? endDate, DateTime? endDate,
@@ -89,6 +93,7 @@ class RecurringOrderState extends Equatable {
List<RecurringOrderRoleOption>? roles, List<RecurringOrderRoleOption>? roles,
List<RecurringOrderManagerOption>? managers, List<RecurringOrderManagerOption>? managers,
RecurringOrderManagerOption? selectedManager, RecurringOrderManagerOption? selectedManager,
bool? isDataLoaded,
}) { }) {
return RecurringOrderState( return RecurringOrderState(
startDate: startDate ?? this.startDate, startDate: startDate ?? this.startDate,
@@ -107,6 +112,7 @@ class RecurringOrderState extends Equatable {
roles: roles ?? this.roles, roles: roles ?? this.roles,
managers: managers ?? this.managers, managers: managers ?? this.managers,
selectedManager: selectedManager ?? this.selectedManager, selectedManager: selectedManager ?? this.selectedManager,
isDataLoaded: isDataLoaded ?? this.isDataLoaded,
); );
} }
@@ -214,6 +220,7 @@ class RecurringOrderState extends Equatable {
roles, roles,
managers, managers,
selectedManager, selectedManager,
isDataLoaded,
]; ];
} }

View File

@@ -44,6 +44,7 @@ class OneTimeOrderPage extends StatelessWidget {
); );
return OneTimeOrderView( return OneTimeOrderView(
isDataLoaded: state.isDataLoaded,
status: _mapStatus(state.status), status: _mapStatus(state.status),
errorMessage: state.errorMessage, errorMessage: state.errorMessage,
eventName: state.eventName, eventName: state.eventName,

View File

@@ -44,6 +44,7 @@ class PermanentOrderPage extends StatelessWidget {
); );
return PermanentOrderView( return PermanentOrderView(
isDataLoaded: state.isDataLoaded,
status: _mapStatus(state.status), status: _mapStatus(state.status),
errorMessage: state.errorMessage, errorMessage: state.errorMessage,
eventName: state.eventName, eventName: state.eventName,

View File

@@ -43,6 +43,7 @@ class RecurringOrderPage extends StatelessWidget {
); );
return RecurringOrderView( return RecurringOrderView(
isDataLoaded: state.isDataLoaded,
status: _mapStatus(state.status), status: _mapStatus(state.status),
errorMessage: state.errorMessage, errorMessage: state.errorMessage,
eventName: state.eventName, eventName: state.eventName,

View File

@@ -3,6 +3,7 @@ export 'src/presentation/widgets/order_ui_models.dart';
// Shared Widgets // Shared Widgets
export 'src/presentation/widgets/order_bottom_action_button.dart'; export 'src/presentation/widgets/order_bottom_action_button.dart';
export 'src/presentation/widgets/order_form_skeleton.dart';
// One Time Order Widgets // One Time Order Widgets
export 'src/presentation/widgets/one_time_order/one_time_order_date_picker.dart'; export 'src/presentation/widgets/one_time_order/one_time_order_date_picker.dart';

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../order_bottom_action_button.dart'; import '../order_bottom_action_button.dart';
import '../order_form_skeleton.dart';
import '../order_ui_models.dart'; import '../order_ui_models.dart';
import 'one_time_order_form.dart'; import 'one_time_order_form.dart';
import 'one_time_order_success_view.dart'; import 'one_time_order_success_view.dart';
@@ -37,6 +38,7 @@ class OneTimeOrderView extends StatelessWidget {
required this.onBack, required this.onBack,
this.title, this.title,
this.subtitle, this.subtitle,
this.isDataLoaded = true,
super.key, super.key,
}); });
@@ -56,6 +58,9 @@ class OneTimeOrderView extends StatelessWidget {
final String? title; final String? title;
final String? subtitle; final String? subtitle;
/// Whether initial data (vendors, hubs) has been fetched from the backend.
final bool isDataLoaded;
final ValueChanged<String> onEventNameChanged; final ValueChanged<String> onEventNameChanged;
final ValueChanged<Vendor> onVendorChanged; final ValueChanged<Vendor> onVendorChanged;
final ValueChanged<DateTime> onDateChanged; final ValueChanged<DateTime> onDateChanged;
@@ -81,7 +86,12 @@ class OneTimeOrderView extends StatelessWidget {
context, context,
message: translateErrorKey(errorMessage!), message: translateErrorKey(errorMessage!),
type: UiSnackbarType.error, 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, BuildContext context,
TranslationsClientCreateOrderOneTimeEn labels, TranslationsClientCreateOrderOneTimeEn labels,
) { ) {
if (!isDataLoaded) {
return const OrderFormSkeleton();
}
if (vendors.isEmpty && status != OrderFormStatus.loading) { if (vendors.isEmpty && status != OrderFormStatus.loading) {
return Column( return Column(
children: <Widget>[ children: <Widget>[
@@ -126,12 +140,12 @@ class OneTimeOrderView extends StatelessWidget {
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
Text( Text(
'No Vendors Available', t.client_create_order.no_vendors_title,
style: UiTypography.headline3m.textPrimary, style: UiTypography.headline3m.textPrimary,
), ),
const SizedBox(height: UiConstants.space2), const SizedBox(height: UiConstants.space2),
Text( Text(
'There are no staffing vendors associated with your account.', t.client_create_order.no_vendors_description,
style: UiTypography.body2r.textSecondary, style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),

View File

@@ -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: <Widget>[
_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: <Widget>[
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: <Widget>[
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: <Widget>[
const Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
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: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const <Widget>[
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 <Widget>[
UiShimmerLine(width: 50, height: 12),
SizedBox(height: UiConstants.space2),
UiShimmerBox(width: double.infinity, height: 44),
],
),
),
],
),
],
),
);
}
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart' show Vendor; import 'package:krow_domain/krow_domain.dart' show Vendor;
import '../order_bottom_action_button.dart'; import '../order_bottom_action_button.dart';
import '../order_form_skeleton.dart';
import '../order_ui_models.dart'; import '../order_ui_models.dart';
import 'permanent_order_form.dart'; import 'permanent_order_form.dart';
import 'permanent_order_success_view.dart'; import 'permanent_order_success_view.dart';
@@ -37,9 +38,12 @@ class PermanentOrderView extends StatelessWidget {
required this.onSubmit, required this.onSubmit,
required this.onDone, required this.onDone,
required this.onBack, required this.onBack,
this.isDataLoaded = true,
super.key, super.key,
}); });
/// Whether initial data (vendors, hubs) has been fetched from the backend.
final bool isDataLoaded;
final OrderFormStatus status; final OrderFormStatus status;
final String? errorMessage; final String? errorMessage;
final String eventName; final String eventName;
@@ -82,7 +86,12 @@ class PermanentOrderView extends StatelessWidget {
context, context,
message: translateErrorKey(errorMessage!), message: translateErrorKey(errorMessage!),
type: UiSnackbarType.error, 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, TranslationsClientCreateOrderPermanentEn labels,
TranslationsClientCreateOrderOneTimeEn oneTimeLabels, TranslationsClientCreateOrderOneTimeEn oneTimeLabels,
) { ) {
if (!isDataLoaded) {
return const OrderFormSkeleton();
}
if (vendors.isEmpty && status != OrderFormStatus.loading) { if (vendors.isEmpty && status != OrderFormStatus.loading) {
return Column( return Column(
children: <Widget>[ children: <Widget>[
@@ -128,12 +141,12 @@ class PermanentOrderView extends StatelessWidget {
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
Text( Text(
'No Vendors Available', t.client_create_order.no_vendors_title,
style: UiTypography.headline3m.textPrimary, style: UiTypography.headline3m.textPrimary,
), ),
const SizedBox(height: UiConstants.space2), const SizedBox(height: UiConstants.space2),
Text( Text(
'There are no staffing vendors associated with your account.', t.client_create_order.no_vendors_description,
style: UiTypography.body2r.textSecondary, style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart' show Vendor; import 'package:krow_domain/krow_domain.dart' show Vendor;
import '../order_bottom_action_button.dart'; import '../order_bottom_action_button.dart';
import '../order_form_skeleton.dart';
import '../order_ui_models.dart'; import '../order_ui_models.dart';
import 'recurring_order_form.dart'; import 'recurring_order_form.dart';
import 'recurring_order_success_view.dart'; import 'recurring_order_success_view.dart';
@@ -39,9 +40,12 @@ class RecurringOrderView extends StatelessWidget {
required this.onSubmit, required this.onSubmit,
required this.onDone, required this.onDone,
required this.onBack, required this.onBack,
this.isDataLoaded = true,
super.key, super.key,
}); });
/// Whether initial data (vendors, hubs) has been fetched from the backend.
final bool isDataLoaded;
final OrderFormStatus status; final OrderFormStatus status;
final String? errorMessage; final String? errorMessage;
final String eventName; final String eventName;
@@ -89,7 +93,12 @@ class RecurringOrderView extends StatelessWidget {
context, context,
message: message, message: message,
type: UiSnackbarType.error, 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, TranslationsClientCreateOrderRecurringEn labels,
TranslationsClientCreateOrderOneTimeEn oneTimeLabels, TranslationsClientCreateOrderOneTimeEn oneTimeLabels,
) { ) {
if (!isDataLoaded) {
return const OrderFormSkeleton();
}
if (vendors.isEmpty && status != OrderFormStatus.loading) { if (vendors.isEmpty && status != OrderFormStatus.loading) {
return Column( return Column(
children: <Widget>[ children: <Widget>[
@@ -135,12 +148,12 @@ class RecurringOrderView extends StatelessWidget {
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
Text( Text(
'No Vendors Available', t.client_create_order.no_vendors_title,
style: UiTypography.headline3m.textPrimary, style: UiTypography.headline3m.textPrimary,
), ),
const SizedBox(height: UiConstants.space2), const SizedBox(height: UiConstants.space2),
Text( Text(
'There are no staffing vendors associated with your account.', t.client_create_order.no_vendors_description,
style: UiTypography.body2r.textSecondary, style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),

View File

@@ -11,6 +11,7 @@ import '../widgets/view_orders_header.dart';
import '../widgets/view_orders_empty_state.dart'; import '../widgets/view_orders_empty_state.dart';
import '../widgets/view_orders_error_state.dart'; import '../widgets/view_orders_error_state.dart';
import '../widgets/view_orders_list.dart'; import '../widgets/view_orders_list.dart';
import '../widgets/view_orders_page_skeleton.dart';
/// The main page for viewing client orders. /// The main page for viewing client orders.
/// ///
@@ -101,20 +102,26 @@ class _ViewOrdersViewState extends State<ViewOrdersView> {
// Content List // Content List
Expanded( Expanded(
child: state.status == ViewOrdersStatus.failure child: switch (state.status) {
? ViewOrdersErrorState( ViewOrdersStatus.loading ||
errorMessage: state.errorMessage, ViewOrdersStatus.initial =>
selectedDate: state.selectedDate, const ViewOrdersPageSkeleton(),
onRetry: () => BlocProvider.of<ViewOrdersCubit>( ViewOrdersStatus.failure => ViewOrdersErrorState(
context, errorMessage: state.errorMessage,
).jumpToDate(state.selectedDate ?? DateTime.now()), selectedDate: state.selectedDate,
) onRetry: () => BlocProvider.of<ViewOrdersCubit>(
: filteredOrders.isEmpty context,
? ViewOrdersEmptyState(selectedDate: state.selectedDate) ).jumpToDate(state.selectedDate ?? DateTime.now()),
: ViewOrdersList( ),
orders: filteredOrders, ViewOrdersStatus.success => filteredOrders.isEmpty
filterTab: state.filterTab, ? ViewOrdersEmptyState(
), selectedDate: state.selectedDate,
)
: ViewOrdersList(
orders: filteredOrders,
filterTab: state.filterTab,
),
},
), ),
], ],
), ),

View File

@@ -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: <Widget>[
// Section header placeholder (dot + title + count)
const _SectionHeaderSkeleton(),
// Order card placeholders
...List<Widget>.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: <Widget>[
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: <Widget>[
// Status and type badges
Row(
children: <Widget>[
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: <Widget>[
UiShimmerCircle(size: 14),
SizedBox(width: UiConstants.space2),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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: <Widget>[
_StatItemSkeleton(),
_StatDividerSkeleton(),
_StatItemSkeleton(),
_StatDividerSkeleton(),
_StatItemSkeleton(),
],
),
),
const SizedBox(height: UiConstants.space5),
// Time boxes (clock in / clock out)
Row(
children: <Widget>[
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: <Widget>[
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: <Widget>[
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,
);
}
}