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:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user