diff --git a/.claude/agent-memory/mobile-architecture-reviewer/MEMORY.md b/.claude/agent-memory/mobile-architecture-reviewer/MEMORY.md new file mode 100644 index 00000000..483ce2c3 --- /dev/null +++ b/.claude/agent-memory/mobile-architecture-reviewer/MEMORY.md @@ -0,0 +1,22 @@ +# Mobile Architecture Reviewer Memory + +## Project Structure +- Features: `apps/mobile/packages/features/{client,staff}//` +- Design System: `apps/mobile/packages/design_system/` +- Shimmer primitives: `design_system/lib/src/widgets/shimmer/` (UiShimmer, UiShimmerBox, UiShimmerCircle, UiShimmerLine, presets) +- UiConstants spacing: space0=0, space1=4, space2=8, space3=12, space4=16, space5=20, space6=24, space8=32, space10=40, space12=48 + +## Design System Conventions +- `UiConstants.radiusLg`, `radiusMd`, `radiusSm`, `radiusFull` are `static final` (not const) - cannot use `const` at call sites +- Shimmer placeholder dimensions (width/height of boxes/lines/circles) are visual content sizes, not spacing - the design system presets use UiConstants for these too +- `Divider(height: 1, thickness: 0.5)` is a common pattern in the codebase for thin dividers + +## Common Pre-Existing Issues (Do Not Flag as New) +- Report detail pages use hardcoded `top: 60` for AppBar clearance (all 6 report pages) +- Payments page and billing page have hardcoded strings (pre-existing, not part of shimmer changes) +- `shift_details_page.dart` has hardcoded strings and Navigator.of usage (pre-existing) + +## Review Patterns +- Skeleton files are pure presentation widgets (StatelessWidget) - no BLoC, no business logic, no navigation +- Skeleton files only import `design_system` and `flutter/material.dart` - clean dependency +- Barrel file `index.dart` in `reports_page/` widgets dir is an internal barrel, not public API diff --git a/.claude/agent-memory/mobile-builder/MEMORY.md b/.claude/agent-memory/mobile-builder/MEMORY.md new file mode 100644 index 00000000..f22b7033 --- /dev/null +++ b/.claude/agent-memory/mobile-builder/MEMORY.md @@ -0,0 +1,42 @@ +# Mobile Builder Agent Memory + +## Design System - Shimmer Primitives +- Shimmer widgets are in `packages/design_system/lib/src/widgets/shimmer/` +- Available: `UiShimmer`, `UiShimmerBox`, `UiShimmerCircle`, `UiShimmerLine`, `UiShimmerListItem`, `UiShimmerStatsCard`, `UiShimmerSectionHeader`, `UiShimmerList` +- `UiShimmerList.itemBuilder` takes `(int index)` -- single parameter, not `(BuildContext, int)` +- `UiShimmerBox.borderRadius` accepts `BorderRadius?` (nullable), uses `UiConstants.radiusMd` as default +- All shimmer shapes render as solid white containers; the parent `UiShimmer` applies the animated gradient +- Exported via `design_system.dart` barrel + +## Staff App Feature Locations +- Shifts: `packages/features/staff/shifts/` -- has ShiftsPage (tabbed: MyShifts/Find/History) + ShiftDetailsPage +- Home: `packages/features/staff/home/` -- WorkerHomePage with sections (TodaysShifts, TomorrowsShifts, Recommended, Benefits, QuickActions) +- Payments: `packages/features/staff/payments/` -- PaymentsPage with gradient header + stats + payment history +- Home cubit: `HomeStatus` enum (initial, loading, loaded, error) +- Shifts bloc: `ShiftsStatus` enum + sub-loading flags (`availableLoading`, `historyLoading`) +- Payments bloc: uses sealed state classes (`PaymentsLoading`, `PaymentsLoaded`, `PaymentsError`) + +## UiConstants Spacing Tokens +- Use `UiConstants.space1` through `UiConstants.space24` for spacing +- Radius: `UiConstants.radiusSm`, `radiusMd`, `radiusLg`, `radiusFull`, `radiusBase`, `radiusMdValue` (double) +- `UiConstants.radiusFull` is a `BorderRadius`, `UiConstants.radiusMdValue` is a `double` + +## Barrel Files (Staff Features) +- Shifts: `lib/staff_shifts.dart` exports modules only +- Payments: `lib/staff_payements.dart` (note: typo in filename) exports module only +- Home: `lib/staff_home.dart` exports module only +- These barrel files only export modules, not individual widgets -- skeleton widgets don't need to be added + +## Client App Feature Locations +- Coverage: `packages/features/client/client_coverage/` +- Home: `packages/features/client/home/` (no loading spinner -- renders default data during load) +- Billing: `packages/features/client/billing/` (billing_page, pending_invoices_page, invoice_ready_page) +- Reports: `packages/features/client/reports/` (reports_page with metrics_grid, plus 6 sub-report pages) +- Reports barrel: `widgets/reports_page/index.dart` +- Hubs: `packages/features/client/hubs/` (client_hubs_page + hub_details_page + edit_hub_page) + +## Key Patterns Observed +- BenefitsOverviewPage also has CircularProgressIndicator (not shimmer-ified yet) +- ShiftDetailsPage has a dialog-level spinner in the "applying" dialog -- this is intentional, not a page loading state +- Hub details/edit pages use CircularProgressIndicator as action overlays (save/delete) -- keep as-is, not initial load +- Client home page has no loading spinner; it renders with default empty dashboard data diff --git a/apps/mobile/packages/design_system/lib/design_system.dart b/apps/mobile/packages/design_system/lib/design_system.dart index 36c51fad..5ffe5f13 100644 --- a/apps/mobile/packages/design_system/lib/design_system.dart +++ b/apps/mobile/packages/design_system/lib/design_system.dart @@ -14,3 +14,6 @@ export 'src/widgets/ui_loading_page.dart'; export 'src/widgets/ui_snackbar.dart'; export 'src/widgets/ui_notice_banner.dart'; export 'src/widgets/ui_empty_state.dart'; +export 'src/widgets/shimmer/ui_shimmer.dart'; +export 'src/widgets/shimmer/ui_shimmer_shapes.dart'; +export 'src/widgets/shimmer/ui_shimmer_presets.dart'; diff --git a/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer.dart b/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer.dart new file mode 100644 index 00000000..7fc83708 --- /dev/null +++ b/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer.dart @@ -0,0 +1,27 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; + +/// Core shimmer wrapper that applies an animated gradient effect to its child. +/// +/// Wraps the `shimmer` package's [Shimmer.fromColors] using design system +/// color tokens. Place shimmer shape primitives as children. +class UiShimmer extends StatelessWidget { + /// Creates a shimmer effect wrapper around [child]. + const UiShimmer({ + super.key, + required this.child, + }); + + /// The widget tree to apply the shimmer gradient over. + final Widget child; + + @override + Widget build(BuildContext context) { + return Shimmer.fromColors( + baseColor: UiColors.muted, + highlightColor: UiColors.background, + child: child, + ); + } +} diff --git a/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer_presets.dart b/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer_presets.dart new file mode 100644 index 00000000..867542b0 --- /dev/null +++ b/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer_presets.dart @@ -0,0 +1,123 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// List-row shimmer skeleton with a leading circle, two text lines, and a +/// trailing box. +/// +/// Mimics a typical list item layout during loading. Wrap with [UiShimmer] +/// to activate the animated gradient. +class UiShimmerListItem extends StatelessWidget { + /// Creates a list-row shimmer skeleton. + const UiShimmerListItem({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space2, + ), + child: Row( + children: [ + const UiShimmerCircle(size: UiConstants.space10), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const UiShimmerLine(width: 160), + const SizedBox(height: UiConstants.space2), + const UiShimmerLine(width: 100, height: 12), + ], + ), + ), + const SizedBox(width: UiConstants.space3), + const UiShimmerBox(width: 48, height: 24), + ], + ), + ); + } +} + +/// Stats-card shimmer skeleton with an icon placeholder, a short label line, +/// and a taller value line. +/// +/// Wrapped in a bordered container matching the design system card pattern. +/// Wrap with [UiShimmer] to activate the animated gradient. +class UiShimmerStatsCard extends StatelessWidget { + /// Creates a stats-card shimmer skeleton. + const UiShimmerStatsCard({super.key}); + + @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: [ + 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), + ], + ), + ); + } +} + +/// Section-header shimmer skeleton rendering a single wide line placeholder. +/// +/// Wrap with [UiShimmer] to activate the animated gradient. +class UiShimmerSectionHeader extends StatelessWidget { + /// Creates a section-header shimmer skeleton. + const UiShimmerSectionHeader({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: UiConstants.space2), + child: UiShimmerLine(width: 200, height: 18), + ); + } +} + +/// Repeats a shimmer widget [itemCount] times in a [Column] with spacing. +/// +/// Use [itemBuilder] to produce each item. Wrap the entire list with +/// [UiShimmer] to share a single animated gradient across all items. +class UiShimmerList extends StatelessWidget { + /// Creates a shimmer list with [itemCount] items built by [itemBuilder]. + const UiShimmerList({ + super.key, + required this.itemBuilder, + this.itemCount = 3, + this.spacing, + }); + + /// Builder that produces each shimmer placeholder item by index. + final Widget Function(int index) itemBuilder; + + /// Number of shimmer items to render. Defaults to 3. + final int itemCount; + + /// Vertical spacing between items. Defaults to [UiConstants.space3]. + final double? spacing; + + @override + Widget build(BuildContext context) { + final gap = spacing ?? UiConstants.space3; + return Column( + children: List.generate(itemCount, (index) { + return Padding( + padding: EdgeInsets.only(bottom: index < itemCount - 1 ? gap : 0), + child: itemBuilder(index), + ); + }), + ); + } +} diff --git a/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer_shapes.dart b/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer_shapes.dart new file mode 100644 index 00000000..4fcc1ba2 --- /dev/null +++ b/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer_shapes.dart @@ -0,0 +1,95 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Rectangular shimmer placeholder with configurable dimensions and corner radius. +/// +/// Renders as a solid white container; the parent [UiShimmer] applies the +/// animated gradient. +class UiShimmerBox extends StatelessWidget { + /// Creates a rectangular shimmer placeholder. + const UiShimmerBox({ + super.key, + required this.width, + required this.height, + this.borderRadius, + }); + + /// Width of the placeholder rectangle. + final double width; + + /// Height of the placeholder rectangle. + final double height; + + /// Corner radius. Defaults to [UiConstants.radiusMd] when null. + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: borderRadius ?? UiConstants.radiusMd, + ), + ); + } +} + +/// Circular shimmer placeholder with a configurable diameter. +/// +/// Renders as a solid white circle; the parent [UiShimmer] applies the +/// animated gradient. +class UiShimmerCircle extends StatelessWidget { + /// Creates a circular shimmer placeholder with the given [size] as diameter. + const UiShimmerCircle({ + super.key, + required this.size, + }); + + /// Diameter of the circle. + final double size; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: const BoxDecoration( + color: UiColors.white, + shape: BoxShape.circle, + ), + ); + } +} + +/// Text-line shimmer placeholder with configurable width and height. +/// +/// Useful for simulating a single line of text. Renders as a solid white +/// rounded rectangle; the parent [UiShimmer] applies the animated gradient. +class UiShimmerLine extends StatelessWidget { + /// Creates a text-line shimmer placeholder. + const UiShimmerLine({ + super.key, + this.width = double.infinity, + this.height = 14, + }); + + /// Width of the line. Defaults to [double.infinity]. + final double width; + + /// Height of the line. Defaults to 14 logical pixels. + final double height; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusSm, + ), + ); + } +} diff --git a/apps/mobile/packages/design_system/pubspec.yaml b/apps/mobile/packages/design_system/pubspec.yaml index 0979764c..1153026d 100644 --- a/apps/mobile/packages/design_system/pubspec.yaml +++ b/apps/mobile/packages/design_system/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: google_fonts: ^7.0.2 lucide_icons: ^0.257.0 font_awesome_flutter: ^10.7.0 + shimmer: ^3.0.0 dev_dependencies: flutter_test: diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart index f7a80aab..ad47a9cf 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart @@ -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 { 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) { diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart index 430b5193..d7620b3b 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart @@ -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( builder: (BuildContext context, BillingState state) { if (state.status == BillingStatus.loading) { - return const Center(child: CircularProgressIndicator()); + return const InvoicesListSkeleton(); } if (state.invoiceHistory.isEmpty) { diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart index d76b6d1a..3b29c4b5 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart @@ -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) { diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton.dart new file mode 100644 index 00000000..b5a64b74 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton.dart @@ -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), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoices_list_skeleton.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoices_list_skeleton.dart new file mode 100644 index 00000000..42bc6543 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoices_list_skeleton.dart @@ -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, + ), + ], + ), + ], + ), + ), + ); + }), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart index 509a4e6d..529bd360 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart @@ -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 { }) { if (state.shifts.isEmpty) { if (state.status == CoverageStatus.loading) { - return const Center( - child: CircularProgressIndicator(), - ); + return const CoveragePageSkeleton(); } if (state.status == CoverageStatus.failure) { diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton.dart new file mode 100644 index 00000000..1efbc417 --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton.dart @@ -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(), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart index d120664b..28857947 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart @@ -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 { diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hubs_page_skeleton.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hubs_page_skeleton.dart new file mode 100644 index 00000000..4fcb39bd --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hubs_page_skeleton.dart @@ -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), + ], + ), + ), + ); + }), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart index 54ba368b..a6f3cdaf 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart @@ -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 { body: BlocBuilder( builder: (BuildContext context, CoverageState state) { if (state is CoverageLoading) { - return const Center(child: CircularProgressIndicator()); + return const ReportDetailSkeleton(); } if (state is CoverageError) { diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart index 15e4765f..03de178c 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart @@ -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 { body: BlocBuilder( builder: (BuildContext context, DailyOpsState state) { if (state is DailyOpsLoading) { - return const Center(child: CircularProgressIndicator()); + return const ReportDetailSkeleton(); } if (state is DailyOpsError) { diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart index a0479a67..cd6ef84b 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart @@ -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 { body: BlocBuilder( builder: (BuildContext context, ForecastState state) { if (state is ForecastLoading) { - return const Center(child: CircularProgressIndicator()); + return const ReportDetailSkeleton(); } if (state is ForecastError) { diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart index 7cf962d2..6ba6a336 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart @@ -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 { body: BlocBuilder( builder: (BuildContext context, NoShowState state) { if (state is NoShowLoading) { - return const Center(child: CircularProgressIndicator()); + return const ReportDetailSkeleton(); } if (state is NoShowError) { diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart index ccfd5169..eb6f3a90 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart @@ -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 { body: BlocBuilder( builder: (BuildContext context, PerformanceState state) { if (state is PerformanceLoading) { - return const Center(child: CircularProgressIndicator()); + return const ReportDetailSkeleton(); } if (state is PerformanceError) { diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart index 7ba1eeb9..af3265e2 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart @@ -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 { body: BlocBuilder( builder: (BuildContext context, SpendState state) { if (state is SpendLoading) { - return const Center(child: CircularProgressIndicator()); + return const ReportDetailSkeleton(); } if (state is SpendError) { diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/report_detail_skeleton.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/report_detail_skeleton.dart new file mode 100644 index 00000000..d9c26fbb --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/report_detail_skeleton.dart @@ -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, + ), + ], + ), + ); + }), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/index.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/index.dart index 58d67814..4040583c 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/index.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/index.dart @@ -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'; diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid.dart index e90d081a..91566e93 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid.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 diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton.dart new file mode 100644 index 00000000..52717048 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton.dart @@ -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, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart index 5045548b..78a5bf22 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart @@ -9,6 +9,7 @@ import 'package:staff_home/src/presentation/widgets/home_page/benefits_section.d import 'package:staff_home/src/presentation/widgets/home_page/full_width_divider.dart'; import 'package:staff_home/src/presentation/widgets/home_page/home_header.dart'; import 'package:staff_home/src/presentation/widgets/home_page/placeholder_banner.dart'; +import 'package:staff_home/src/presentation/widgets/home_page/home_page_skeleton.dart'; import 'package:staff_home/src/presentation/widgets/home_page/quick_actions_section.dart'; import 'package:staff_home/src/presentation/widgets/home_page/recommended_shifts_section.dart'; import 'package:staff_home/src/presentation/widgets/home_page/todays_shifts_section.dart'; @@ -59,8 +60,14 @@ class WorkerHomePage extends StatelessWidget { ), child: BlocBuilder( buildWhen: (previous, current) => + previous.status != current.status || previous.isProfileComplete != current.isProfileComplete, builder: (context, state) { + if (state.status == HomeStatus.loading || + state.status == HomeStatus.initial) { + return const HomePageSkeleton(); + } + if (!state.isProfileComplete) { return SizedBox( height: MediaQuery.of(context).size.height - 300, diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton.dart new file mode 100644 index 00000000..aaa8e48c --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton.dart @@ -0,0 +1,201 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer loading skeleton for the staff home page. +/// +/// Mimics the loaded layout with quick actions, today's shifts, tomorrow's +/// shifts, recommended shifts, and benefits sections. Displayed while +/// [HomeCubit] is fetching initial data. +class HomePageSkeleton extends StatelessWidget { + /// Creates a [HomePageSkeleton]. + const HomePageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Quick actions row (3 circular icons + labels) + const _QuickActionsSkeleton(), + + const _SkeletonDivider(), + + // Today's Shifts section + const _ShiftSectionSkeleton(), + + const _SkeletonDivider(), + + // Tomorrow's Shifts section + const _ShiftSectionSkeleton(), + + const _SkeletonDivider(), + + // Recommended Shifts (horizontal cards) + const _RecommendedSectionSkeleton(), + + const _SkeletonDivider(), + + // Benefits section + Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space3, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space3), + UiShimmerList( + itemCount: 2, + itemBuilder: (index) => const UiShimmerListItem(), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// Skeleton for the quick actions row (3 circular placeholders with labels). +class _QuickActionsSkeleton extends StatelessWidget { + const _QuickActionsSkeleton(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space3, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(3, (index) { + return const Expanded( + child: Column( + children: [ + UiShimmerCircle(size: 48), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 60, height: 12), + ], + ), + ); + }), + ), + ); + } +} + +/// Skeleton for a shift section (section header + 2 shift card placeholders). +class _ShiftSectionSkeleton extends StatelessWidget { + const _ShiftSectionSkeleton(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space3, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space3), + UiShimmerList( + itemCount: 2, + itemBuilder: (index) => const _ShiftCardSkeleton(), + ), + ], + ), + ); + } +} + +/// Skeleton for a single compact shift card on the home page. +class _ShiftCardSkeleton extends StatelessWidget { + const _ShiftCardSkeleton(); + + @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: [ + UiShimmerBox(width: 48, height: 48), + SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 160, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 120, height: 12), + ], + ), + ), + SizedBox(width: UiConstants.space3), + UiShimmerBox(width: 56, height: 24), + ], + ), + ); + } +} + +/// Skeleton for the recommended shifts horizontal scroll section. +class _RecommendedSectionSkeleton extends StatelessWidget { + const _RecommendedSectionSkeleton(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: UiConstants.space3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: UiConstants.space4), + child: UiShimmerSectionHeader(), + ), + const SizedBox(height: UiConstants.space3), + SizedBox( + height: 120, + child: ListView.builder( + scrollDirection: Axis.horizontal, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + ), + itemCount: 3, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.only(right: UiConstants.space3), + child: UiShimmerBox( + width: 200, + height: 120, + borderRadius: UiConstants.radiusLg, + ), + ), + ), + ), + ], + ), + ); + } +} + +/// A thin full-width divider placeholder matching the home page layout. +class _SkeletonDivider extends StatelessWidget { + const _SkeletonDivider(); + + @override + Widget build(BuildContext context) { + return const Divider(height: 1, thickness: 0.5, color: UiColors.border); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/todays_shifts_section.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/todays_shifts_section.dart index 764da501..adad147a 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/todays_shifts_section.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/todays_shifts_section.dart @@ -35,15 +35,7 @@ class TodaysShiftsSection extends StatelessWidget { ) : null, child: state.status == HomeStatus.loading - ? const Center( - child: SizedBox( - height: UiConstants.space10, - width: UiConstants.space10, - child: CircularProgressIndicator( - color: UiColors.primary, - ), - ), - ) + ? const _ShiftsSectionSkeleton() : shifts.isEmpty ? EmptyStateWidget( message: emptyI18n.no_shifts_today, @@ -66,3 +58,40 @@ class TodaysShiftsSection extends StatelessWidget { ); } } + +/// Inline shimmer skeleton for the shifts section loading state. +class _ShiftsSectionSkeleton extends StatelessWidget { + const _ShiftsSectionSkeleton(); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: UiShimmerList( + itemCount: 2, + itemBuilder: (index) => Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + ), + child: const Row( + children: [ + UiShimmerBox(width: 48, height: 48), + SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 160, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 120, height: 12), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart index b1ff94f3..1420c110 100644 --- a/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/pages/payments_page.dart @@ -8,6 +8,7 @@ import 'package:core_localization/core_localization.dart'; import '../blocs/payments/payments_bloc.dart'; import '../blocs/payments/payments_event.dart'; import '../blocs/payments/payments_state.dart'; +import '../widgets/payments_page_skeleton.dart'; import '../widgets/payment_stats_card.dart'; import '../widgets/payment_history_item.dart'; import '../widgets/earnings_graph.dart'; @@ -41,7 +42,7 @@ class _PaymentsPageState extends State { }, builder: (BuildContext context, PaymentsState state) { if (state is PaymentsLoading) { - return const Center(child: CircularProgressIndicator()); + return const PaymentsPageSkeleton(); } else if (state is PaymentsError) { return Center( diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton.dart new file mode 100644 index 00000000..abeeeb0a --- /dev/null +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton.dart @@ -0,0 +1,148 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer loading skeleton for the payments page. +/// +/// Mimics the loaded layout: a gradient header with balance and period tabs, +/// an earnings graph placeholder, stat cards, and a recent payments list. +class PaymentsPageSkeleton extends StatelessWidget { + /// Creates a [PaymentsPageSkeleton]. + const PaymentsPageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + child: Column( + children: [ + // Header section with gradient + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + UiColors.primary, + UiColors.primary.withValues(alpha: 0.8), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + padding: EdgeInsets.fromLTRB( + UiConstants.space5, + MediaQuery.of(context).padding.top + UiConstants.space6, + UiConstants.space5, + UiConstants.space8, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title placeholder + const UiShimmerLine(width: 120, height: 24), + const SizedBox(height: UiConstants.space6), + + // Balance center + const Center( + child: Column( + children: [ + UiShimmerLine(width: 100, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 160, height: 36), + ], + ), + ), + const SizedBox(height: UiConstants.space4), + + // Period tabs placeholder + UiShimmerBox( + width: double.infinity, + height: 40, + borderRadius: UiConstants.radiusMd, + ), + ], + ), + ), + + // Main content offset upwards + Transform.translate( + offset: const Offset(0, -UiConstants.space4), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Earnings graph placeholder + UiShimmerBox( + width: double.infinity, + height: 180, + borderRadius: UiConstants.radiusLg, + ), + const SizedBox(height: UiConstants.space6), + + // Quick stats row + Row( + children: [ + Expanded(child: UiShimmerStatsCard()), + const SizedBox(width: UiConstants.space3), + Expanded(child: UiShimmerStatsCard()), + ], + ), + const SizedBox(height: UiConstants.space8), + + // Recent Payments header + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space3), + + // Payment history items + UiShimmerList( + itemCount: 4, + itemBuilder: (index) => const _PaymentItemSkeleton(), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Skeleton for a single payment history item. +/// +/// Matches the [PaymentHistoryItem] layout with a leading icon, title/subtitle +/// lines, and trailing amount text. +class _PaymentItemSkeleton extends StatelessWidget { + const _PaymentItemSkeleton(); + + @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: [ + UiShimmerCircle(size: 40), + SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 140, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 100, height: 12), + ], + ), + ), + SizedBox(width: UiConstants.space3), + UiShimmerLine(width: 60, height: 16), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart index 11caa4ac..0a56ae04 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shift_details_page.dart @@ -15,6 +15,7 @@ import '../widgets/shift_details/shift_date_time_section.dart'; import '../widgets/shift_details/shift_description_section.dart'; import '../widgets/shift_details/shift_details_bottom_bar.dart'; import '../widgets/shift_details/shift_details_header.dart'; +import '../widgets/shift_details_page_skeleton.dart'; import '../widgets/shift_details/shift_location_section.dart'; import '../widgets/shift_details/shift_schedule_summary_section.dart'; import '../widgets/shift_details/shift_stats_row.dart'; @@ -118,9 +119,7 @@ class _ShiftDetailsPageState extends State { }, builder: (context, state) { if (state is ShiftDetailsLoading) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); + return const ShiftDetailsPageSkeleton(); } final Shift displayShift = widget.shift; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart index 2896fe8d..6f6a3a6d 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/pages/shifts_page.dart @@ -6,6 +6,7 @@ import 'package:core_localization/core_localization.dart'; import 'package:krow_domain/krow_domain.dart'; import '../blocs/shifts/shifts_bloc.dart'; import '../utils/shift_tab_type.dart'; +import '../widgets/shifts_page_skeleton.dart'; import '../widgets/tabs/my_shifts_tab.dart'; import '../widgets/tabs/find_shifts_tab.dart'; import '../widgets/tabs/history_shifts_tab.dart'; @@ -196,7 +197,7 @@ class _ShiftsPageState extends State { // Body Content Expanded( child: state.status == ShiftsStatus.loading - ? const Center(child: CircularProgressIndicator()) + ? const ShiftsPageSkeleton() : state.status == ShiftsStatus.error ? Center( child: Padding( @@ -252,7 +253,7 @@ class _ShiftsPageState extends State { ); case ShiftTabType.find: if (availableLoading) { - return const Center(child: CircularProgressIndicator()); + return const ShiftsPageSkeleton(); } return FindShiftsTab( availableJobs: availableJobs, @@ -260,7 +261,7 @@ class _ShiftsPageState extends State { ); case ShiftTabType.history: if (historyLoading) { - return const Center(child: CircularProgressIndicator()); + return const ShiftsPageSkeleton(); } return HistoryShiftsTab(historyShifts: historyShifts); } diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton.dart new file mode 100644 index 00000000..01bdefeb --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton.dart @@ -0,0 +1,173 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer loading skeleton for the shift details page. +/// +/// Mimics the loaded layout: a header with icon + text lines, a stats row +/// with three stat cards, and content sections with date/time and location +/// placeholders. +class ShiftDetailsPageSkeleton extends StatelessWidget { + /// Creates a [ShiftDetailsPageSkeleton]. + const ShiftDetailsPageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: const UiAppBar(centerTitle: false), + body: UiShimmer( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: icon box + title/subtitle lines + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerBox( + width: 114, + height: 100, + borderRadius: UiConstants.radiusMd, + ), + const SizedBox(width: UiConstants.space4), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 180, height: 20), + SizedBox(height: UiConstants.space3), + UiShimmerLine(width: 140, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 200, height: 12), + ], + ), + ), + ], + ), + ), + + const Divider(height: 1, thickness: 0.5), + + // Stats row: three stat cards + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Row( + children: List.generate(3, (index) { + return Expanded( + child: Padding( + padding: EdgeInsets.only( + left: index > 0 ? UiConstants.space2 : 0, + ), + child: const _StatCardSkeleton(), + ), + ); + }), + ), + ), + + const Divider(height: 1, thickness: 0.5), + + // Date / time section + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const UiShimmerLine(width: 100, height: 14), + const SizedBox(height: UiConstants.space3), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerLine(width: 80, height: 12), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 120, height: 16), + ], + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerLine(width: 80, height: 12), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 120, height: 16), + ], + ), + ), + ], + ), + ], + ), + ), + + const Divider(height: 1, thickness: 0.5), + + // Location section + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerLine(width: 80, height: 14), + SizedBox(height: UiConstants.space3), + UiShimmerLine(height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 240, height: 12), + ], + ), + ), + + const Divider(height: 1, thickness: 0.5), + + // Description section + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerLine(width: 120, height: 14), + SizedBox(height: UiConstants.space3), + UiShimmerLine(height: 12), + SizedBox(height: UiConstants.space2), + UiShimmerLine(height: 12), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 200, height: 12), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Skeleton for a single stat card in the stats row. +class _StatCardSkeleton extends StatelessWidget { + const _StatCardSkeleton(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.bgThird, + borderRadius: UiConstants.radiusMd, + ), + child: const Column( + children: [ + UiShimmerCircle(size: 40), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 50, height: 16), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 60, height: 12), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton.dart new file mode 100644 index 00000000..fb187171 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton.dart @@ -0,0 +1,72 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer loading skeleton for the shifts page body content. +/// +/// Mimics the loaded layout with a section header and a list of shift card +/// placeholders. Used while the initial shifts data is being fetched. +class ShiftsPageSkeleton extends StatelessWidget { + /// Creates a [ShiftsPageSkeleton]. + const ShiftsPageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space3), + UiShimmerList( + itemCount: 5, + itemBuilder: (index) => const _ShiftCardSkeleton(), + ), + ], + ), + ), + ); + } +} + +/// Skeleton for a single shift card matching the shift list item layout. +/// +/// Shows a rounded container with placeholder lines for the shift title, +/// time, location, and a trailing status badge. +class _ShiftCardSkeleton extends StatelessWidget { + const _ShiftCardSkeleton(); + + @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( + children: [ + const Expanded( + child: UiShimmerLine(width: 180, height: 16), + ), + const SizedBox(width: UiConstants.space3), + UiShimmerBox( + width: 64, + height: 24, + borderRadius: UiConstants.radiusFull, + ), + ], + ), + const SizedBox(height: UiConstants.space3), + const UiShimmerLine(width: 140, height: 12), + const SizedBox(height: UiConstants.space2), + const UiShimmerLine(width: 200, height: 12), + ], + ), + ); + } +} diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 3b76b755..c08e4dd6 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -1397,6 +1397,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + shimmer: + dependency: transitive + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter