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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: <Widget>[
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
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<Widget>.generate(itemCount, (int index) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: index < itemCount - 1 ? gap : 0),
|
||||
child: itemBuilder(index),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ class OneTimeOrderPage extends StatelessWidget {
|
||||
);
|
||||
|
||||
return OneTimeOrderView(
|
||||
isDataLoaded: state.isDataLoaded,
|
||||
status: _mapStatus(state.status),
|
||||
errorMessage: state.errorMessage,
|
||||
eventName: state.eventName,
|
||||
|
||||
@@ -44,6 +44,7 @@ class PermanentOrderPage extends StatelessWidget {
|
||||
);
|
||||
|
||||
return PermanentOrderView(
|
||||
isDataLoaded: state.isDataLoaded,
|
||||
status: _mapStatus(state.status),
|
||||
errorMessage: state.errorMessage,
|
||||
eventName: state.eventName,
|
||||
|
||||
@@ -43,6 +43,7 @@ class RecurringOrderPage extends StatelessWidget {
|
||||
);
|
||||
|
||||
return RecurringOrderView(
|
||||
isDataLoaded: state.isDataLoaded,
|
||||
status: _mapStatus(state.status),
|
||||
errorMessage: state.errorMessage,
|
||||
eventName: state.eventName,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user