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..77531b1b --- /dev/null +++ b/.claude/agent-memory/mobile-builder/MEMORY.md @@ -0,0 +1,55 @@ +# 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) + +## Staff Profile Sections (shimmer done) +- Compliance: certificates, documents, tax_forms -- all have shimmer skeletons +- Finances: staff_bank_account, time_card -- all have shimmer skeletons +- Onboarding: attire, profile_info (personal_info_page only) -- have shimmer skeletons +- Support: faqs, privacy_security (including legal sub-pages) -- have shimmer skeletons +- Pages that intentionally keep CircularProgressIndicator (action/submit spinners): + - form_i9_page, form_w4_page (submit button spinners) + - experience_page (save button spinner) + - preferred_locations_page (save button + overlay spinner) + - certificate_upload_page, document_upload_page, attire_capture_page (form/upload pages, no initial load) + - language_selection_page (no loading state, static list) +- LegalDocumentSkeleton is shared between PrivacyPolicyPage and TermsOfServicePage + +## 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/.claude/agents/architecture-reviewer.md b/.claude/agents/architecture-reviewer.md index 887a4f0b..ebbffb75 100644 --- a/.claude/agents/architecture-reviewer.md +++ b/.claude/agents/architecture-reviewer.md @@ -223,7 +223,9 @@ A PR is approved ONLY when ALL of these are true: - Zero CRITICAL violations - Zero HIGH violations - MODERATE violations have a documented plan or justification -- All automated checks pass (tests, linting) +- All automated checks pass + - defined tests + - defined lints including the dart analyzer with no warnings or errors - Test coverage ≥ 70% for business logic - Design system fully compliant - Architecture boundaries fully respected diff --git a/.gitignore b/.gitignore index 6face2b0..eb271963 100644 --- a/.gitignore +++ b/.gitignore @@ -187,6 +187,9 @@ krow-workforce-export-latest/ apps/mobile/packages/data_connect/lib/src/dataconnect_generated/ apps/web/src/dataconnect-generated/ +# Legacy mobile applications +apps/mobile/legacy/* + AGENTS.md TASKS.md diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 9be43245..7178240d 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -325,6 +325,8 @@ "client_create_order": { "title": "Create Order", "section_title": "ORDER TYPE", + "no_vendors_title": "No Vendors Available", + "no_vendors_description": "There are no staffing vendors associated with your account.", "types": { "rapid": "RAPID", "rapid_desc": "URGENT same-day Coverage", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 9f99b499..5fce4a09 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -325,6 +325,8 @@ "client_create_order": { "title": "Crear Orden", "section_title": "TIPO DE ORDEN", + "no_vendors_title": "No Hay Proveedores Disponibles", + "no_vendors_description": "No hay proveedores de personal asociados con su cuenta.", "types": { "rapid": "R\u00c1PIDO", "rapid_desc": "Cobertura URGENTE mismo d\u00eda", 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..c8478cfc --- /dev/null +++ b/apps/mobile/packages/design_system/lib/src/widgets/shimmer/ui_shimmer_presets.dart @@ -0,0 +1,122 @@ +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 const Padding( + padding: EdgeInsets.symmetric( + vertical: UiConstants.space2, + ), + child: Row( + spacing: UiConstants.space3, + children: [ + UiShimmerCircle(size: UiConstants.space10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: UiConstants.space2, + children: [ + UiShimmerLine(width: 160), + UiShimmerLine(width: 100, height: 12), + ], + ), + ), + 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: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerCircle(size: UiConstants.space8), + SizedBox(height: UiConstants.space3), + UiShimmerLine(width: 80, height: 12), + SizedBox(height: UiConstants.space2), + 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 double gap = spacing ?? UiConstants.space3; + return Column( + children: List.generate(itemCount, (int 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/lib/src/widgets/ui_notice_banner.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart index 445e8141..430d163d 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_notice_banner.dart @@ -1,8 +1,6 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import '../ui_constants.dart'; - /// A customizable notice banner widget for displaying informational messages. /// /// [UiNoticeBanner] displays a message with an optional icon and supports 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..398b9434 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton.dart @@ -0,0 +1 @@ +export 'billing_page_skeleton/index.dart'; diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/billing_page_skeleton.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/billing_page_skeleton.dart new file mode 100644 index 00000000..e4d41037 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/billing_page_skeleton.dart @@ -0,0 +1,67 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'breakdown_row_skeleton.dart'; +import 'invoice_card_skeleton.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(), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/breakdown_row_skeleton.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/breakdown_row_skeleton.dart new file mode 100644 index 00000000..978b5f38 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/breakdown_row_skeleton.dart @@ -0,0 +1,19 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a spending breakdown row. +class BreakdownRowSkeleton extends StatelessWidget { + /// Creates a [BreakdownRowSkeleton]. + const BreakdownRowSkeleton({super.key}); + + @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/billing_page_skeleton/index.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/index.dart new file mode 100644 index 00000000..d803d599 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/index.dart @@ -0,0 +1,3 @@ +export 'billing_page_skeleton.dart'; +export 'breakdown_row_skeleton.dart'; +export 'invoice_card_skeleton.dart'; diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/invoice_card_skeleton.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/invoice_card_skeleton.dart new file mode 100644 index 00000000..e86811db --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/billing_page_skeleton/invoice_card_skeleton.dart @@ -0,0 +1,58 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single pending invoice card. +class InvoiceCardSkeleton extends StatelessWidget { + /// Creates an [InvoiceCardSkeleton]. + const InvoiceCardSkeleton({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, + ), + 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, + ), + ], + ), + ], + ), + ); + } +} 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..04c499bf --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton.dart @@ -0,0 +1 @@ +export 'coverage_page_skeleton/index.dart'; diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/coverage_page_skeleton.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/coverage_page_skeleton.dart new file mode 100644 index 00000000..bfb12d31 --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/coverage_page_skeleton.dart @@ -0,0 +1,47 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'shift_card_skeleton.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(), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/index.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/index.dart new file mode 100644 index 00000000..ddac4e8b --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/index.dart @@ -0,0 +1,2 @@ +export 'coverage_page_skeleton.dart'; +export 'shift_card_skeleton.dart'; diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/shift_card_skeleton.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/shift_card_skeleton.dart new file mode 100644 index 00000000..c74212cd --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_page_skeleton/shift_card_skeleton.dart @@ -0,0 +1,60 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single shift card with header and worker rows. +class ShiftCardSkeleton extends StatelessWidget { + /// Creates a [ShiftCardSkeleton]. + const ShiftCardSkeleton({super.key}); + + @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/home/lib/src/presentation/widgets/client_home_body.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_body.dart index 06e65c95..9b39ec2f 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_body.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_body.dart @@ -8,10 +8,12 @@ import '../blocs/client_home_state.dart'; import 'client_home_edit_mode_body.dart'; import 'client_home_error_state.dart'; import 'client_home_normal_mode_body.dart'; +import 'client_home_page_skeleton.dart'; /// Main body widget for the client home page. /// -/// Manages the state transitions between error, edit mode, and normal mode views. +/// Manages the state transitions between loading, error, edit mode, +/// and normal mode views. class ClientHomeBody extends StatelessWidget { /// Creates a [ClientHomeBody]. const ClientHomeBody({super.key}); @@ -31,6 +33,10 @@ class ClientHomeBody extends StatelessWidget { } }, builder: (BuildContext context, ClientHomeState state) { + if (state.status == ClientHomeStatus.initial || + state.status == ClientHomeStatus.loading) { + return const ClientHomePageSkeleton(); + } if (state.status == ClientHomeStatus.error) { return ClientHomeErrorState(state: state); } diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart index bcfe0d31..0a1f4489 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart @@ -22,8 +22,15 @@ class ClientHomeEditBanner extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder( - buildWhen: (ClientHomeState prev, ClientHomeState curr) => prev.isEditMode != curr.isEditMode, + buildWhen: (ClientHomeState prev, ClientHomeState curr) => + prev.isEditMode != curr.isEditMode || + prev.status != curr.status, builder: (BuildContext context, ClientHomeState state) { + if (state.status == ClientHomeStatus.initial || + state.status == ClientHomeStatus.loading) { + return const SizedBox.shrink(); + } + return AnimatedContainer( duration: const Duration(milliseconds: 300), height: state.isEditMode ? 80 : 0, diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header.dart index aebf6e36..9d311d2f 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header.dart @@ -7,6 +7,7 @@ import '../blocs/client_home_bloc.dart'; import '../blocs/client_home_event.dart'; import '../blocs/client_home_state.dart'; import 'header_icon_button.dart'; +import 'client_home_header_skeleton.dart'; /// The header section of the client home page. /// @@ -26,6 +27,11 @@ class ClientHomeHeader extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (BuildContext context, ClientHomeState state) { + if (state.status == ClientHomeStatus.initial || + state.status == ClientHomeStatus.loading) { + return const ClientHomeHeaderSkeleton(); + } + final String businessName = state.businessName; final String? photoUrl = state.photoUrl; final String avatarLetter = businessName.trim().isNotEmpty diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header_skeleton.dart new file mode 100644 index 00000000..2e186863 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_header_skeleton.dart @@ -0,0 +1,50 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the client home header during loading. +/// +/// Mimics the avatar, welcome text, business name, and action buttons. +class ClientHomeHeaderSkeleton extends StatelessWidget { + /// Creates a [ClientHomeHeaderSkeleton]. + const ClientHomeHeaderSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: Padding( + padding: const EdgeInsets.fromLTRB( + UiConstants.space4, + UiConstants.space4, + UiConstants.space4, + UiConstants.space3, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const UiShimmerCircle(size: UiConstants.space10), + const SizedBox(width: UiConstants.space3), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerLine(width: 80, height: 12), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 120, height: 16), + ], + ), + ], + ), + Row( + spacing: UiConstants.space2, + children: const [ + UiShimmerBox(width: 36, height: 36), + UiShimmerBox(width: 36, height: 36), + ], + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton.dart new file mode 100644 index 00000000..c293fca1 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton.dart @@ -0,0 +1,10 @@ +export 'client_home_page_skeleton/action_card_skeleton.dart'; +export 'client_home_page_skeleton/actions_section_skeleton.dart'; +export 'client_home_page_skeleton/client_home_page_skeleton.dart'; +export 'client_home_page_skeleton/coverage_section_skeleton.dart'; +export 'client_home_page_skeleton/live_activity_section_skeleton.dart'; +export 'client_home_page_skeleton/metric_card_skeleton.dart'; +export 'client_home_page_skeleton/reorder_card_skeleton.dart'; +export 'client_home_page_skeleton/reorder_section_skeleton.dart'; +export 'client_home_page_skeleton/spending_card_skeleton.dart'; +export 'client_home_page_skeleton/spending_section_skeleton.dart'; diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/action_card_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/action_card_skeleton.dart new file mode 100644 index 00000000..dd4c0668 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/action_card_skeleton.dart @@ -0,0 +1,28 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Skeleton for a single action card with icon, title, and subtitle. +class ActionCardSkeleton extends StatelessWidget { + /// Creates an [ActionCardSkeleton]. + const ActionCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border, width: 0.5), + borderRadius: UiConstants.radiusLg, + ), + child: const Column( + children: [ + UiShimmerBox(width: 36, height: 36), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 60, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 100, height: 10), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/actions_section_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/actions_section_skeleton.dart new file mode 100644 index 00000000..4aafa370 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/actions_section_skeleton.dart @@ -0,0 +1,28 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'action_card_skeleton.dart'; + +/// Skeleton for the two side-by-side action cards. +class ActionsSectionSkeleton extends StatelessWidget { + /// Creates an [ActionsSectionSkeleton]. + const ActionsSectionSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerSectionHeader(), + SizedBox(height: UiConstants.space2), + Row( + children: [ + Expanded(child: ActionCardSkeleton()), + SizedBox(width: UiConstants.space4), + Expanded(child: ActionCardSkeleton()), + ], + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/client_home_page_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/client_home_page_skeleton.dart new file mode 100644 index 00000000..09cddb61 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/client_home_page_skeleton.dart @@ -0,0 +1,69 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'actions_section_skeleton.dart'; +import 'coverage_section_skeleton.dart'; +import 'live_activity_section_skeleton.dart'; +import 'reorder_section_skeleton.dart'; +import 'spending_section_skeleton.dart'; + +/// Shimmer loading skeleton for the client home page. +/// +/// Mimics the loaded dashboard layout with action cards, reorder cards, +/// coverage metrics, spending card, and live activity sections. +class ClientHomePageSkeleton extends StatelessWidget { + /// Creates a [ClientHomePageSkeleton]. + const ClientHomePageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: ListView( + children: const [ + // Actions section + Padding( + padding: EdgeInsets.symmetric(horizontal: UiConstants.space4), + child: ActionsSectionSkeleton(), + ), + SizedBox(height: UiConstants.space8), + Divider(color: UiColors.border, height: 0.1), + SizedBox(height: UiConstants.space8), + + // Reorder section + Padding( + padding: EdgeInsets.symmetric(horizontal: UiConstants.space4), + child: ReorderSectionSkeleton(), + ), + SizedBox(height: UiConstants.space8), + Divider(color: UiColors.border, height: 0.1), + SizedBox(height: UiConstants.space8), + + // Coverage section + Padding( + padding: EdgeInsets.symmetric(horizontal: UiConstants.space4), + child: CoverageSectionSkeleton(), + ), + SizedBox(height: UiConstants.space8), + Divider(color: UiColors.border, height: 0.1), + SizedBox(height: UiConstants.space8), + + // Spending section + Padding( + padding: EdgeInsets.symmetric(horizontal: UiConstants.space4), + child: SpendingSectionSkeleton(), + ), + SizedBox(height: UiConstants.space8), + Divider(color: UiColors.border, height: 0.1), + SizedBox(height: UiConstants.space8), + + // Live activity section + Padding( + padding: EdgeInsets.symmetric(horizontal: UiConstants.space4), + child: LiveActivitySectionSkeleton(), + ), + SizedBox(height: UiConstants.space8), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/coverage_section_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/coverage_section_skeleton.dart new file mode 100644 index 00000000..628d6489 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/coverage_section_skeleton.dart @@ -0,0 +1,30 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'metric_card_skeleton.dart'; + +/// Skeleton for the coverage metric cards row. +class CoverageSectionSkeleton extends StatelessWidget { + /// Creates a [CoverageSectionSkeleton]. + const CoverageSectionSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerSectionHeader(), + SizedBox(height: UiConstants.space2), + Row( + children: [ + Expanded(child: MetricCardSkeleton()), + SizedBox(width: UiConstants.space2), + Expanded(child: MetricCardSkeleton()), + SizedBox(width: UiConstants.space2), + Expanded(child: MetricCardSkeleton()), + ], + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/live_activity_section_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/live_activity_section_skeleton.dart new file mode 100644 index 00000000..0abe8950 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/live_activity_section_skeleton.dart @@ -0,0 +1,23 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Skeleton for the live activity section. +class LiveActivitySectionSkeleton extends StatelessWidget { + /// Creates a [LiveActivitySectionSkeleton]. + const LiveActivitySectionSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerSectionHeader(), + SizedBox(height: UiConstants.space2), + UiShimmerStatsCard(), + SizedBox(height: UiConstants.space3), + UiShimmerListItem(), + UiShimmerListItem(), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/metric_card_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/metric_card_skeleton.dart new file mode 100644 index 00000000..bb154f8d --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/metric_card_skeleton.dart @@ -0,0 +1,33 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Skeleton for a single coverage metric card. +class MetricCardSkeleton extends StatelessWidget { + /// Creates a [MetricCardSkeleton]. + const MetricCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space2), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border, width: 0.5), + borderRadius: UiConstants.radiusLg, + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + UiShimmerCircle(size: 14), + SizedBox(width: UiConstants.space1), + UiShimmerLine(width: 40, height: 10), + ], + ), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 32, height: 20), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/reorder_card_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/reorder_card_skeleton.dart new file mode 100644 index 00000000..c5550a68 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/reorder_card_skeleton.dart @@ -0,0 +1,62 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Skeleton for a single reorder card. +class ReorderCardSkeleton extends StatelessWidget { + /// Creates a [ReorderCardSkeleton]. + const ReorderCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border, width: 0.6), + borderRadius: UiConstants.radiusLg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + UiShimmerBox(width: 36, height: 36), + SizedBox(width: UiConstants.space2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 100, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 80, height: 10), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + UiShimmerLine(width: 40, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 60, height: 10), + ], + ), + ], + ), + const SizedBox(height: UiConstants.space3), + const Row( + children: [ + UiShimmerBox(width: 60, height: 22), + SizedBox(width: UiConstants.space2), + UiShimmerBox(width: 36, height: 22), + ], + ), + const Spacer(), + UiShimmerBox( + width: double.infinity, + height: 32, + borderRadius: UiConstants.radiusLg, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/reorder_section_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/reorder_section_skeleton.dart new file mode 100644 index 00000000..783fc2b0 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/reorder_section_skeleton.dart @@ -0,0 +1,31 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'reorder_card_skeleton.dart'; + +/// Skeleton for the horizontal reorder cards list. +class ReorderSectionSkeleton extends StatelessWidget { + /// Creates a [ReorderSectionSkeleton]. + const ReorderSectionSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerSectionHeader(), + SizedBox(height: UiConstants.space2), + SizedBox( + height: 164, + child: Row( + children: [ + Expanded(child: ReorderCardSkeleton()), + SizedBox(width: UiConstants.space3), + Expanded(child: ReorderCardSkeleton()), + ], + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/spending_card_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/spending_card_skeleton.dart new file mode 100644 index 00000000..dee41bff --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/spending_card_skeleton.dart @@ -0,0 +1,47 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Skeleton mimicking the spending card layout. +class SpendingCardSkeleton extends StatelessWidget { + /// Creates a [SpendingCardSkeleton]. + const SpendingCardSkeleton({super.key}); + + @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: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 60, height: 10), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 80, height: 22), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 50, height: 10), + ], + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + UiShimmerLine(width: 60, height: 10), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 70, height: 18), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 50, height: 10), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/spending_section_skeleton.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/spending_section_skeleton.dart new file mode 100644 index 00000000..c46a7e2a --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_page_skeleton/spending_section_skeleton.dart @@ -0,0 +1,22 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'spending_card_skeleton.dart'; + +/// Skeleton for the spending gradient card. +class SpendingSectionSkeleton extends StatelessWidget { + /// Creates a [SpendingSectionSkeleton]. + const SpendingSectionSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerSectionHeader(), + SizedBox(height: UiConstants.space2), + SpendingCardSkeleton(), + ], + ); + } +} 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/orders/create_order/lib/src/create_order_module.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart index b5491474..8afdfcb2 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/create_order_module.dart @@ -79,7 +79,7 @@ class ClientCreateOrderModule extends Module { @override void routes(RouteManager r) { r.child( - '/', + ClientPaths.childRoute(ClientPaths.createOrder, ClientPaths.createOrder), child: (BuildContext context) => const ClientCreateOrderPage(), ); r.child( diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart index 8ebfb27c..1f4ceb17 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart @@ -149,7 +149,11 @@ class OneTimeOrderBloc extends Bloc ? event.vendors.first : null; emit( - state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor), + state.copyWith( + vendors: event.vendors, + selectedVendor: selectedVendor, + isDataLoaded: true, + ), ); if (selectedVendor != null) { await _loadRolesForVendor(selectedVendor.id, emit); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart index 3a504e25..b8e3201b 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart @@ -21,6 +21,7 @@ class OneTimeOrderState extends Equatable { this.managers = const [], this.selectedManager, this.isRapidDraft = false, + this.isDataLoaded = false, }); factory OneTimeOrderState.initial() { @@ -52,6 +53,9 @@ class OneTimeOrderState extends Equatable { final OneTimeOrderManagerOption? selectedManager; final bool isRapidDraft; + /// Whether initial data (vendors, hubs) has been fetched from the backend. + final bool isDataLoaded; + OneTimeOrderState copyWith({ DateTime? date, String? location, @@ -67,6 +71,7 @@ class OneTimeOrderState extends Equatable { List? managers, OneTimeOrderManagerOption? selectedManager, bool? isRapidDraft, + bool? isDataLoaded, }) { return OneTimeOrderState( date: date ?? this.date, @@ -83,6 +88,7 @@ class OneTimeOrderState extends Equatable { managers: managers ?? this.managers, selectedManager: selectedManager ?? this.selectedManager, isRapidDraft: isRapidDraft ?? this.isRapidDraft, + isDataLoaded: isDataLoaded ?? this.isDataLoaded, ); } @@ -187,6 +193,7 @@ class OneTimeOrderState extends Equatable { managers, selectedManager, isRapidDraft, + isDataLoaded, ]; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart index 1f43713a..928d248c 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart @@ -136,7 +136,11 @@ class PermanentOrderBloc extends Bloc ? event.vendors.first : null; emit( - state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor), + state.copyWith( + vendors: event.vendors, + selectedVendor: selectedVendor, + isDataLoaded: true, + ), ); if (selectedVendor != null) { await _loadRolesForVendor(selectedVendor.id, emit); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart index c024994b..0ffea2ff 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart @@ -21,6 +21,7 @@ class PermanentOrderState extends Equatable { this.roles = const [], this.managers = const [], this.selectedManager, + this.isDataLoaded = false, }); factory PermanentOrderState.initial() { @@ -68,6 +69,9 @@ class PermanentOrderState extends Equatable { final List managers; final PermanentOrderManagerOption? selectedManager; + /// Whether initial data (vendors, hubs) has been fetched from the backend. + final bool isDataLoaded; + PermanentOrderState copyWith({ DateTime? startDate, List? permanentDays, @@ -84,6 +88,7 @@ class PermanentOrderState extends Equatable { List? roles, List? managers, PermanentOrderManagerOption? selectedManager, + bool? isDataLoaded, }) { return PermanentOrderState( startDate: startDate ?? this.startDate, @@ -101,6 +106,7 @@ class PermanentOrderState extends Equatable { roles: roles ?? this.roles, managers: managers ?? this.managers, selectedManager: selectedManager ?? this.selectedManager, + isDataLoaded: isDataLoaded ?? this.isDataLoaded, ); } @@ -186,6 +192,7 @@ class PermanentOrderState extends Equatable { roles, managers, selectedManager, + isDataLoaded, ]; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart index 37e4f5cf..972db182 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart @@ -149,7 +149,11 @@ class RecurringOrderBloc extends Bloc ? event.vendors.first : null; emit( - state.copyWith(vendors: event.vendors, selectedVendor: selectedVendor), + state.copyWith( + vendors: event.vendors, + selectedVendor: selectedVendor, + isDataLoaded: true, + ), ); if (selectedVendor != null) { await _loadRolesForVendor(selectedVendor.id, emit); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart index 522a9c35..fc9706b7 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart @@ -23,6 +23,7 @@ class RecurringOrderState extends Equatable { this.roles = const [], this.managers = const [], this.selectedManager, + this.isDataLoaded = false, }); factory RecurringOrderState.initial() { @@ -72,6 +73,9 @@ class RecurringOrderState extends Equatable { final List managers; final RecurringOrderManagerOption? selectedManager; + /// Whether initial data (vendors, hubs) has been fetched from the backend. + final bool isDataLoaded; + RecurringOrderState copyWith({ DateTime? startDate, DateTime? endDate, @@ -89,6 +93,7 @@ class RecurringOrderState extends Equatable { List? roles, List? managers, RecurringOrderManagerOption? selectedManager, + bool? isDataLoaded, }) { return RecurringOrderState( startDate: startDate ?? this.startDate, @@ -107,6 +112,7 @@ class RecurringOrderState extends Equatable { roles: roles ?? this.roles, managers: managers ?? this.managers, selectedManager: selectedManager ?? this.selectedManager, + isDataLoaded: isDataLoaded ?? this.isDataLoaded, ); } @@ -214,6 +220,7 @@ class RecurringOrderState extends Equatable { roles, managers, selectedManager, + isDataLoaded, ]; } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart index 8e272bb9..e77caf39 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart @@ -44,6 +44,7 @@ class OneTimeOrderPage extends StatelessWidget { ); return OneTimeOrderView( + isDataLoaded: state.isDataLoaded, status: _mapStatus(state.status), errorMessage: state.errorMessage, eventName: state.eventName, diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart index 331c76b6..c018bfe9 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart @@ -44,6 +44,7 @@ class PermanentOrderPage extends StatelessWidget { ); return PermanentOrderView( + isDataLoaded: state.isDataLoaded, status: _mapStatus(state.status), errorMessage: state.errorMessage, eventName: state.eventName, diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart index c092b12e..0da250ed 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart @@ -43,6 +43,7 @@ class RecurringOrderPage extends StatelessWidget { ); return RecurringOrderView( + isDataLoaded: state.isDataLoaded, status: _mapStatus(state.status), errorMessage: state.errorMessage, eventName: state.eventName, diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart index cec30ce5..28fe45ee 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/client_orders_common.dart @@ -3,6 +3,7 @@ export 'src/presentation/widgets/order_ui_models.dart'; // Shared Widgets export 'src/presentation/widgets/order_bottom_action_button.dart'; +export 'src/presentation/widgets/order_form_skeleton.dart'; // One Time Order Widgets export 'src/presentation/widgets/one_time_order/one_time_order_date_picker.dart'; diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/hub_manager_selector.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/hub_manager_selector.dart index f6d05571..4dfd6b0f 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/hub_manager_selector.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/hub_manager_selector.dart @@ -32,7 +32,7 @@ class HubManagerSelector extends StatelessWidget { children: [ Text( label, - style: UiTypography.body1m.textPrimary, + style: UiTypography.body1r, ), if (description != null) ...[ Text(description!, style: UiTypography.body2r.textSecondary), diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart index 3f2050f5..3e66e2fa 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart'; import '../order_bottom_action_button.dart'; +import '../order_form_skeleton.dart'; import '../order_ui_models.dart'; import 'one_time_order_form.dart'; import 'one_time_order_success_view.dart'; @@ -37,6 +38,7 @@ class OneTimeOrderView extends StatelessWidget { required this.onBack, this.title, this.subtitle, + this.isDataLoaded = true, super.key, }); @@ -56,6 +58,9 @@ class OneTimeOrderView extends StatelessWidget { final String? title; final String? subtitle; + /// Whether initial data (vendors, hubs) has been fetched from the backend. + final bool isDataLoaded; + final ValueChanged onEventNameChanged; final ValueChanged onVendorChanged; final ValueChanged onDateChanged; @@ -81,7 +86,12 @@ class OneTimeOrderView extends StatelessWidget { context, message: translateErrorKey(errorMessage!), type: UiSnackbarType.error, - margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16), + // bottom: 140 clears the bottom navigation bar area + margin: const EdgeInsets.only( + bottom: 140, + left: UiConstants.space4, + right: UiConstants.space4, + ), ); }); } @@ -111,6 +121,10 @@ class OneTimeOrderView extends StatelessWidget { BuildContext context, TranslationsClientCreateOrderOneTimeEn labels, ) { + if (!isDataLoaded) { + return const OrderFormSkeleton(); + } + if (vendors.isEmpty && status != OrderFormStatus.loading) { return Column( children: [ @@ -126,12 +140,12 @@ class OneTimeOrderView extends StatelessWidget { ), const SizedBox(height: UiConstants.space4), Text( - 'No Vendors Available', + t.client_create_order.no_vendors_title, style: UiTypography.headline3m.textPrimary, ), const SizedBox(height: UiConstants.space2), Text( - 'There are no staffing vendors associated with your account.', + t.client_create_order.no_vendors_description, style: UiTypography.body2r.textSecondary, textAlign: TextAlign.center, ), diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_form_skeleton.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_form_skeleton.dart new file mode 100644 index 00000000..291fcf59 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_form_skeleton.dart @@ -0,0 +1,144 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer skeleton that mimics the order creation form layout. +/// +/// Displayed while initial data (vendors, hubs, roles) is being fetched. +/// Renders placeholder shapes for the text input, dropdowns, date picker, +/// hub manager section, and one position card. +class OrderFormSkeleton extends StatelessWidget { + /// Creates an [OrderFormSkeleton]. + const OrderFormSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: ListView( + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.all(UiConstants.space5), + children: [ + _buildLabelPlaceholder(), + const SizedBox(height: UiConstants.space2), + _buildTextFieldPlaceholder(), + const SizedBox(height: UiConstants.space4), + _buildLabelPlaceholder(), + const SizedBox(height: UiConstants.space2), + _buildDropdownPlaceholder(), + const SizedBox(height: UiConstants.space4), + _buildLabelPlaceholder(), + const SizedBox(height: UiConstants.space2), + _buildDropdownPlaceholder(), + const SizedBox(height: UiConstants.space4), + _buildLabelPlaceholder(), + const SizedBox(height: UiConstants.space2), + _buildDropdownPlaceholder(), + const SizedBox(height: UiConstants.space4), + _buildHubManagerPlaceholder(), + const SizedBox(height: UiConstants.space6), + _buildSectionHeaderPlaceholder(), + const SizedBox(height: UiConstants.space3), + _buildPositionCardPlaceholder(), + ], + ), + ); + } + + /// Small label placeholder above each field. + Widget _buildLabelPlaceholder() { + return const Align( + alignment: Alignment.centerLeft, + child: UiShimmerLine(width: 100, height: 12), + ); + } + + /// Full-width text input placeholder. + Widget _buildTextFieldPlaceholder() { + return const UiShimmerBox(width: double.infinity, height: 48); + } + + /// Full-width dropdown selector placeholder. + Widget _buildDropdownPlaceholder() { + return const UiShimmerBox(width: double.infinity, height: 48); + } + + /// Hub manager section with label and description lines. + Widget _buildHubManagerPlaceholder() { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 140, height: 12), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 220, height: 10), + SizedBox(height: UiConstants.space2), + UiShimmerBox(width: double.infinity, height: 48), + ], + ); + } + + /// Section header placeholder with title and action button. + Widget _buildSectionHeaderPlaceholder() { + return const Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + UiShimmerLine(width: 100, height: 16), + UiShimmerBox(width: 90, height: 28), + ], + ); + } + + /// Position card placeholder mimicking role, worker count, and time fields. + Widget _buildPositionCardPlaceholder() { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + UiShimmerLine(width: 80, height: 14), + UiShimmerCircle(size: 24), + ], + ), + const SizedBox(height: UiConstants.space3), + const UiShimmerBox(width: double.infinity, height: 44), + const SizedBox(height: UiConstants.space3), + const UiShimmerLine(width: 60, height: 12), + const SizedBox(height: UiConstants.space2), + const UiShimmerBox(width: double.infinity, height: 44), + const SizedBox(height: UiConstants.space3), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerLine(width: 50, height: 12), + SizedBox(height: UiConstants.space2), + UiShimmerBox(width: double.infinity, height: 44), + ], + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerLine(width: 50, height: 12), + SizedBox(height: UiConstants.space2), + UiShimmerBox(width: double.infinity, height: 44), + ], + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_form.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_form.dart index a9185ce3..36d7ba08 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_form.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_form.dart @@ -105,20 +105,12 @@ class PermanentOrderForm extends StatelessWidget { @override Widget build(BuildContext context) { - final TranslationsClientCreateOrderPermanentEn labels = - t.client_create_order.permanent; final TranslationsClientCreateOrderOneTimeEn oneTimeLabels = t.client_create_order.one_time; return ListView( padding: const EdgeInsets.all(UiConstants.space5), children: [ - Text( - labels.title, - style: UiTypography.headline3m.textPrimary, - ), - const SizedBox(height: UiConstants.space4), - PermanentOrderEventNameInput( label: 'ORDER NAME', value: eventName, diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart index 8c1bbf80..5a253eb0 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart' show Vendor; import '../order_bottom_action_button.dart'; +import '../order_form_skeleton.dart'; import '../order_ui_models.dart'; import 'permanent_order_form.dart'; import 'permanent_order_success_view.dart'; @@ -37,9 +38,12 @@ class PermanentOrderView extends StatelessWidget { required this.onSubmit, required this.onDone, required this.onBack, + this.isDataLoaded = true, super.key, }); + /// Whether initial data (vendors, hubs) has been fetched from the backend. + final bool isDataLoaded; final OrderFormStatus status; final String? errorMessage; final String eventName; @@ -82,7 +86,12 @@ class PermanentOrderView extends StatelessWidget { context, message: translateErrorKey(errorMessage!), type: UiSnackbarType.error, - margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16), + // bottom: 140 clears the bottom navigation bar area + margin: const EdgeInsets.only( + bottom: 140, + left: UiConstants.space4, + right: UiConstants.space4, + ), ); }); } @@ -113,6 +122,10 @@ class PermanentOrderView extends StatelessWidget { TranslationsClientCreateOrderPermanentEn labels, TranslationsClientCreateOrderOneTimeEn oneTimeLabels, ) { + if (!isDataLoaded) { + return const OrderFormSkeleton(); + } + if (vendors.isEmpty && status != OrderFormStatus.loading) { return Column( children: [ @@ -128,12 +141,12 @@ class PermanentOrderView extends StatelessWidget { ), const SizedBox(height: UiConstants.space4), Text( - 'No Vendors Available', + t.client_create_order.no_vendors_title, style: UiTypography.headline3m.textPrimary, ), const SizedBox(height: UiConstants.space2), Text( - 'There are no staffing vendors associated with your account.', + t.client_create_order.no_vendors_description, style: UiTypography.body2r.textSecondary, textAlign: TextAlign.center, ), diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_form.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_form.dart index 7a0421d9..2bc274bc 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_form.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_form.dart @@ -100,7 +100,7 @@ class RecurringOrderForm extends StatelessWidget { /// Called when a position at [index] is updated with new values. final void Function(int index, OrderPositionUiModel position) - onPositionUpdated; + onPositionUpdated; /// Called when a position at [index] is removed. final void Function(int index) onPositionRemoved; @@ -113,20 +113,12 @@ class RecurringOrderForm extends StatelessWidget { @override Widget build(BuildContext context) { - final TranslationsClientCreateOrderRecurringEn labels = - t.client_create_order.recurring; final TranslationsClientCreateOrderOneTimeEn oneTimeLabels = t.client_create_order.one_time; return ListView( padding: const EdgeInsets.all(UiConstants.space5), children: [ - Text( - labels.title, - style: UiTypography.headline3m.textPrimary, - ), - const SizedBox(height: UiConstants.space4), - RecurringOrderEventNameInput( label: 'ORDER NAME', value: eventName, @@ -222,16 +214,13 @@ class RecurringOrderForm extends StatelessWidget { items: hubs.map((OrderHubUiModel hub) { return DropdownMenuItem( value: hub, - child: Text( - hub.name, - style: UiTypography.body2m.textPrimary, - ), + child: Text(hub.name, style: UiTypography.body2m.textPrimary), ); }).toList(), ), ), ), - const SizedBox(height: UiConstants.space4), + const SizedBox(height: UiConstants.space6), HubManagerSelector( label: oneTimeLabels.hub_manager_label, diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart index ffd3ad51..d5d2e469 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart' show Vendor; import '../order_bottom_action_button.dart'; +import '../order_form_skeleton.dart'; import '../order_ui_models.dart'; import 'recurring_order_form.dart'; import 'recurring_order_success_view.dart'; @@ -39,9 +40,12 @@ class RecurringOrderView extends StatelessWidget { required this.onSubmit, required this.onDone, required this.onBack, + this.isDataLoaded = true, super.key, }); + /// Whether initial data (vendors, hubs) has been fetched from the backend. + final bool isDataLoaded; final OrderFormStatus status; final String? errorMessage; final String eventName; @@ -89,7 +93,12 @@ class RecurringOrderView extends StatelessWidget { context, message: message, type: UiSnackbarType.error, - margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16), + // bottom: 140 clears the bottom navigation bar area + margin: const EdgeInsets.only( + bottom: 140, + left: UiConstants.space4, + right: UiConstants.space4, + ), ); }); } @@ -120,6 +129,10 @@ class RecurringOrderView extends StatelessWidget { TranslationsClientCreateOrderRecurringEn labels, TranslationsClientCreateOrderOneTimeEn oneTimeLabels, ) { + if (!isDataLoaded) { + return const OrderFormSkeleton(); + } + if (vendors.isEmpty && status != OrderFormStatus.loading) { return Column( children: [ @@ -135,12 +148,12 @@ class RecurringOrderView extends StatelessWidget { ), const SizedBox(height: UiConstants.space4), Text( - 'No Vendors Available', + t.client_create_order.no_vendors_title, style: UiTypography.headline3m.textPrimary, ), const SizedBox(height: UiConstants.space2), Text( - 'There are no staffing vendors associated with your account.', + t.client_create_order.no_vendors_description, style: UiTypography.body2r.textSecondary, textAlign: TextAlign.center, ), diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/pages/view_orders_page.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/pages/view_orders_page.dart index 6c0a8923..32e317e7 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/pages/view_orders_page.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/pages/view_orders_page.dart @@ -11,6 +11,7 @@ import '../widgets/view_orders_header.dart'; import '../widgets/view_orders_empty_state.dart'; import '../widgets/view_orders_error_state.dart'; import '../widgets/view_orders_list.dart'; +import '../widgets/view_orders_page_skeleton.dart'; /// The main page for viewing client orders. /// @@ -101,20 +102,26 @@ class _ViewOrdersViewState extends State { // Content List Expanded( - child: state.status == ViewOrdersStatus.failure - ? ViewOrdersErrorState( - errorMessage: state.errorMessage, - selectedDate: state.selectedDate, - onRetry: () => BlocProvider.of( - context, - ).jumpToDate(state.selectedDate ?? DateTime.now()), - ) - : filteredOrders.isEmpty - ? ViewOrdersEmptyState(selectedDate: state.selectedDate) - : ViewOrdersList( - orders: filteredOrders, - filterTab: state.filterTab, - ), + child: switch (state.status) { + ViewOrdersStatus.loading || + ViewOrdersStatus.initial => + const ViewOrdersPageSkeleton(), + ViewOrdersStatus.failure => ViewOrdersErrorState( + errorMessage: state.errorMessage, + selectedDate: state.selectedDate, + onRetry: () => BlocProvider.of( + context, + ).jumpToDate(state.selectedDate ?? DateTime.now()), + ), + ViewOrdersStatus.success => filteredOrders.isEmpty + ? ViewOrdersEmptyState( + selectedDate: state.selectedDate, + ) + : ViewOrdersList( + orders: filteredOrders, + filterTab: state.filterTab, + ), + }, ), ], ), diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton.dart new file mode 100644 index 00000000..66f9a6da --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton.dart @@ -0,0 +1 @@ +export 'view_orders_page_skeleton/index.dart'; diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/index.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/index.dart new file mode 100644 index 00000000..d64c5a98 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/index.dart @@ -0,0 +1,5 @@ +export 'order_card_skeleton.dart'; +export 'section_header_skeleton.dart'; +export 'stat_divider_skeleton.dart'; +export 'stat_item_skeleton.dart'; +export 'view_orders_page_skeleton.dart'; diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/order_card_skeleton.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/order_card_skeleton.dart new file mode 100644 index 00000000..8f1cf480 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/order_card_skeleton.dart @@ -0,0 +1,127 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'stat_divider_skeleton.dart'; +import 'stat_item_skeleton.dart'; + +/// Shimmer placeholder for a single order card. +class OrderCardSkeleton extends StatelessWidget { + /// Creates an [OrderCardSkeleton]. + const OrderCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + border: Border.all(color: UiColors.border, width: 0.5), + borderRadius: UiConstants.radiusLg, + ), + child: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Status and type badges + Row( + children: [ + UiShimmerBox( + width: 80, + height: 22, + borderRadius: UiConstants.radiusSm, + ), + const SizedBox(width: UiConstants.space2), + UiShimmerBox( + width: 72, + height: 22, + borderRadius: UiConstants.radiusSm, + ), + ], + ), + const SizedBox(height: UiConstants.space3), + + // Title line + const UiShimmerLine(width: 200, height: 18), + const SizedBox(height: UiConstants.space2), + + // Event name line + const UiShimmerLine(width: 160, height: 14), + const SizedBox(height: UiConstants.space4), + + // Location lines + const Row( + children: [ + UiShimmerCircle(size: 14), + SizedBox(width: UiConstants.space2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 180, height: 12), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 140, height: 10), + ], + ), + ), + ], + ), + + const SizedBox(height: UiConstants.space4), + const Divider(height: 1, color: UiColors.border), + const SizedBox(height: UiConstants.space4), + + // Stats row (cost / hours / workers) + const Padding( + padding: EdgeInsets.symmetric( + horizontal: UiConstants.space4, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + StatItemSkeleton(), + StatDividerSkeleton(), + StatItemSkeleton(), + StatDividerSkeleton(), + StatItemSkeleton(), + ], + ), + ), + + const SizedBox(height: UiConstants.space5), + + // Time boxes (clock in / clock out) + Row( + children: [ + Expanded(child: _timeBoxSkeleton()), + const SizedBox(width: UiConstants.space3), + Expanded(child: _timeBoxSkeleton()), + ], + ), + + const SizedBox(height: UiConstants.space4), + + // Coverage progress bar + const UiShimmerLine(height: 8), + ], + ), + ), + ); + } + + /// Builds a placeholder for a time display box (clock-in / clock-out). + Widget _timeBoxSkeleton() { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border, width: 0.5), + borderRadius: UiConstants.radiusLg, + ), + child: const Column( + children: [ + UiShimmerLine(width: 60, height: 10), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 80, height: 16), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/section_header_skeleton.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/section_header_skeleton.dart new file mode 100644 index 00000000..491b0c60 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/section_header_skeleton.dart @@ -0,0 +1,24 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the section header row (dot + title + count). +class SectionHeaderSkeleton extends StatelessWidget { + /// Creates a [SectionHeaderSkeleton]. + const SectionHeaderSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.only(bottom: UiConstants.space3), + child: Row( + children: [ + UiShimmerCircle(size: 8), + SizedBox(width: UiConstants.space2), + UiShimmerLine(width: 100, height: 14), + SizedBox(width: UiConstants.space1), + UiShimmerLine(width: 24, height: 14), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/stat_divider_skeleton.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/stat_divider_skeleton.dart new file mode 100644 index 00000000..b7b0878d --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/stat_divider_skeleton.dart @@ -0,0 +1,17 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the vertical stat divider. +class StatDividerSkeleton extends StatelessWidget { + /// Creates a [StatDividerSkeleton]. + const StatDividerSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const UiShimmerBox( + width: 1, + height: 24, + borderRadius: BorderRadius.zero, + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/stat_item_skeleton.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/stat_item_skeleton.dart new file mode 100644 index 00000000..85cbe602 --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/stat_item_skeleton.dart @@ -0,0 +1,20 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single stat item (icon + value + label). +class StatItemSkeleton extends StatelessWidget { + /// Creates a [StatItemSkeleton]. + const StatItemSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Column( + spacing: UiConstants.space1, + children: [ + UiShimmerCircle(size: 14), + UiShimmerLine(width: 32, height: 16), + UiShimmerLine(width: 40, height: 10), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/view_orders_page_skeleton.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/view_orders_page_skeleton.dart new file mode 100644 index 00000000..87f45b7d --- /dev/null +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_orders_page_skeleton/view_orders_page_skeleton.dart @@ -0,0 +1,41 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'order_card_skeleton.dart'; +import 'section_header_skeleton.dart'; + +/// Shimmer loading skeleton for the View Orders page. +/// +/// Mimics the loaded layout: a section header followed by a list of order +/// card placeholders, each containing badge, title, location, stats, time +/// boxes, and a coverage progress bar. +class ViewOrdersPageSkeleton extends StatelessWidget { + /// Creates a [ViewOrdersPageSkeleton]. + const ViewOrdersPageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: ListView( + padding: const EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space4, + UiConstants.space5, + // Extra bottom padding for bottom navigation clearance. + UiConstants.space24, + ), + children: [ + // Section header placeholder (dot + title + count) + const SectionHeaderSkeleton(), + // Order card placeholders + ...List.generate(3, (int index) { + return const Padding( + padding: EdgeInsets.only(bottom: UiConstants.space3), + child: OrderCardSkeleton(), + ); + }), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/view_orders_module.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/view_orders_module.dart index 7229767c..ec20567d 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/view_orders_module.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/view_orders_module.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; import 'data/repositories/view_orders_repository_impl.dart'; @@ -33,7 +34,7 @@ class ViewOrdersModule extends Module { @override void routes(RouteManager r) { r.child( - '/', + ClientPaths.childRoute(ClientPaths.orders, ClientPaths.orders), child: (BuildContext context) { final Object? args = Modular.args.data; DateTime? initialDate; 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..0bebed71 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton.dart @@ -0,0 +1 @@ +export 'metrics_grid_skeleton/index.dart'; diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton/index.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton/index.dart new file mode 100644 index 00000000..41c2aebd --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton/index.dart @@ -0,0 +1,2 @@ +export 'metric_card_skeleton.dart'; +export 'metrics_grid_skeleton.dart'; diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton/metric_card_skeleton.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton/metric_card_skeleton.dart new file mode 100644 index 00000000..61d5940d --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton/metric_card_skeleton.dart @@ -0,0 +1,45 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single metric card. +class MetricCardSkeleton extends StatelessWidget { + /// Creates a [MetricCardSkeleton]. + const MetricCardSkeleton({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: [ + // 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/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton/metrics_grid_skeleton.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton/metrics_grid_skeleton.dart new file mode 100644 index 00000000..9181ec7a --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/widgets/reports_page/metrics_grid_skeleton/metrics_grid_skeleton.dart @@ -0,0 +1,31 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'metric_card_skeleton.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(); + }), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart index e7cb7754..7d254a70 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/pages/availability_page.dart @@ -10,6 +10,7 @@ import 'package:krow_domain/krow_domain.dart'; import '../blocs/availability_bloc.dart'; import '../blocs/availability_event.dart'; import '../blocs/availability_state.dart'; +import '../widgets/availability_page_skeleton/availability_page_skeleton.dart'; class AvailabilityPage extends StatefulWidget { const AvailabilityPage({super.key}); @@ -72,7 +73,7 @@ class _AvailabilityPageState extends State { child: BlocBuilder( builder: (context, state) { if (state is AvailabilityLoading) { - return const Center(child: CircularProgressIndicator()); + return const AvailabilityPageSkeleton(); } else if (state is AvailabilityLoaded) { return Stack( children: [ diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton.dart new file mode 100644 index 00000000..59e45024 --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton.dart @@ -0,0 +1 @@ +export 'availability_page_skeleton/index.dart'; diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/availability_page_skeleton.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/availability_page_skeleton.dart new file mode 100644 index 00000000..b4b0bc2b --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/availability_page_skeleton.dart @@ -0,0 +1,36 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'day_availability_skeleton.dart'; +import 'info_card_skeleton.dart'; +import 'quick_set_skeleton.dart'; +import 'week_navigation_skeleton.dart'; + +/// Shimmer loading skeleton for the availability page. +/// +/// Mimics the loaded layout: quick-set buttons, week navigation calendar, +/// selected day detail with time-slot rows, and an info card. +class AvailabilityPageSkeleton extends StatelessWidget { + /// Creates an [AvailabilityPageSkeleton]. + const AvailabilityPageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + child: Column( + spacing: UiConstants.space6, + children: const [ + QuickSetSkeleton(), + WeekNavigationSkeleton(), + DayAvailabilitySkeleton(), + InfoCardSkeleton(), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/day_availability_skeleton.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/day_availability_skeleton.dart new file mode 100644 index 00000000..cdf984f5 --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/day_availability_skeleton.dart @@ -0,0 +1,88 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the selected day detail card (header + time slot rows). +class DayAvailabilitySkeleton extends StatelessWidget { + /// Creates a [DayAvailabilitySkeleton]. + const DayAvailabilitySkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space5), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + ), + child: Column( + children: [ + // Header: date text + toggle placeholder + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerLine(width: 160, height: 16), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 80, height: 12), + ], + ), + UiShimmerBox( + width: 48, + height: 28, + borderRadius: UiConstants.radiusFull, + ), + ], + ), + const SizedBox(height: UiConstants.space4), + // 3 time-slot rows (morning, afternoon, evening) + ..._buildSlotPlaceholders(), + ], + ), + ); + } + + /// Generates 3 time-slot shimmer rows. + List _buildSlotPlaceholders() { + return List.generate(3, (index) { + return Padding( + padding: EdgeInsets.only( + bottom: index < 2 ? UiConstants.space3 : 0, + ), + child: Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + ), + child: Row( + children: [ + // Icon placeholder + UiShimmerBox( + width: 40, + height: 40, + borderRadius: + UiConstants.radiusLg, + ), + const SizedBox(width: UiConstants.space3), + // Text lines + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 80, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 120, height: 12), + ], + ), + ), + // Checkbox circle + const UiShimmerCircle(size: 24), + ], + ), + ), + ); + }); + } +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/index.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/index.dart new file mode 100644 index 00000000..505afb28 --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/index.dart @@ -0,0 +1,5 @@ +export 'availability_page_skeleton.dart'; +export 'day_availability_skeleton.dart'; +export 'info_card_skeleton.dart'; +export 'quick_set_skeleton.dart'; +export 'week_navigation_skeleton.dart'; diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/info_card_skeleton.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/info_card_skeleton.dart new file mode 100644 index 00000000..2c3ad6e0 --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/info_card_skeleton.dart @@ -0,0 +1,36 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the info card at the bottom (icon + two text lines). +class InfoCardSkeleton extends StatelessWidget { + /// Creates an [InfoCardSkeleton]. + const InfoCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const UiShimmerCircle(size: 20), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + UiShimmerLine(width: 140, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(height: 12), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/quick_set_skeleton.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/quick_set_skeleton.dart new file mode 100644 index 00000000..6e31c4af --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/quick_set_skeleton.dart @@ -0,0 +1,45 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the quick-set section (title + 4 action buttons). +class QuickSetSkeleton extends StatelessWidget { + /// Creates a [QuickSetSkeleton]. + const QuickSetSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title line + const UiShimmerLine(width: 100, height: 14), + const SizedBox(height: UiConstants.space3), + // Row of 4 button placeholders + Row( + children: List.generate(4, (index) { + return Expanded( + child: Padding( + padding: EdgeInsets.only( + left: index == 0 ? 0 : UiConstants.space1, + right: index == 3 ? 0 : UiConstants.space1, + ), + child: UiShimmerBox( + width: double.infinity, + height: 32, + borderRadius: UiConstants.radiusLg, + ), + ), + ); + }), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/week_navigation_skeleton.dart b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/week_navigation_skeleton.dart new file mode 100644 index 00000000..cfede807 --- /dev/null +++ b/apps/mobile/packages/features/staff/availability/lib/src/presentation/widgets/availability_page_skeleton/week_navigation_skeleton.dart @@ -0,0 +1,51 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the week navigation card (month header + 7 day cells). +class WeekNavigationSkeleton extends StatelessWidget { + /// Creates a [WeekNavigationSkeleton]. + const WeekNavigationSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + ), + child: Column( + children: [ + // Navigation header: left arrow, month label, right arrow + Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const UiShimmerCircle(size: 32), + UiShimmerLine(width: 140, height: 16), + const UiShimmerCircle(size: 32), + ], + ), + ), + // 7 day cells + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(7, (_) { + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space1), + child: UiShimmerBox( + width: double.infinity, + height: 64, + borderRadius: UiConstants.radiusLg, + ), + ), + ); + }), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart b/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart index 7d596b28..7c7b7a74 100644 --- a/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart +++ b/apps/mobile/packages/features/staff/availability/lib/src/staff_availability_module.dart @@ -17,12 +17,12 @@ class StaffAvailabilityModule extends Module { @override void binds(Injector i) { // Repository - i.add(AvailabilityRepositoryImpl.new); + i.addLazySingleton(AvailabilityRepositoryImpl.new); // UseCases - i.add(GetWeeklyAvailabilityUseCase.new); - i.add(UpdateDayAvailabilityUseCase.new); - i.add(ApplyQuickSetUseCase.new); + i.addLazySingleton(GetWeeklyAvailabilityUseCase.new); + i.addLazySingleton(UpdateDayAvailabilityUseCase.new); + i.addLazySingleton(ApplyQuickSetUseCase.new); // BLoC i.add(AvailabilityBloc.new); diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart index 3f6fbadc..76636878 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/pages/clock_in_page.dart @@ -10,6 +10,7 @@ import 'package:krow_domain/krow_domain.dart'; import '../bloc/clock_in_bloc.dart'; import '../bloc/clock_in_event.dart'; import '../bloc/clock_in_state.dart'; +import '../widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart'; import '../widgets/commute_tracker.dart'; import '../widgets/date_selector.dart'; import '../widgets/lunch_break_modal.dart'; @@ -52,8 +53,9 @@ class _ClockInPageState extends State { builder: (BuildContext context, ClockInState state) { if (state.status == ClockInStatus.loading && state.todayShifts.isEmpty) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), + return Scaffold( + appBar: UiAppBar(title: i18n.title, showBackButton: false), + body: const SafeArea(child: ClockInPageSkeleton()), ); } diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/activity_header_skeleton.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/activity_header_skeleton.dart new file mode 100644 index 00000000..4b392c5f --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/activity_header_skeleton.dart @@ -0,0 +1,16 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the "Your Activity" section header text. +class ActivityHeaderSkeleton extends StatelessWidget { + /// Creates a shimmer line matching the activity header. + const ActivityHeaderSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Align( + alignment: Alignment.centerLeft, + child: UiShimmerLine(width: 120, height: 18), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart new file mode 100644 index 00000000..b4c0aade --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/clock_in_page_skeleton.dart @@ -0,0 +1,52 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'activity_header_skeleton.dart'; +import 'date_selector_skeleton.dart'; +import 'shift_card_skeleton.dart'; +import 'swipe_action_skeleton.dart'; + +/// Full-page shimmer skeleton shown while clock-in data loads. +/// +/// Mirrors the loaded [ClockInPage] layout: date selector, activity header, +/// two shift cards, and the swipe-to-check-in bar. +class ClockInPageSkeleton extends StatelessWidget { + /// Creates the clock-in page shimmer skeleton. + const ClockInPageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + padding: const EdgeInsets.only( + bottom: UiConstants.space24, + top: UiConstants.space6, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + // Date selector row + DateSelectorSkeleton(), + SizedBox(height: UiConstants.space5), + + // "Your Activity" header + ActivityHeaderSkeleton(), + SizedBox(height: UiConstants.space4), + + // Shift cards (show two placeholders) + ShiftCardSkeleton(), + ShiftCardSkeleton(), + + // Swipe action bar + SwipeActionSkeleton(), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/date_selector_skeleton.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/date_selector_skeleton.dart new file mode 100644 index 00000000..e84b7c7c --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/date_selector_skeleton.dart @@ -0,0 +1,32 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the [DateSelector] row of 7 day chips. +class DateSelectorSkeleton extends StatelessWidget { + /// Creates a shimmer placeholder matching the date selector layout. + const DateSelectorSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 80, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(7, (int index) { + return Expanded( + child: Container( + margin: const EdgeInsets.symmetric( + horizontal: UiConstants.space1, + ), + child: UiShimmerBox( + width: double.infinity, + height: 80, + borderRadius: UiConstants.radiusLg, + ), + ), + ); + }), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/shift_card_skeleton.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/shift_card_skeleton.dart new file mode 100644 index 00000000..9665d288 --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/shift_card_skeleton.dart @@ -0,0 +1,52 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single shift info card. +/// +/// Mirrors the two-column layout: left side has badge, title, and subtitle +/// lines; right side has time range and rate lines. +class ShiftCardSkeleton extends StatelessWidget { + /// Creates a shimmer placeholder for one shift card. + const ShiftCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space3), + margin: const EdgeInsets.only(bottom: UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + child: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Left column: badge + title + subtitle + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 80, height: 10), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 160, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 200, height: 12), + ], + ), + ), + SizedBox(width: UiConstants.space3), + // Right column: time + rate + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + UiShimmerLine(width: 100, height: 12), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 60, height: 12), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/swipe_action_skeleton.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/swipe_action_skeleton.dart new file mode 100644 index 00000000..4218186b --- /dev/null +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/widgets/clock_in_page_skeleton/swipe_action_skeleton.dart @@ -0,0 +1,17 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the swipe-to-check-in action area. +class SwipeActionSkeleton extends StatelessWidget { + /// Creates a shimmer placeholder matching the swipe bar height. + const SwipeActionSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmerBox( + width: double.infinity, + height: 60, + borderRadius: UiConstants.radiusLg, + ); + } +} 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..1e204eb8 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 @@ -8,7 +8,9 @@ import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart'; import 'package:staff_home/src/presentation/widgets/home_page/benefits_section.dart'; 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/staff_home_header_skeleton.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'; @@ -47,8 +49,13 @@ class WorkerHomePage extends StatelessWidget { children: [ BlocBuilder( buildWhen: (previous, current) => - previous.staffName != current.staffName, + previous.staffName != current.staffName || + previous.status != current.status, builder: (context, state) { + if (state.status == HomeStatus.initial || + state.status == HomeStatus.loading) { + return const StaffHomeHeaderSkeleton(); + } return HomeHeader(userName: state.staffName); }, ), @@ -59,8 +66,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..652a6c58 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton.dart @@ -0,0 +1 @@ +export 'home_page_skeleton/index.dart'; diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/home_page_skeleton.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/home_page_skeleton.dart new file mode 100644 index 00000000..2892b948 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/home_page_skeleton.dart @@ -0,0 +1,66 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'quick_actions_skeleton.dart'; +import 'recommended_section_skeleton.dart'; +import 'shift_section_skeleton.dart'; +import 'skeleton_divider.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(), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/index.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/index.dart new file mode 100644 index 00000000..bb80e1c9 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/index.dart @@ -0,0 +1,6 @@ +export 'home_page_skeleton.dart'; +export 'quick_actions_skeleton.dart'; +export 'recommended_section_skeleton.dart'; +export 'shift_card_skeleton.dart'; +export 'shift_section_skeleton.dart'; +export 'skeleton_divider.dart'; diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/quick_actions_skeleton.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/quick_actions_skeleton.dart new file mode 100644 index 00000000..b7dc048c --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/quick_actions_skeleton.dart @@ -0,0 +1,32 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Skeleton for the quick actions row (3 circular placeholders with labels). +class QuickActionsSkeleton extends StatelessWidget { + /// Creates a [QuickActionsSkeleton]. + const QuickActionsSkeleton({super.key}); + + @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), + ], + ), + ); + }), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/recommended_section_skeleton.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/recommended_section_skeleton.dart new file mode 100644 index 00000000..15cd2ffe --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/recommended_section_skeleton.dart @@ -0,0 +1,44 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Skeleton for the recommended shifts horizontal scroll section. +class RecommendedSectionSkeleton extends StatelessWidget { + /// Creates a [RecommendedSectionSkeleton]. + const RecommendedSectionSkeleton({super.key}); + + @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, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/shift_card_skeleton.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/shift_card_skeleton.dart new file mode 100644 index 00000000..450aea7d --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/shift_card_skeleton.dart @@ -0,0 +1,37 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Skeleton for a single compact shift card on the home page. +class ShiftCardSkeleton extends StatelessWidget { + /// Creates a [ShiftCardSkeleton]. + const ShiftCardSkeleton({super.key}); + + @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), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/shift_section_skeleton.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/shift_section_skeleton.dart new file mode 100644 index 00000000..f8ffc72a --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/shift_section_skeleton.dart @@ -0,0 +1,31 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'shift_card_skeleton.dart'; + +/// Skeleton for a shift section (section header + 2 shift card placeholders). +class ShiftSectionSkeleton extends StatelessWidget { + /// Creates a [ShiftSectionSkeleton]. + const ShiftSectionSkeleton({super.key}); + + @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(), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/skeleton_divider.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/skeleton_divider.dart new file mode 100644 index 00000000..51f0566c --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/home_page_skeleton/skeleton_divider.dart @@ -0,0 +1,13 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A thin full-width divider placeholder matching the home page layout. +class SkeletonDivider extends StatelessWidget { + /// Creates a [SkeletonDivider]. + const SkeletonDivider({super.key}); + + @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/staff_home_header_skeleton.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/staff_home_header_skeleton.dart new file mode 100644 index 00000000..e3e7d7e1 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/staff_home_header_skeleton.dart @@ -0,0 +1,38 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the staff home header during loading. +/// +/// Mimics the avatar circle, welcome text, and user name layout. +class StaffHomeHeaderSkeleton extends StatelessWidget { + /// Creates a [StaffHomeHeaderSkeleton]. + const StaffHomeHeaderSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: Padding( + padding: const EdgeInsets.fromLTRB( + UiConstants.space4, + UiConstants.space4, + UiConstants.space4, + UiConstants.space3, + ), + child: Row( + spacing: UiConstants.space3, + children: const [ + UiShimmerCircle(size: UiConstants.space12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 80, height: 12), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 120, height: 16), + ], + ), + ], + ), + ), + ); + } +} 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..f6d4c461 --- /dev/null +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton.dart @@ -0,0 +1 @@ +export 'payments_page_skeleton/index.dart'; diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton/index.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton/index.dart new file mode 100644 index 00000000..ec96faf5 --- /dev/null +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton/index.dart @@ -0,0 +1,2 @@ +export 'payment_item_skeleton.dart'; +export 'payments_page_skeleton.dart'; diff --git a/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton/payment_item_skeleton.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton/payment_item_skeleton.dart new file mode 100644 index 00000000..2d24c1ae --- /dev/null +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton/payment_item_skeleton.dart @@ -0,0 +1,40 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// 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 { + /// Creates a [PaymentItemSkeleton]. + const PaymentItemSkeleton({super.key}); + + @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/payments/lib/src/presentation/widgets/payments_page_skeleton/payments_page_skeleton.dart b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton/payments_page_skeleton.dart new file mode 100644 index 00000000..45de7a7a --- /dev/null +++ b/apps/mobile/packages/features/staff/payments/lib/src/presentation/widgets/payments_page_skeleton/payments_page_skeleton.dart @@ -0,0 +1,113 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'payment_item_skeleton.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(), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart index 8bec14f2..7f54d16b 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart @@ -10,6 +10,7 @@ import '../blocs/profile_cubit.dart'; import '../blocs/profile_state.dart'; import '../widgets/logout_button.dart'; import '../widgets/header/profile_header.dart'; +import '../widgets/profile_page_skeleton/profile_page_skeleton.dart'; import '../widgets/reliability_score_bar.dart'; import '../widgets/reliability_stats_card.dart'; import '../widgets/sections/index.dart'; @@ -63,9 +64,9 @@ class StaffProfilePage extends StatelessWidget { } }, builder: (BuildContext context, ProfileState state) { - // Show loading spinner if status is loading + // Show shimmer skeleton while profile data loads if (state.status == ProfileStatus.loading) { - return const Center(child: CircularProgressIndicator()); + return const ProfilePageSkeleton(); } if (state.status == ProfileStatus.error) { @@ -87,7 +88,7 @@ class StaffProfilePage extends StatelessWidget { final Staff? profile = state.profile; if (profile == null) { - return const Center(child: CircularProgressIndicator()); + return const ProfilePageSkeleton(); } return SingleChildScrollView( diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/index.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/index.dart new file mode 100644 index 00000000..5996ff84 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/index.dart @@ -0,0 +1,5 @@ +export 'menu_section_skeleton.dart'; +export 'profile_header_skeleton.dart'; +export 'profile_page_skeleton.dart'; +export 'reliability_score_skeleton.dart'; +export 'reliability_stats_skeleton.dart'; diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/menu_section_skeleton.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/menu_section_skeleton.dart new file mode 100644 index 00000000..ff95bbbc --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/menu_section_skeleton.dart @@ -0,0 +1,87 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a profile menu section. +/// +/// Mirrors the section layout: a section title line followed by a grid of +/// square menu item placeholders. Reused for onboarding, compliance, finance, +/// and support sections. +class MenuSectionSkeleton extends StatelessWidget { + /// Creates a [MenuSectionSkeleton]. + const MenuSectionSkeleton({ + super.key, + this.itemCount = 4, + this.crossAxisCount = 3, + }); + + /// Number of menu item placeholders to display. + final int itemCount; + + /// Number of columns in the grid. + final int crossAxisCount; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Section title placeholder + Padding( + padding: const EdgeInsets.only(left: UiConstants.space1), + child: const UiShimmerLine(width: 100, height: 12), + ), + const SizedBox(height: UiConstants.space3), + // Menu items grid + LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + const double spacing = UiConstants.space3; + final double totalWidth = constraints.maxWidth; + final double totalSpacingWidth = spacing * (crossAxisCount - 1); + final double itemWidth = + (totalWidth - totalSpacingWidth) / crossAxisCount; + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: List.generate(itemCount, (int index) { + return SizedBox( + width: itemWidth, + child: const _MenuItemSkeleton(), + ); + }), + ); + }, + ), + ], + ); + } +} + +/// Single menu item shimmer: a bordered square with an icon circle and label +/// line. +class _MenuItemSkeleton extends StatelessWidget { + const _MenuItemSkeleton(); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: UiColors.bgPopup, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + padding: const EdgeInsets.all(UiConstants.space2), + child: const AspectRatio( + aspectRatio: 1.0, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + UiShimmerBox(width: 36, height: 36), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 48, height: 10), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/profile_header_skeleton.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/profile_header_skeleton.dart new file mode 100644 index 00000000..60ee0ac0 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/profile_header_skeleton.dart @@ -0,0 +1,45 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the profile header section. +/// +/// Mirrors [ProfileHeader] layout: circle avatar, name line, and level badge +/// on the primary-colored background with rounded bottom corners. +class ProfileHeaderSkeleton extends StatelessWidget { + /// Creates a [ProfileHeaderSkeleton]. + const ProfileHeaderSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space5, + UiConstants.space5, + UiConstants.space16, + ), + decoration: const BoxDecoration( + color: UiColors.primary, + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(UiConstants.space6), + ), + ), + child: SafeArea( + bottom: false, + child: Column( + children: [ + // Avatar placeholder + const UiShimmerCircle(size: 112), + const SizedBox(height: UiConstants.space4), + // Name placeholder + const UiShimmerLine(width: 160, height: 20), + const SizedBox(height: UiConstants.space2), + // Level badge placeholder + const UiShimmerBox(width: 100, height: 24), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/profile_page_skeleton.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/profile_page_skeleton.dart new file mode 100644 index 00000000..162a61e6 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/profile_page_skeleton.dart @@ -0,0 +1,62 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'menu_section_skeleton.dart'; +import 'profile_header_skeleton.dart'; +import 'reliability_score_skeleton.dart'; +import 'reliability_stats_skeleton.dart'; + +/// Full-page shimmer skeleton for [StaffProfilePage]. +/// +/// Mimics the loaded profile layout: header, reliability stats, score bar, +/// and four menu sections. Displayed while [ProfileCubit] fetches data. +class ProfilePageSkeleton extends StatelessWidget { + /// Creates a [ProfilePageSkeleton]. + const ProfilePageSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + child: Column( + children: [ + // Header with avatar, name, and badge + const ProfileHeaderSkeleton(), + + // Content offset to overlap the header bottom radius + Transform.translate( + offset: const Offset(0, -UiConstants.space6), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + child: Column( + spacing: UiConstants.space6, + children: const [ + // Reliability stats row (5 items) + ReliabilityStatsSkeleton(), + + // Reliability score bar + ReliabilityScoreSkeleton(), + + // Onboarding section (4 items, 3 columns) + MenuSectionSkeleton(itemCount: 4, crossAxisCount: 3), + + // Compliance section (3 items, 3 columns) + MenuSectionSkeleton(itemCount: 3, crossAxisCount: 3), + + // Finance section (3 items, 3 columns) + MenuSectionSkeleton(itemCount: 3, crossAxisCount: 3), + + // Support section (2 items, 3 columns) + MenuSectionSkeleton(itemCount: 2, crossAxisCount: 3), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/reliability_score_skeleton.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/reliability_score_skeleton.dart new file mode 100644 index 00000000..8bc6898a --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/reliability_score_skeleton.dart @@ -0,0 +1,45 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the reliability score bar section. +/// +/// Mirrors [ReliabilityScoreBar] layout: a tinted container with a title line, +/// percentage line, progress bar placeholder, and description line. +class ReliabilityScoreSkeleton extends StatelessWidget { + /// Creates a [ReliabilityScoreSkeleton]. + const ReliabilityScoreSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.1), + borderRadius: UiConstants.radiusLg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title row with label and percentage + const Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + UiShimmerLine(width: 120, height: 14), + UiShimmerLine(width: 40, height: 18), + ], + ), + const SizedBox(height: UiConstants.space2), + // Progress bar placeholder + UiShimmerBox( + width: double.infinity, + height: 8, + borderRadius: UiConstants.radiusSm, + ), + const SizedBox(height: UiConstants.space2), + // Description line + const UiShimmerLine(width: 200, height: 10), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/reliability_stats_skeleton.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/reliability_stats_skeleton.dart new file mode 100644 index 00000000..a8d40bb4 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/profile_page_skeleton/reliability_stats_skeleton.dart @@ -0,0 +1,54 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the reliability stats card. +/// +/// Mirrors [ReliabilityStatsCard] layout: a bordered card containing five +/// evenly-spaced stat columns, each with an icon circle, value line, and +/// label line. +class ReliabilityStatsSkeleton extends StatelessWidget { + /// Creates a [ReliabilityStatsSkeleton]. + const ReliabilityStatsSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.bgPopup, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _StatItemSkeleton(), + _StatItemSkeleton(), + _StatItemSkeleton(), + _StatItemSkeleton(), + _StatItemSkeleton(), + ], + ), + ); + } +} + +/// Single stat column shimmer: icon circle, value line, label line. +class _StatItemSkeleton extends StatelessWidget { + const _StatItemSkeleton(); + + @override + Widget build(BuildContext context) { + return const Expanded( + child: Column( + children: [ + UiShimmerBox(width: UiConstants.space10, height: UiConstants.space10), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 28, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 36, height: 10), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificates_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificates_page.dart index 21e2c4c7..c393f0e0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificates_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificates_page.dart @@ -11,6 +11,7 @@ import '../blocs/certificates/certificates_state.dart'; import '../widgets/add_certificate_card.dart'; import '../widgets/certificate_card.dart'; import '../widgets/certificates_header.dart'; +import '../widgets/certificates_skeleton/certificates_skeleton.dart'; /// Page for viewing and managing staff certificates. /// @@ -28,9 +29,7 @@ class CertificatesPage extends StatelessWidget { builder: (BuildContext context, CertificatesState state) { if (state.status == CertificatesStatus.loading || state.status == CertificatesStatus.initial) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); + return const Scaffold(body: CertificatesSkeleton()); } if (state.status == CertificatesStatus.failure) { diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_skeleton/certificate_card_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_skeleton/certificate_card_skeleton.dart new file mode 100644 index 00000000..55b05acb --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_skeleton/certificate_card_skeleton.dart @@ -0,0 +1,38 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single certificate card. +class CertificateCardSkeleton extends StatelessWidget { + /// Creates a [CertificateCardSkeleton]. + const CertificateCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + margin: const EdgeInsets.only(bottom: UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + 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), + ], + ), + ), + UiShimmerBox(width: 60, height: 28), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_skeleton/certificates_header_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_skeleton/certificates_header_skeleton.dart new file mode 100644 index 00000000..7e41aad5 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_skeleton/certificates_header_skeleton.dart @@ -0,0 +1,37 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the certificates progress header. +class CertificatesHeaderSkeleton extends StatelessWidget { + /// Creates a [CertificatesHeaderSkeleton]. + const CertificatesHeaderSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(UiConstants.space5), + decoration: const BoxDecoration(color: UiColors.primary), + child: SafeArea( + bottom: false, + child: Column( + children: [ + const SizedBox(height: UiConstants.space4), + const UiShimmerCircle(size: 64), + const SizedBox(height: UiConstants.space3), + UiShimmerLine( + width: 120, + height: 14, + ), + const SizedBox(height: UiConstants.space2), + UiShimmerLine( + width: 80, + height: 12, + ), + const SizedBox(height: UiConstants.space6), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_skeleton/certificates_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_skeleton/certificates_skeleton.dart new file mode 100644 index 00000000..30c461d9 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificates_skeleton/certificates_skeleton.dart @@ -0,0 +1,38 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'certificate_card_skeleton.dart'; +import 'certificates_header_skeleton.dart'; + +/// Full-page shimmer skeleton shown while certificates are loading. +class CertificatesSkeleton extends StatelessWidget { + /// Creates a [CertificatesSkeleton]. + const CertificatesSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + child: Column( + children: [ + const CertificatesHeaderSkeleton(), + Transform.translate( + offset: const Offset(0, -UiConstants.space12), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), + child: UiShimmerList( + itemCount: 4, + spacing: UiConstants.space3, + itemBuilder: (int index) => + const CertificateCardSkeleton(), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart index 77e2a08d..353a0f70 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart @@ -10,6 +10,7 @@ import '../blocs/documents/documents_cubit.dart'; import '../blocs/documents/documents_state.dart'; import '../widgets/document_card.dart'; import '../widgets/documents_progress_card.dart'; +import '../widgets/documents_skeleton/documents_skeleton.dart'; class DocumentsPage extends StatelessWidget { const DocumentsPage({super.key}); @@ -28,11 +29,7 @@ class DocumentsPage extends StatelessWidget { child: BlocBuilder( builder: (BuildContext context, DocumentsState state) { if (state.status == DocumentsStatus.loading) { - return const Center( - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(UiColors.primary), - ), - ); + return const DocumentsSkeleton(); } if (state.status == DocumentsStatus.failure) { return Center( diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_skeleton/document_card_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_skeleton/document_card_skeleton.dart new file mode 100644 index 00000000..6a5149d6 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_skeleton/document_card_skeleton.dart @@ -0,0 +1,37 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single document card row. +class DocumentCardSkeleton extends StatelessWidget { + /// Creates a [DocumentCardSkeleton]. + const DocumentCardSkeleton({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: const Row( + children: [ + UiShimmerCircle(size: 40), + SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 160, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 100, height: 12), + ], + ), + ), + UiShimmerBox(width: 24, height: 24), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_skeleton/documents_progress_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_skeleton/documents_progress_skeleton.dart new file mode 100644 index 00000000..e528ebb6 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_skeleton/documents_progress_skeleton.dart @@ -0,0 +1,30 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the documents progress card. +class DocumentsProgressSkeleton extends StatelessWidget { + /// Creates a [DocumentsProgressSkeleton]. + const DocumentsProgressSkeleton({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: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 140, height: 14), + SizedBox(height: UiConstants.space3), + UiShimmerBox(width: double.infinity, height: 8), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 80, height: 12), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_skeleton/documents_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_skeleton/documents_skeleton.dart new file mode 100644 index 00000000..8fdd205d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/documents_skeleton/documents_skeleton.dart @@ -0,0 +1,32 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'document_card_skeleton.dart'; +import 'documents_progress_skeleton.dart'; + +/// Full-page shimmer skeleton shown while documents are loading. +class DocumentsSkeleton extends StatelessWidget { + /// Creates a [DocumentsSkeleton]. + const DocumentsSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: ListView( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + vertical: UiConstants.space6, + ), + children: [ + const DocumentsProgressSkeleton(), + const SizedBox(height: UiConstants.space4), + UiShimmerList( + itemCount: 5, + spacing: UiConstants.space3, + itemBuilder: (int index) => const DocumentCardSkeleton(), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart index bc350439..edeb738a 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/pages/tax_forms_page.dart @@ -8,6 +8,7 @@ import 'package:krow_domain/krow_domain.dart'; import '../blocs/tax_forms/tax_forms_cubit.dart'; import '../blocs/tax_forms/tax_forms_state.dart'; import '../widgets/tax_forms_page/index.dart'; +import '../widgets/tax_forms_skeleton/tax_forms_skeleton.dart'; class TaxFormsPage extends StatelessWidget { const TaxFormsPage({super.key}); @@ -31,7 +32,7 @@ class TaxFormsPage extends StatelessWidget { child: BlocBuilder( builder: (BuildContext context, TaxFormsState state) { if (state.status == TaxFormsStatus.loading) { - return const Center(child: CircularProgressIndicator()); + return const TaxFormsSkeleton(); } if (state.status == TaxFormsStatus.failure) { diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_skeleton/tax_form_card_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_skeleton/tax_form_card_skeleton.dart new file mode 100644 index 00000000..ded5efe1 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_skeleton/tax_form_card_skeleton.dart @@ -0,0 +1,37 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single tax form card. +class TaxFormCardSkeleton extends StatelessWidget { + /// Creates a [TaxFormCardSkeleton]. + const TaxFormCardSkeleton({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: const Row( + children: [ + UiShimmerCircle(size: 40), + SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 120, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 80, height: 12), + ], + ), + ), + UiShimmerBox(width: 60, height: 24), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_skeleton/tax_forms_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_skeleton/tax_forms_skeleton.dart new file mode 100644 index 00000000..a60e3dba --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/tax_forms/lib/src/presentation/widgets/tax_forms_skeleton/tax_forms_skeleton.dart @@ -0,0 +1,55 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'tax_form_card_skeleton.dart'; + +/// Full-page shimmer skeleton shown while tax forms are loading. +class TaxFormsSkeleton extends StatelessWidget { + /// Creates a [TaxFormsSkeleton]. + const TaxFormsSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + vertical: UiConstants.space6, + ), + child: Column( + spacing: UiConstants.space4, + children: [ + // Info card placeholder + Container( + width: double.infinity, + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + color: UiColors.cardViewBackground, + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 180, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(height: 12), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 200, height: 12), + ], + ), + ), + // Progress overview placeholder + const UiShimmerStatsCard(), + // Form card placeholders + UiShimmerList( + itemCount: 3, + spacing: UiConstants.space2, + itemBuilder: (int index) => const TaxFormCardSkeleton(), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart index 1d9fd651..c7a8bd8b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart @@ -10,6 +10,7 @@ import '../blocs/bank_account_cubit.dart'; import '../blocs/bank_account_state.dart'; import '../widgets/account_card.dart'; import '../widgets/add_account_form.dart'; +import '../widgets/bank_account_skeleton/bank_account_skeleton.dart'; import '../widgets/security_notice.dart'; class BankAccountPage extends StatelessWidget { @@ -49,7 +50,7 @@ class BankAccountPage extends StatelessWidget { builder: (BuildContext context, BankAccountState state) { if (state.status == BankAccountStatus.loading && state.accounts.isEmpty) { - return const Center(child: CircularProgressIndicator()); + return const BankAccountSkeleton(); } if (state.status == BankAccountStatus.error) { diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/bank_account_skeleton/account_card_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/bank_account_skeleton/account_card_skeleton.dart new file mode 100644 index 00000000..0cedfaff --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/bank_account_skeleton/account_card_skeleton.dart @@ -0,0 +1,37 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single bank account card. +class AccountCardSkeleton extends StatelessWidget { + /// Creates an [AccountCardSkeleton]. + const AccountCardSkeleton({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: 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), + ], + ), + ), + UiShimmerBox(width: 48, height: 24), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/bank_account_skeleton/bank_account_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/bank_account_skeleton/bank_account_skeleton.dart new file mode 100644 index 00000000..539cd596 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/bank_account_skeleton/bank_account_skeleton.dart @@ -0,0 +1,32 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'account_card_skeleton.dart'; +import 'security_notice_skeleton.dart'; + +/// Full-page shimmer skeleton shown while bank accounts are loading. +class BankAccountSkeleton extends StatelessWidget { + /// Creates a [BankAccountSkeleton]. + const BankAccountSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SecurityNoticeSkeleton(), + const SizedBox(height: UiConstants.space4), + UiShimmerList( + itemCount: 2, + spacing: UiConstants.space3, + itemBuilder: (int index) => const AccountCardSkeleton(), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/bank_account_skeleton/security_notice_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/bank_account_skeleton/security_notice_skeleton.dart new file mode 100644 index 00000000..0d83d46b --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/widgets/bank_account_skeleton/security_notice_skeleton.dart @@ -0,0 +1,36 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the security notice banner. +class SecurityNoticeSkeleton extends StatelessWidget { + /// Creates a [SecurityNoticeSkeleton]. + const SecurityNoticeSkeleton({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: const Row( + children: [ + UiShimmerCircle(size: 24), + SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 160, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(height: 12), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart index 80f5a327..77aecffc 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/pages/time_card_page.dart @@ -8,6 +8,7 @@ import 'package:krow_core/core.dart'; import '../blocs/time_card_bloc.dart'; import '../widgets/month_selector.dart'; import '../widgets/shift_history_list.dart'; +import '../widgets/time_card_skeleton/time_card_skeleton.dart'; import '../widgets/time_card_summary.dart'; /// The main page for displaying the staff time card. @@ -50,7 +51,7 @@ class _TimeCardPageState extends State { }, builder: (BuildContext context, TimeCardState state) { if (state is TimeCardLoading) { - return const Center(child: CircularProgressIndicator()); + return const TimeCardSkeleton(); } else if (state is TimeCardError) { return Center( child: Padding( diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/month_selector_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/month_selector_skeleton.dart new file mode 100644 index 00000000..d6452723 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/month_selector_skeleton.dart @@ -0,0 +1,20 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the month selector row. +class MonthSelectorSkeleton extends StatelessWidget { + /// Creates a [MonthSelectorSkeleton]. + const MonthSelectorSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + UiShimmerCircle(size: 32), + UiShimmerLine(width: 120, height: 16), + UiShimmerCircle(size: 32), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/shift_history_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/shift_history_skeleton.dart new file mode 100644 index 00000000..b045392f --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/shift_history_skeleton.dart @@ -0,0 +1,42 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single shift history row. +class ShiftHistorySkeleton extends StatelessWidget { + /// Creates a [ShiftHistorySkeleton]. + const ShiftHistorySkeleton({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: const Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 140, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 100, height: 12), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + UiShimmerLine(width: 60, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 40, height: 12), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/time_card_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/time_card_skeleton.dart new file mode 100644 index 00000000..3d952454 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/time_card_skeleton.dart @@ -0,0 +1,37 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'month_selector_skeleton.dart'; +import 'shift_history_skeleton.dart'; +import 'time_card_summary_skeleton.dart'; + +/// Full-page shimmer skeleton shown while time card data is loading. +class TimeCardSkeleton extends StatelessWidget { + /// Creates a [TimeCardSkeleton]. + const TimeCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + vertical: UiConstants.space6, + ), + child: Column( + children: [ + const MonthSelectorSkeleton(), + const SizedBox(height: UiConstants.space6), + const TimeCardSummarySkeleton(), + const SizedBox(height: UiConstants.space6), + UiShimmerList( + itemCount: 5, + spacing: UiConstants.space3, + itemBuilder: (int index) => const ShiftHistorySkeleton(), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/time_card_summary_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/time_card_summary_skeleton.dart new file mode 100644 index 00000000..92382057 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/finances/time_card/lib/src/presentation/widgets/time_card_skeleton/time_card_summary_skeleton.dart @@ -0,0 +1,19 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the time card summary (hours + earnings). +class TimeCardSummarySkeleton extends StatelessWidget { + /// Creates a [TimeCardSummarySkeleton]. + const TimeCardSummarySkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Row( + children: [ + Expanded(child: UiShimmerStatsCard()), + SizedBox(width: UiConstants.space3), + Expanded(child: UiShimmerStatsCard()), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart index afcc60f4..2637c9c0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart @@ -13,6 +13,7 @@ import '../widgets/attire_info_card.dart'; import '../widgets/attire_item_card.dart'; import '../widgets/attire_section_header.dart'; import '../widgets/attire_section_tab.dart'; +import '../widgets/attire_skeleton/attire_skeleton.dart'; class AttirePage extends StatefulWidget { const AttirePage({super.key}); @@ -49,7 +50,7 @@ class _AttirePageState extends State { }, builder: (BuildContext context, AttireState state) { if (state.status == AttireStatus.loading && state.options.isEmpty) { - return const Center(child: CircularProgressIndicator()); + return const AttireSkeleton(); } final List requiredItems = state.options diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_skeleton/attire_item_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_skeleton/attire_item_skeleton.dart new file mode 100644 index 00000000..6387185d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_skeleton/attire_item_skeleton.dart @@ -0,0 +1,37 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single attire item card. +class AttireItemSkeleton extends StatelessWidget { + /// Creates an [AttireItemSkeleton]. + const AttireItemSkeleton({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: const Row( + children: [ + UiShimmerBox(width: 56, height: 56), + SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 120, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 80, height: 12), + ], + ), + ), + UiShimmerBox(width: 60, height: 24), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_skeleton/attire_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_skeleton/attire_skeleton.dart new file mode 100644 index 00000000..3090cfd9 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_skeleton/attire_skeleton.dart @@ -0,0 +1,63 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'attire_item_skeleton.dart'; + +/// Full-page shimmer skeleton shown while attire items are loading. +class AttireSkeleton extends StatelessWidget { + /// Creates an [AttireSkeleton]. + const AttireSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Info card placeholder + Container( + width: double.infinity, + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + border: Border.all(color: UiColors.border), + borderRadius: UiConstants.radiusLg, + color: UiColors.cardViewBackground, + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 160, height: 14), + SizedBox(height: UiConstants.space2), + UiShimmerLine(height: 12), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 200, height: 12), + ], + ), + ), + const SizedBox(height: UiConstants.space6), + // Section toggle chips placeholder + const Row( + children: [ + UiShimmerBox(width: 80, height: 32), + SizedBox(width: UiConstants.space3), + UiShimmerBox(width: 100, height: 32), + ], + ), + const SizedBox(height: UiConstants.space6), + // Section header placeholder + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space3), + // Attire item cards + UiShimmerList( + itemCount: 4, + spacing: UiConstants.space3, + itemBuilder: (int index) => const AttireItemSkeleton(), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/pages/emergency_contact_screen.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/pages/emergency_contact_screen.dart index ab377812..dd85406f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/pages/emergency_contact_screen.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/pages/emergency_contact_screen.dart @@ -9,6 +9,7 @@ import '../widgets/emergency_contact_add_button.dart'; import '../widgets/emergency_contact_form_item.dart'; import '../widgets/emergency_contact_info_banner.dart'; import '../widgets/emergency_contact_save_button.dart'; +import '../widgets/emergency_contact_skeleton/emergency_contact_skeleton.dart'; /// The Staff Emergency Contact screen. /// @@ -43,7 +44,7 @@ class EmergencyContactScreen extends StatelessWidget { }, builder: (context, state) { if (state.status == EmergencyContactStatus.loading) { - return const Center(child: CircularProgressIndicator()); + return const EmergencyContactSkeleton(); } return Column( children: [ diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_info_banner.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_info_banner.dart index 00ed24a7..2592a230 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_info_banner.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_info_banner.dart @@ -7,7 +7,9 @@ class EmergencyContactInfoBanner extends StatelessWidget { @override Widget build(BuildContext context) { return UiNoticeBanner( - title: + icon: UiIcons.warning, + title: 'Emergency Contact Information', + description: 'Please provide at least one emergency contact. This information will only be used in case of an emergency during your shifts.', ); } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/contact_card_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/contact_card_skeleton.dart new file mode 100644 index 00000000..9109a538 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/contact_card_skeleton.dart @@ -0,0 +1,39 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'contact_field_skeleton.dart'; + +/// Shimmer placeholder for a single emergency contact card. +class ContactCardSkeleton extends StatelessWidget { + /// Creates a [ContactCardSkeleton]. + const ContactCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: UiConstants.space4), + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.bgPopup, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header ("Contact 1") + UiShimmerLine(width: 90, height: 16), + SizedBox(height: UiConstants.space4), + // Full Name field + ContactFieldSkeleton(), + SizedBox(height: UiConstants.space4), + // Phone Number field + ContactFieldSkeleton(), + SizedBox(height: UiConstants.space4), + // Relationship field + ContactFieldSkeleton(), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/contact_field_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/contact_field_skeleton.dart new file mode 100644 index 00000000..b376b11e --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/contact_field_skeleton.dart @@ -0,0 +1,20 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single form field (label + input). +class ContactFieldSkeleton extends StatelessWidget { + /// Creates a [ContactFieldSkeleton]. + const ContactFieldSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 80, height: 12), + SizedBox(height: UiConstants.space2), + UiShimmerBox(width: double.infinity, height: 48), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/emergency_contact_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/emergency_contact_skeleton.dart new file mode 100644 index 00000000..280e599e --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/emergency_contact_skeleton.dart @@ -0,0 +1,57 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'contact_card_skeleton.dart'; +import 'info_banner_skeleton.dart'; + +/// Full-page shimmer skeleton shown while emergency contacts are loading. +class EmergencyContactSkeleton extends StatelessWidget { + /// Creates an [EmergencyContactSkeleton]. + const EmergencyContactSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space6), + child: Column( + children: [ + // Info banner + const InfoBannerSkeleton(), + const SizedBox(height: UiConstants.space6), + // Contact card + const ContactCardSkeleton(), + const SizedBox(height: UiConstants.space4), + // Add contact button placeholder + UiShimmerBox( + width: 180, + height: 40, + borderRadius: UiConstants.radiusFull, + ), + const SizedBox(height: UiConstants.space16), + ], + ), + ), + ), + // Save button placeholder + Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: UiColors.border)), + ), + child: SafeArea( + child: UiShimmerBox( + width: double.infinity, + height: 48, + borderRadius: UiConstants.radiusLg, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/info_banner_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/info_banner_skeleton.dart new file mode 100644 index 00000000..dd1462b9 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/emergency_contact/lib/src/presentation/widgets/emergency_contact_skeleton/info_banner_skeleton.dart @@ -0,0 +1,37 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for the emergency contact info banner. +class InfoBannerSkeleton extends StatelessWidget { + /// Creates an [InfoBannerSkeleton]. + const InfoBannerSkeleton({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: const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerCircle(size: 24), + SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(height: 12), + SizedBox(height: UiConstants.space2), + UiShimmerLine(width: 200, height: 12), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart index a7cbf5cc..b450f4d7 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/pages/personal_info_page.dart @@ -7,6 +7,7 @@ import 'package:krow_core/core.dart'; import 'package:staff_profile_info/src/presentation/blocs/personal_info_bloc.dart'; import 'package:staff_profile_info/src/presentation/blocs/personal_info_state.dart'; import 'package:staff_profile_info/src/presentation/widgets/personal_info_page/personal_info_content.dart'; +import 'package:staff_profile_info/src/presentation/widgets/personal_info_skeleton/personal_info_skeleton.dart'; /// The Personal Info page for staff onboarding. @@ -56,7 +57,7 @@ class PersonalInfoPage extends StatelessWidget { builder: (BuildContext context, PersonalInfoState state) { if (state.status == PersonalInfoStatus.loading || state.status == PersonalInfoStatus.initial) { - return const Center(child: CircularProgressIndicator()); + return const PersonalInfoSkeleton(); } if (state.staff == null) { diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_skeleton/form_field_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_skeleton/form_field_skeleton.dart new file mode 100644 index 00000000..4fd28d9a --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_skeleton/form_field_skeleton.dart @@ -0,0 +1,20 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single form field (label + input). +class FormFieldSkeleton extends StatelessWidget { + /// Creates a [FormFieldSkeleton]. + const FormFieldSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 80, height: 12), + SizedBox(height: UiConstants.space2), + UiShimmerBox(width: double.infinity, height: 48), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_skeleton/personal_info_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_skeleton/personal_info_skeleton.dart new file mode 100644 index 00000000..0a20ab5a --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/presentation/widgets/personal_info_skeleton/personal_info_skeleton.dart @@ -0,0 +1,32 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'form_field_skeleton.dart'; + +/// Full-page shimmer skeleton shown while personal info is loading. +class PersonalInfoSkeleton extends StatelessWidget { + /// Creates a [PersonalInfoSkeleton]. + const PersonalInfoSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + children: [ + // Avatar placeholder + const Center(child: UiShimmerCircle(size: 80)), + const SizedBox(height: UiConstants.space6), + // Form fields + UiShimmerList( + itemCount: 5, + spacing: UiConstants.space5, + itemBuilder: (int index) => const FormFieldSkeleton(), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_skeleton/faq_item_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_skeleton/faq_item_skeleton.dart new file mode 100644 index 00000000..14407abc --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_skeleton/faq_item_skeleton.dart @@ -0,0 +1,29 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single FAQ accordion item. +class FaqItemSkeleton extends StatelessWidget { + /// Creates a [FaqItemSkeleton]. + const FaqItemSkeleton({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: const Row( + children: [ + Expanded( + child: UiShimmerLine(height: 14), + ), + SizedBox(width: UiConstants.space3), + UiShimmerCircle(size: 20), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_skeleton/faqs_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_skeleton/faqs_skeleton.dart new file mode 100644 index 00000000..5ab1e2f8 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_skeleton/faqs_skeleton.dart @@ -0,0 +1,54 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'faq_item_skeleton.dart'; + +/// Full-page shimmer skeleton shown while FAQs are loading. +class FaqsSkeleton extends StatelessWidget { + /// Creates a [FaqsSkeleton]. + const FaqsSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: Padding( + padding: const EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space5, + UiConstants.space5, + UiConstants.space24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Search bar placeholder + UiShimmerBox( + width: double.infinity, + height: 48, + borderRadius: UiConstants.radiusLg, + ), + const SizedBox(height: UiConstants.space6), + // Category header + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space3), + // FAQ items + UiShimmerList( + itemCount: 3, + spacing: UiConstants.space2, + itemBuilder: (int index) => const FaqItemSkeleton(), + ), + const SizedBox(height: UiConstants.space6), + // Second category + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space3), + UiShimmerList( + itemCount: 3, + spacing: UiConstants.space2, + itemBuilder: (int index) => const FaqItemSkeleton(), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart index bda66591..80b1f00f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/faqs/lib/src/presentation/widgets/faqs_widget.dart @@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:staff_faqs/src/presentation/blocs/faqs_bloc.dart'; +import 'faqs_skeleton/faqs_skeleton.dart'; /// Widget displaying FAQs with search functionality and accordion items class FaqsWidget extends StatefulWidget { @@ -76,10 +77,7 @@ class _FaqsWidgetState extends State { // FAQ List or Empty State if (state.isLoading) - const Padding( - padding: EdgeInsets.symmetric(vertical: 48), - child: CircularProgressIndicator(), - ) + const FaqsSkeleton() else if (state.categories.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 48), diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/privacy_policy_page.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/privacy_policy_page.dart index 1f9c0379..7e2cf227 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/privacy_policy_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/privacy_policy_page.dart @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import '../../blocs/legal/privacy_policy_cubit.dart'; +import '../../widgets/skeletons/legal_document_skeleton.dart'; /// Page displaying the Privacy Policy document class PrivacyPolicyPage extends StatelessWidget { @@ -24,9 +25,7 @@ class PrivacyPolicyPage extends StatelessWidget { child: BlocBuilder( builder: (BuildContext context, PrivacyPolicyState state) { if (state.isLoading) { - return const Center( - child: CircularProgressIndicator(), - ); + return const LegalDocumentSkeleton(); } if (state.error != null) { diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/terms_of_service_page.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/terms_of_service_page.dart index e5e30c13..2be5be37 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/terms_of_service_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/legal/terms_of_service_page.dart @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import '../../blocs/legal/terms_cubit.dart'; +import '../../widgets/skeletons/legal_document_skeleton.dart'; /// Page displaying the Terms of Service document class TermsOfServicePage extends StatelessWidget { @@ -24,9 +25,7 @@ class TermsOfServicePage extends StatelessWidget { child: BlocBuilder( builder: (BuildContext context, TermsState state) { if (state.isLoading) { - return const Center( - child: CircularProgressIndicator(), - ); + return const LegalDocumentSkeleton(); } if (state.error != null) { diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/privacy_security_page.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/privacy_security_page.dart index df83b2cd..cbc8bd7b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/privacy_security_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/pages/privacy_security_page.dart @@ -7,6 +7,7 @@ import 'package:flutter_modular/flutter_modular.dart'; import '../blocs/privacy_security_bloc.dart'; import '../widgets/legal/legal_section_widget.dart'; import '../widgets/privacy/privacy_section_widget.dart'; +import '../widgets/skeletons/privacy_security_skeleton.dart'; /// Page displaying privacy & security settings for staff class PrivacySecurityPage extends StatelessWidget { @@ -25,7 +26,7 @@ class PrivacySecurityPage extends StatelessWidget { child: BlocBuilder( builder: (BuildContext context, PrivacySecurityState state) { if (state.isLoading) { - return const UiLoadingPage(); + return const PrivacySecuritySkeleton(); } return const SingleChildScrollView( diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/skeletons/legal_document_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/skeletons/legal_document_skeleton.dart new file mode 100644 index 00000000..39176a89 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/skeletons/legal_document_skeleton.dart @@ -0,0 +1,61 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shared shimmer skeleton for legal document pages (Privacy Policy, Terms). +/// +/// Simulates a long-form text document with varied line widths. +class LegalDocumentSkeleton extends StatelessWidget { + /// Creates a [LegalDocumentSkeleton]. + const LegalDocumentSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title line + const UiShimmerLine(width: 200, height: 18), + const SizedBox(height: UiConstants.space4), + // Body text lines with varied widths + UiShimmerList( + itemCount: 4, + spacing: UiConstants.space2, + itemBuilder: (int index) => const UiShimmerLine(), + ), + const SizedBox(height: UiConstants.space5), + const UiShimmerLine(width: 180, height: 16), + const SizedBox(height: UiConstants.space3), + UiShimmerList( + itemCount: 5, + spacing: UiConstants.space2, + itemBuilder: (int index) => UiShimmerLine( + width: index == 4 ? 200 : double.infinity, + ), + ), + const SizedBox(height: UiConstants.space5), + const UiShimmerLine(width: 160, height: 16), + const SizedBox(height: UiConstants.space3), + UiShimmerList( + itemCount: 3, + spacing: UiConstants.space2, + itemBuilder: (int index) => const UiShimmerLine(), + ), + const SizedBox(height: UiConstants.space5), + const UiShimmerLine(width: 140, height: 16), + const SizedBox(height: UiConstants.space3), + UiShimmerList( + itemCount: 4, + spacing: UiConstants.space2, + itemBuilder: (int index) => UiShimmerLine( + width: index == 3 ? 160 : double.infinity, + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/skeletons/privacy_security_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/skeletons/privacy_security_skeleton.dart new file mode 100644 index 00000000..85db9d2d --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/skeletons/privacy_security_skeleton.dart @@ -0,0 +1,42 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'settings_toggle_skeleton.dart'; + +/// Full-page shimmer skeleton shown while privacy settings are loading. +class PrivacySecuritySkeleton extends StatelessWidget { + /// Creates a [PrivacySecuritySkeleton]. + const PrivacySecuritySkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return UiShimmer( + child: SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Privacy section header + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space4), + UiShimmerList( + itemCount: 3, + spacing: UiConstants.space4, + itemBuilder: (int index) => const SettingsToggleSkeleton(), + ), + const SizedBox(height: UiConstants.space6), + // Legal section header + const UiShimmerSectionHeader(), + const SizedBox(height: UiConstants.space4), + // Legal links + UiShimmerList( + itemCount: 2, + spacing: UiConstants.space3, + itemBuilder: (int index) => const UiShimmerListItem(), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/skeletons/settings_toggle_skeleton.dart b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/skeletons/settings_toggle_skeleton.dart new file mode 100644 index 00000000..fc60ed97 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/support/privacy_security/lib/src/presentation/widgets/skeletons/settings_toggle_skeleton.dart @@ -0,0 +1,31 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Shimmer placeholder for a single settings toggle row. +class SettingsToggleSkeleton extends StatelessWidget { + /// Creates a [SettingsToggleSkeleton]. + const SettingsToggleSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: UiConstants.space2), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UiShimmerLine(width: 140, height: 14), + SizedBox(height: UiConstants.space1), + UiShimmerLine(width: 200, height: 12), + ], + ), + ), + SizedBox(width: UiConstants.space3), + UiShimmerBox(width: 48, height: 28), + ], + ), + ); + } +} 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..85f9f266 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton.dart @@ -0,0 +1 @@ +export 'shift_details_page_skeleton/index.dart'; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton/index.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton/index.dart new file mode 100644 index 00000000..01ee6e4c --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton/index.dart @@ -0,0 +1,2 @@ +export 'shift_details_page_skeleton.dart'; +export 'stat_card_skeleton.dart'; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton/shift_details_page_skeleton.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton/shift_details_page_skeleton.dart new file mode 100644 index 00000000..dbb787f9 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton/shift_details_page_skeleton.dart @@ -0,0 +1,150 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'stat_card_skeleton.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), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton/stat_card_skeleton.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton/stat_card_skeleton.dart new file mode 100644 index 00000000..595a02b1 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_details_page_skeleton/stat_card_skeleton.dart @@ -0,0 +1,28 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Skeleton for a single stat card in the stats row. +class StatCardSkeleton extends StatelessWidget { + /// Creates a [StatCardSkeleton]. + const StatCardSkeleton({super.key}); + + @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..e105af4b --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton.dart @@ -0,0 +1 @@ +export 'shifts_page_skeleton/index.dart'; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton/index.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton/index.dart new file mode 100644 index 00000000..1fffff3a --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton/index.dart @@ -0,0 +1,2 @@ +export 'shift_card_skeleton.dart'; +export 'shifts_page_skeleton.dart'; diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton/shift_card_skeleton.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton/shift_card_skeleton.dart new file mode 100644 index 00000000..db661acc --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton/shift_card_skeleton.dart @@ -0,0 +1,44 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// 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 { + /// Creates a [ShiftCardSkeleton]. + const ShiftCardSkeleton({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, + ), + 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/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton/shifts_page_skeleton.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton/shifts_page_skeleton.dart new file mode 100644 index 00000000..844e8cf1 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shifts_page_skeleton/shifts_page_skeleton.dart @@ -0,0 +1,33 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'shift_card_skeleton.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(), + ), + ], + ), + ), + ); + } +} 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