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

@@ -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);
}

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
: 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);

View File

@@ -21,6 +21,7 @@ class OneTimeOrderState extends Equatable {
this.managers = const <OneTimeOrderManagerOption>[],
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<OneTimeOrderManagerOption>? 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,
];
}

View File

@@ -136,7 +136,11 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
? 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);

View File

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

View File

@@ -149,7 +149,11 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
? 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);

View File

@@ -23,6 +23,7 @@ class RecurringOrderState extends Equatable {
this.roles = const <RecurringOrderRoleOption>[],
this.managers = const <RecurringOrderManagerOption>[],
this.selectedManager,
this.isDataLoaded = false,
});
factory RecurringOrderState.initial() {
@@ -72,6 +73,9 @@ class RecurringOrderState extends Equatable {
final List<RecurringOrderManagerOption> 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<RecurringOrderRoleOption>? roles,
List<RecurringOrderManagerOption>? 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,
];
}

View File

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

View File

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

View File

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

View File

@@ -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';

View File

@@ -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<String> onEventNameChanged;
final ValueChanged<Vendor> onVendorChanged;
final ValueChanged<DateTime> 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: <Widget>[
@@ -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,
),

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 '../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: <Widget>[
@@ -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,
),

View File

@@ -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: <Widget>[
@@ -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,
),

View File

@@ -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<ViewOrdersView> {
// Content List
Expanded(
child: state.status == ViewOrdersStatus.failure
? ViewOrdersErrorState(
errorMessage: state.errorMessage,
selectedDate: state.selectedDate,
onRetry: () => BlocProvider.of<ViewOrdersCubit>(
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<ViewOrdersCubit>(
context,
).jumpToDate(state.selectedDate ?? DateTime.now()),
),
ViewOrdersStatus.success => filteredOrders.isEmpty
? 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,
);
}
}