feat: add shimmer loading skeletons for various pages and components

- Implemented UiShimmer as a core shimmer wrapper for animated gradient effects.
- Created shimmer presets for list items, stats cards, section headers, and more.
- Developed specific skeletons for billing, invoices, coverage, hubs, reports, payments, shifts, and home pages.
- Enhanced user experience by providing visual placeholders during data loading.
This commit is contained in:
Achintha Isuru
2026-03-10 13:21:30 -04:00
parent 3f112f5eb7
commit 0f0714c55b
36 changed files with 1594 additions and 36 deletions

View File

@@ -8,6 +8,7 @@ import 'package:krow_core/core.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_event.dart';
import '../blocs/billing_state.dart';
import '../widgets/billing_page_skeleton.dart';
import '../widgets/invoice_history_section.dart';
import '../widgets/pending_invoices_section.dart';
import '../widgets/spending_breakdown_card.dart';
@@ -179,10 +180,7 @@ class _BillingViewState extends State<BillingView> {
Widget _buildContent(BuildContext context, BillingState state) {
if (state.status == BillingStatus.loading) {
return const Padding(
padding: EdgeInsets.all(UiConstants.space10),
child: Center(child: CircularProgressIndicator()),
);
return const BillingPageSkeleton();
}
if (state.status == BillingStatus.failure) {

View File

@@ -7,6 +7,7 @@ import '../blocs/billing_bloc.dart';
import '../blocs/billing_event.dart';
import '../blocs/billing_state.dart';
import '../models/billing_invoice_model.dart';
import '../widgets/invoices_list_skeleton.dart';
class InvoiceReadyPage extends StatelessWidget {
const InvoiceReadyPage({super.key});
@@ -30,7 +31,7 @@ class InvoiceReadyView extends StatelessWidget {
body: BlocBuilder<BillingBloc, BillingState>(
builder: (BuildContext context, BillingState state) {
if (state.status == BillingStatus.loading) {
return const Center(child: CircularProgressIndicator());
return const InvoicesListSkeleton();
}
if (state.invoiceHistory.isEmpty) {

View File

@@ -7,6 +7,7 @@ import 'package:krow_core/core.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_state.dart';
import '../widgets/invoices_list_skeleton.dart';
import '../widgets/pending_invoices_section.dart';
class PendingInvoicesPage extends StatelessWidget {
@@ -31,7 +32,7 @@ class PendingInvoicesPage extends StatelessWidget {
Widget _buildBody(BuildContext context, BillingState state) {
if (state.status == BillingStatus.loading) {
return const Center(child: CircularProgressIndicator());
return const InvoicesListSkeleton();
}
if (state.pendingInvoices.isEmpty) {

View File

@@ -0,0 +1,135 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Shimmer loading skeleton for the billing page content area.
///
/// Mimics the loaded layout with a pending invoices section,
/// a spending breakdown card, and an invoice history list.
class BillingPageSkeleton extends StatelessWidget {
/// Creates a [BillingPageSkeleton].
const BillingPageSkeleton({super.key});
@override
Widget build(BuildContext context) {
return UiShimmer(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Pending invoices section header
const UiShimmerSectionHeader(),
const SizedBox(height: UiConstants.space3),
// Pending invoice cards
const _InvoiceCardSkeleton(),
const SizedBox(height: UiConstants.space4),
const _InvoiceCardSkeleton(),
const SizedBox(height: UiConstants.space6),
// Spending breakdown card
Container(
padding: const EdgeInsets.all(UiConstants.space5),
decoration: BoxDecoration(
border: Border.all(color: UiColors.border),
borderRadius: UiConstants.radiusLg,
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
UiShimmerLine(width: 160, height: 16),
SizedBox(height: UiConstants.space4),
// Breakdown rows
_BreakdownRowSkeleton(),
SizedBox(height: UiConstants.space3),
_BreakdownRowSkeleton(),
SizedBox(height: UiConstants.space3),
_BreakdownRowSkeleton(),
],
),
),
const SizedBox(height: UiConstants.space6),
// Invoice history section header
const UiShimmerSectionHeader(),
const SizedBox(height: UiConstants.space3),
const UiShimmerListItem(),
const UiShimmerListItem(),
const UiShimmerListItem(),
],
),
),
);
}
}
/// Shimmer placeholder for a single pending invoice card.
class _InvoiceCardSkeleton extends StatelessWidget {
const _InvoiceCardSkeleton();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
border: Border.all(color: UiColors.border),
borderRadius: UiConstants.radiusLg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
UiShimmerBox(
width: 72,
height: 24,
borderRadius: UiConstants.radiusFull,
),
const UiShimmerLine(width: 80, height: 12),
],
),
const SizedBox(height: UiConstants.space4),
const UiShimmerLine(width: 200, height: 16),
const SizedBox(height: UiConstants.space2),
const UiShimmerLine(width: 160, height: 12),
const SizedBox(height: UiConstants.space4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
UiShimmerLine(width: 80, height: 10),
SizedBox(height: UiConstants.space1),
UiShimmerLine(width: 100, height: 18),
],
),
UiShimmerBox(
width: 100,
height: 36,
borderRadius: UiConstants.radiusMd,
),
],
),
],
),
);
}
}
/// Shimmer placeholder for a spending breakdown row.
class _BreakdownRowSkeleton extends StatelessWidget {
const _BreakdownRowSkeleton();
@override
Widget build(BuildContext context) {
return const Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
UiShimmerLine(width: 100, height: 14),
UiShimmerLine(width: 60, height: 14),
],
);
}
}

View File

@@ -0,0 +1,75 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Shimmer loading skeleton for invoice list pages.
///
/// Used by both [PendingInvoicesPage] and [InvoiceReadyPage] to show
/// placeholder cards while data loads.
class InvoicesListSkeleton extends StatelessWidget {
/// Creates an [InvoicesListSkeleton].
const InvoicesListSkeleton({super.key});
@override
Widget build(BuildContext context) {
return UiShimmer(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
children: List.generate(4, (int index) {
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space4),
child: Container(
padding: const EdgeInsets.all(UiConstants.space5),
decoration: BoxDecoration(
border: Border.all(color: UiColors.border),
borderRadius: UiConstants.radiusLg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
UiShimmerBox(
width: 64,
height: 22,
borderRadius: UiConstants.radiusFull,
),
const UiShimmerLine(width: 80, height: 12),
],
),
const SizedBox(height: UiConstants.space4),
const UiShimmerLine(width: 180, height: 16),
const SizedBox(height: UiConstants.space2),
const UiShimmerLine(width: 140, height: 12),
const SizedBox(height: UiConstants.space4),
const Divider(color: UiColors.border),
const SizedBox(height: UiConstants.space3),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
UiShimmerLine(width: 80, height: 10),
SizedBox(height: UiConstants.space1),
UiShimmerLine(width: 100, height: 20),
],
),
UiShimmerBox(
width: 100,
height: 36,
borderRadius: UiConstants.radiusMd,
),
],
),
],
),
),
);
}),
),
),
);
}
}

View File

@@ -9,6 +9,7 @@ import '../blocs/coverage_bloc.dart';
import '../blocs/coverage_event.dart';
import '../blocs/coverage_state.dart';
import '../widgets/coverage_calendar_selector.dart';
import '../widgets/coverage_page_skeleton.dart';
import '../widgets/coverage_quick_stats.dart';
import '../widgets/coverage_shift_list.dart';
import '../widgets/coverage_stats_header.dart';
@@ -180,9 +181,7 @@ class _CoveragePageState extends State<CoveragePage> {
}) {
if (state.shifts.isEmpty) {
if (state.status == CoverageStatus.loading) {
return const Center(
child: CircularProgressIndicator(),
);
return const CoveragePageSkeleton();
}
if (state.status == CoverageStatus.failure) {

View File

@@ -0,0 +1,102 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Shimmer loading skeleton that mimics the coverage page loaded layout.
///
/// Shows placeholder shapes for the quick stats row, shift section header,
/// and a list of shift cards with worker rows.
class CoveragePageSkeleton extends StatelessWidget {
/// Creates a [CoveragePageSkeleton].
const CoveragePageSkeleton({super.key});
@override
Widget build(BuildContext context) {
return UiShimmer(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Quick stats row (2 stat cards)
const Row(
children: [
Expanded(child: UiShimmerStatsCard()),
SizedBox(width: UiConstants.space2),
Expanded(child: UiShimmerStatsCard()),
],
),
const SizedBox(height: UiConstants.space6),
// Shifts section header
const UiShimmerLine(width: 140, height: 18),
const SizedBox(height: UiConstants.space6),
// Shift cards with worker rows
const _ShiftCardSkeleton(),
const SizedBox(height: UiConstants.space3),
const _ShiftCardSkeleton(),
const SizedBox(height: UiConstants.space3),
const _ShiftCardSkeleton(),
],
),
),
);
}
}
/// Shimmer placeholder for a single shift card with header and worker rows.
class _ShiftCardSkeleton extends StatelessWidget {
const _ShiftCardSkeleton();
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: Border.all(color: UiColors.border),
borderRadius: UiConstants.radiusLg,
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
// Shift header
Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const UiShimmerLine(width: 180, height: 16),
const SizedBox(height: UiConstants.space2),
const UiShimmerLine(width: 120, height: 12),
const SizedBox(height: UiConstants.space2),
Row(
children: [
const UiShimmerLine(width: 80, height: 12),
const Spacer(),
UiShimmerBox(
width: 60,
height: 24,
borderRadius: UiConstants.radiusFull,
),
],
),
],
),
),
// Worker rows
Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space3,
).copyWith(bottom: UiConstants.space3),
child: const Column(
children: [
UiShimmerListItem(),
UiShimmerListItem(),
],
),
),
],
),
);
}
}

View File

@@ -12,6 +12,7 @@ import '../blocs/client_hubs_state.dart';
import '../widgets/hub_card.dart';
import '../widgets/hub_empty_state.dart';
import '../widgets/hub_info_card.dart';
import '../widgets/hubs_page_skeleton.dart';
/// The main page for the client hubs feature.
///
@@ -94,7 +95,7 @@ class ClientHubsPage extends StatelessWidget {
),
if (state.status == ClientHubsStatus.loading)
const Center(child: CircularProgressIndicator())
const HubsPageSkeleton()
else if (state.hubs.isEmpty)
HubEmptyState(
onAddPressed: () async {

View File

@@ -0,0 +1,56 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Shimmer loading skeleton for the hubs list page.
///
/// Shows placeholder hub cards matching the [HubCard] layout with a
/// leading icon box, title line, and address line.
class HubsPageSkeleton extends StatelessWidget {
/// Creates a [HubsPageSkeleton].
const HubsPageSkeleton({super.key});
@override
Widget build(BuildContext context) {
return UiShimmer(
child: Column(
children: List.generate(5, (int index) {
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: Container(
decoration: BoxDecoration(
border: Border.all(color: UiColors.border),
borderRadius: UiConstants.radiusLg,
),
padding: const EdgeInsets.all(UiConstants.space4),
child: Row(
children: [
// Leading icon placeholder
UiShimmerBox(
width: 52,
height: 52,
borderRadius: UiConstants.radiusLg,
),
const SizedBox(width: UiConstants.space4),
// Title and address lines
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
UiShimmerLine(width: 160, height: 16),
SizedBox(height: UiConstants.space2),
UiShimmerLine(width: 200, height: 12),
],
),
),
const SizedBox(width: UiConstants.space3),
// Chevron placeholder
const UiShimmerBox(width: 16, height: 16),
],
),
),
);
}),
),
);
}
}

View File

@@ -10,6 +10,8 @@ import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../widgets/report_detail_skeleton.dart';
class CoverageReportPage extends StatefulWidget {
const CoverageReportPage({super.key});
@@ -30,7 +32,7 @@ class _CoverageReportPageState extends State<CoverageReportPage> {
body: BlocBuilder<CoverageBloc, CoverageState>(
builder: (BuildContext context, CoverageState state) {
if (state is CoverageLoading) {
return const Center(child: CircularProgressIndicator());
return const ReportDetailSkeleton();
}
if (state is CoverageError) {

View File

@@ -10,6 +10,8 @@ import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../widgets/report_detail_skeleton.dart';
class DailyOpsReportPage extends StatefulWidget {
const DailyOpsReportPage({super.key});
@@ -57,7 +59,7 @@ class _DailyOpsReportPageState extends State<DailyOpsReportPage> {
body: BlocBuilder<DailyOpsBloc, DailyOpsState>(
builder: (BuildContext context, DailyOpsState state) {
if (state is DailyOpsLoading) {
return const Center(child: CircularProgressIndicator());
return const ReportDetailSkeleton();
}
if (state is DailyOpsError) {

View File

@@ -12,6 +12,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import '../widgets/report_detail_skeleton.dart';
class ForecastReportPage extends StatefulWidget {
const ForecastReportPage({super.key});
@@ -32,7 +34,7 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
body: BlocBuilder<ForecastBloc, ForecastState>(
builder: (BuildContext context, ForecastState state) {
if (state is ForecastLoading) {
return const Center(child: CircularProgressIndicator());
return const ReportDetailSkeleton();
}
if (state is ForecastError) {

View File

@@ -11,6 +11,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import '../widgets/report_detail_skeleton.dart';
class NoShowReportPage extends StatefulWidget {
const NoShowReportPage({super.key});
@@ -31,7 +33,7 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
body: BlocBuilder<NoShowBloc, NoShowState>(
builder: (BuildContext context, NoShowState state) {
if (state is NoShowLoading) {
return const Center(child: CircularProgressIndicator());
return const ReportDetailSkeleton();
}
if (state is NoShowError) {

View File

@@ -9,6 +9,8 @@ import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../widgets/report_detail_skeleton.dart';
class PerformanceReportPage extends StatefulWidget {
const PerformanceReportPage({super.key});
@@ -29,7 +31,7 @@ class _PerformanceReportPageState extends State<PerformanceReportPage> {
body: BlocBuilder<PerformanceBloc, PerformanceState>(
builder: (BuildContext context, PerformanceState state) {
if (state is PerformanceLoading) {
return const Center(child: CircularProgressIndicator());
return const ReportDetailSkeleton();
}
if (state is PerformanceError) {

View File

@@ -11,6 +11,8 @@ import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../widgets/report_detail_skeleton.dart';
class SpendReportPage extends StatefulWidget {
const SpendReportPage({super.key});
@@ -42,7 +44,7 @@ class _SpendReportPageState extends State<SpendReportPage> {
body: BlocBuilder<SpendBloc, SpendState>(
builder: (BuildContext context, SpendState state) {
if (state is SpendLoading) {
return const Center(child: CircularProgressIndicator());
return const ReportDetailSkeleton();
}
if (state is SpendError) {

View File

@@ -0,0 +1,156 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Shimmer loading skeleton for individual report detail pages.
///
/// Shows a header area, two summary stat cards, a chart placeholder,
/// and a breakdown list. Used by spend, coverage, no-show, forecast,
/// daily ops, and performance report pages.
class ReportDetailSkeleton extends StatelessWidget {
/// Creates a [ReportDetailSkeleton].
const ReportDetailSkeleton({super.key});
@override
Widget build(BuildContext context) {
return UiShimmer(
child: SingleChildScrollView(
child: Column(
children: [
// Header area (matches the blue header with back button + title)
Container(
padding: const EdgeInsets.only(
top: 60,
left: UiConstants.space5,
right: UiConstants.space5,
bottom: UiConstants.space10,
),
color: UiColors.primary,
child: Row(
children: [
const UiShimmerCircle(size: UiConstants.space10),
const SizedBox(width: UiConstants.space3),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
UiShimmerBox(
width: 140,
height: 18,
borderRadius: UiConstants.radiusSm,
),
const SizedBox(height: UiConstants.space2),
UiShimmerBox(
width: 100,
height: 12,
borderRadius: UiConstants.radiusSm,
),
],
),
],
),
),
// Content pulled up to overlap header
Transform.translate(
offset: const Offset(0, -40),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Summary stat cards row
const Row(
children: [
Expanded(child: UiShimmerStatsCard()),
SizedBox(width: UiConstants.space3),
Expanded(child: UiShimmerStatsCard()),
],
),
const SizedBox(height: UiConstants.space6),
// Chart placeholder
Container(
height: 280,
padding: const EdgeInsets.all(UiConstants.space5),
decoration: BoxDecoration(
border: Border.all(color: UiColors.border),
borderRadius: UiConstants.radiusXl,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const UiShimmerLine(width: 140, height: 14),
const SizedBox(height: UiConstants.space8),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(7, (int index) {
// Varying bar heights for visual interest
final double height =
40.0 + (index * 17 % 120);
return UiShimmerBox(
width: 12,
height: height,
borderRadius: UiConstants.radiusSm,
);
}),
),
),
const SizedBox(height: UiConstants.space3),
const UiShimmerLine(height: 10),
],
),
),
const SizedBox(height: UiConstants.space6),
// Breakdown section
Container(
padding: const EdgeInsets.all(UiConstants.space5),
decoration: BoxDecoration(
border: Border.all(color: UiColors.border),
borderRadius: UiConstants.radiusXl,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const UiShimmerLine(width: 160, height: 14),
const SizedBox(height: UiConstants.space6),
...List.generate(3, (int index) {
return Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space5,
),
child: Column(
children: [
const Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
UiShimmerLine(width: 100, height: 12),
UiShimmerLine(width: 60, height: 12),
],
),
const SizedBox(height: UiConstants.space2),
UiShimmerBox(
width: double.infinity,
height: 6,
borderRadius: UiConstants.radiusSm,
),
],
),
);
}),
],
),
),
],
),
),
),
],
),
),
);
}
}

View File

@@ -1,5 +1,6 @@
export 'metric_card.dart';
export 'metrics_grid.dart';
export 'metrics_grid_skeleton.dart';
export 'quick_reports_section.dart';
export 'report_card.dart';
export 'reports_header.dart';

View File

@@ -8,6 +8,7 @@ import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import 'metric_card.dart';
import 'metrics_grid_skeleton.dart';
/// A grid of key metrics driven by the ReportsSummaryBloc.
///
@@ -29,10 +30,7 @@ class MetricsGrid extends StatelessWidget {
builder: (BuildContext context, ReportsSummaryState state) {
// Loading or Initial State
if (state is ReportsSummaryLoading || state is ReportsSummaryInitial) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: CircularProgressIndicator()),
);
return const MetricsGridSkeleton();
}
// Error State

View File

@@ -0,0 +1,71 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Shimmer loading skeleton for the reports metrics grid.
///
/// Shows a 2-column grid of 6 placeholder cards matching the [MetricsGrid]
/// loaded layout.
class MetricsGridSkeleton extends StatelessWidget {
/// Creates a [MetricsGridSkeleton].
const MetricsGridSkeleton({super.key});
@override
Widget build(BuildContext context) {
return UiShimmer(
child: GridView.count(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space6),
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: UiConstants.space3,
crossAxisSpacing: UiConstants.space3,
childAspectRatio: 1.32,
children: List.generate(6, (int index) {
return const _MetricCardSkeleton();
}),
),
);
}
}
/// Shimmer placeholder for a single metric card.
class _MetricCardSkeleton extends StatelessWidget {
const _MetricCardSkeleton();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
border: Border.all(color: UiColors.border),
borderRadius: UiConstants.radiusLg,
color: UiColors.cardViewBackground,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Icon + label row
Row(
children: [
const UiShimmerCircle(size: UiConstants.space6),
const SizedBox(width: UiConstants.space2),
const Expanded(
child: UiShimmerLine(width: 60, height: 10),
),
],
),
const Spacer(),
// Value
const UiShimmerLine(width: 80, height: 22),
const SizedBox(height: UiConstants.space2),
// Badge
UiShimmerBox(
width: 60,
height: 20,
borderRadius: UiConstants.radiusSm,
),
],
),
);
}
}