diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/pages/client_home_page.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/pages/client_home_page.dart index 92f741dc..cd1b47de 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/pages/client_home_page.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/pages/client_home_page.dart @@ -1,15 +1,12 @@ import 'package:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import '../blocs/client_home_bloc.dart'; -import '../blocs/client_home_event.dart'; -import '../blocs/client_home_state.dart'; +import '../widgets/client_home_body.dart'; import '../widgets/client_home_edit_banner.dart'; import '../widgets/client_home_header.dart'; -import '../widgets/dashboard_widget_builder.dart'; /// The main Home page for client users. /// @@ -21,133 +18,23 @@ class ClientHomePage extends StatelessWidget { @override Widget build(BuildContext context) { - final TranslationsClientHomeEn i18n = t.client_home; - return BlocProvider( create: (BuildContext context) => Modular.get(), child: Scaffold( body: SafeArea( child: Column( children: [ - ClientHomeHeader(i18n: i18n), - ClientHomeEditBanner(i18n: i18n), - Flexible( - child: BlocConsumer( - listener: (BuildContext context, ClientHomeState state) { - if (state.status == ClientHomeStatus.error && - state.errorMessage != null) { - UiSnackbar.show( - context, - message: translateErrorKey(state.errorMessage!), - type: UiSnackbarType.error, - ); - } - }, - builder: (BuildContext context, ClientHomeState state) { - if (state.status == ClientHomeStatus.error) { - return _buildErrorState(context, state); - } - if (state.isEditMode) { - return _buildEditModeList(context, state); - } - return _buildNormalModeList(state); - }, - ), + ClientHomeHeader( + i18n: t.client_home, ), + ClientHomeEditBanner( + i18n: t.client_home, + ), + const ClientHomeBody(), ], ), ), ), ); } - - /// Builds the widget list in edit mode with drag-and-drop support. - Widget _buildEditModeList(BuildContext context, ClientHomeState state) { - return ReorderableListView( - padding: const EdgeInsets.fromLTRB( - UiConstants.space4, - 0, - UiConstants.space4, - 100, - ), - onReorder: (int oldIndex, int newIndex) { - BlocProvider.of( - context, - ).add(ClientHomeWidgetReordered(oldIndex, newIndex)); - }, - children: state.widgetOrder.map((String id) { - return Container( - key: ValueKey(id), - margin: const EdgeInsets.only(bottom: UiConstants.space4), - child: DashboardWidgetBuilder(id: id, state: state, isEditMode: true), - ); - }).toList(), - ); - } - - /// Builds the widget list in normal mode with visibility filters. - Widget _buildNormalModeList(ClientHomeState state) { - final List visibleWidgets = state.widgetOrder.where((String id) { - if (id == 'reorder' && state.reorderItems.isEmpty) { - return false; - } - return state.widgetVisibility[id] ?? true; - }).toList(); - - return ListView.separated( - separatorBuilder: (BuildContext context, int index) { - return const Divider(color: UiColors.border, height: 0.1); - }, - itemCount: visibleWidgets.length, - itemBuilder: (BuildContext context, int index) { - final String id = visibleWidgets[index]; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (index != 0) const SizedBox(height: UiConstants.space8), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space4, - ), - child: DashboardWidgetBuilder( - id: id, - state: state, - isEditMode: false, - ), - ), - const SizedBox(height: UiConstants.space8), - ], - ); - }, - ); - } - - Widget _buildErrorState(BuildContext context, ClientHomeState state) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - UiIcons.error, - size: 48, - color: UiColors.error, - ), - const SizedBox(height: UiConstants.space4), - Text( - state.errorMessage != null - ? translateErrorKey(state.errorMessage!) - : 'An error occurred', - style: UiTypography.body1m.textError, - textAlign: TextAlign.center, - ), - const SizedBox(height: UiConstants.space4), - UiButton.secondary( - text: 'Retry', - onPressed: () => - BlocProvider.of(context).add(ClientHomeStarted()), - ), - ], - ), - ); - } } diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/actions_widget.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/actions_widget.dart index 468d6b85..c7dc4fed 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/actions_widget.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/actions_widget.dart @@ -4,10 +4,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import 'section_layout.dart'; + /// A widget that displays quick actions for the client. class ActionsWidget extends StatelessWidget { /// Creates an [ActionsWidget]. - const ActionsWidget({super.key, this.subtitle}); + const ActionsWidget({ + super.key, + this.title, + this.subtitle, + }); + + /// Optional title for the section. + final String? title; /// Optional subtitle for the section. final String? subtitle; @@ -17,38 +26,41 @@ class ActionsWidget extends StatelessWidget { // Check if client_home exists in t final TranslationsClientHomeActionsEn i18n = t.client_home.actions; - return Row( - spacing: UiConstants.space4, - children: [ - Expanded( - child: _ActionCard( - title: i18n.rapid, - subtitle: i18n.rapid_subtitle, - icon: UiIcons.zap, - color: UiColors.tagError.withValues(alpha: 0.5), - borderColor: UiColors.borderError.withValues(alpha: 0.3), - iconBgColor: UiColors.white, - iconColor: UiColors.textError, - textColor: UiColors.textError, - subtitleColor: UiColors.textError.withValues(alpha: 0.8), - onTap: () => Modular.to.toCreateOrderRapid(), + return SectionLayout( + title: title, + child: Row( + spacing: UiConstants.space4, + children: [ + Expanded( + child: _ActionCard( + title: i18n.rapid, + subtitle: i18n.rapid_subtitle, + icon: UiIcons.zap, + color: UiColors.tagError.withValues(alpha: 0.5), + borderColor: UiColors.borderError.withValues(alpha: 0.3), + iconBgColor: UiColors.white, + iconColor: UiColors.textError, + textColor: UiColors.textError, + subtitleColor: UiColors.textError.withValues(alpha: 0.8), + onTap: () => Modular.to.toCreateOrderRapid(), + ), ), - ), - Expanded( - child: _ActionCard( - title: i18n.create_order, - subtitle: i18n.create_order_subtitle, - icon: UiIcons.add, - color: UiColors.white, - borderColor: UiColors.border, - iconBgColor: UiColors.primaryForeground, - iconColor: UiColors.primary, - textColor: UiColors.textPrimary, - subtitleColor: UiColors.textSecondary, - onTap: () => Modular.to.toCreateOrder(), + Expanded( + child: _ActionCard( + title: i18n.create_order, + subtitle: i18n.create_order_subtitle, + icon: UiIcons.add, + color: UiColors.white, + borderColor: UiColors.border, + iconBgColor: UiColors.primaryForeground, + iconColor: UiColors.primary, + textColor: UiColors.textPrimary, + subtitleColor: UiColors.textSecondary, + onTap: () => Modular.to.toCreateOrder(), + ), ), - ), - ], + ], + ), ); } } 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 new file mode 100644 index 00000000..06e65c95 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_body.dart @@ -0,0 +1,45 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../blocs/client_home_bloc.dart'; +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'; + +/// Main body widget for the client home page. +/// +/// Manages the state transitions between error, edit mode, and normal mode views. +class ClientHomeBody extends StatelessWidget { + /// Creates a [ClientHomeBody]. + const ClientHomeBody({super.key}); + + @override + Widget build(BuildContext context) { + return Flexible( + child: BlocConsumer( + listener: (BuildContext context, ClientHomeState state) { + if (state.status == ClientHomeStatus.error && + state.errorMessage != null) { + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, + ); + } + }, + builder: (BuildContext context, ClientHomeState state) { + if (state.status == ClientHomeStatus.error) { + return ClientHomeErrorState(state: state); + } + if (state.isEditMode) { + return ClientHomeEditModeBody(state: state); + } + return ClientHomeNormalModeBody(state: state); + }, + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_mode_body.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_mode_body.dart new file mode 100644 index 00000000..5acdb4bc --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_mode_body.dart @@ -0,0 +1,49 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../blocs/client_home_bloc.dart'; +import '../blocs/client_home_event.dart'; +import '../blocs/client_home_state.dart'; +import 'dashboard_widget_builder.dart'; + +/// Widget that displays the home dashboard in edit mode with drag-and-drop support. +/// +/// Allows users to reorder and rearrange dashboard widgets. +class ClientHomeEditModeBody extends StatelessWidget { + /// The current home state. + final ClientHomeState state; + + /// Creates a [ClientHomeEditModeBody]. + const ClientHomeEditModeBody({ + required this.state, + super.key, + }); + + @override + Widget build(BuildContext context) { + return ReorderableListView( + padding: const EdgeInsets.fromLTRB( + UiConstants.space4, + 0, + UiConstants.space4, + 100, + ), + onReorder: (int oldIndex, int newIndex) { + BlocProvider.of(context) + .add(ClientHomeWidgetReordered(oldIndex, newIndex)); + }, + children: state.widgetOrder.map((String id) { + return Container( + key: ValueKey(id), + margin: const EdgeInsets.only(bottom: UiConstants.space4), + child: DashboardWidgetBuilder( + id: id, + state: state, + isEditMode: true, + ), + ); + }).toList(), + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_error_state.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_error_state.dart new file mode 100644 index 00000000..a1c6e4f5 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_error_state.dart @@ -0,0 +1,52 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../blocs/client_home_bloc.dart'; +import '../blocs/client_home_event.dart'; +import '../blocs/client_home_state.dart'; + +/// Widget that displays an error state for the client home page. +/// +/// Shows an error message with a retry button when data fails to load. +class ClientHomeErrorState extends StatelessWidget { + /// The current home state containing error information. + final ClientHomeState state; + + /// Creates a [ClientHomeErrorState]. + const ClientHomeErrorState({ + required this.state, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.error, + size: 48, + color: UiColors.error, + ), + const SizedBox(height: UiConstants.space4), + Text( + state.errorMessage != null + ? translateErrorKey(state.errorMessage!) + : 'An error occurred', + style: UiTypography.body1m.textError, + textAlign: TextAlign.center, + ), + const SizedBox(height: UiConstants.space4), + UiButton.secondary( + text: 'Retry', + onPressed: () => + BlocProvider.of(context).add(ClientHomeStarted()), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_normal_mode_body.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_normal_mode_body.dart new file mode 100644 index 00000000..9583ece3 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_normal_mode_body.dart @@ -0,0 +1,56 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../blocs/client_home_state.dart'; +import 'dashboard_widget_builder.dart'; + +/// Widget that displays the home dashboard in normal mode. +/// +/// Shows visible dashboard widgets in a vertical scrollable list with dividers. +class ClientHomeNormalModeBody extends StatelessWidget { + /// The current home state. + final ClientHomeState state; + + /// Creates a [ClientHomeNormalModeBody]. + const ClientHomeNormalModeBody({ + required this.state, + super.key, + }); + + @override + Widget build(BuildContext context) { + final List visibleWidgets = state.widgetOrder.where((String id) { + if (id == 'reorder' && state.reorderItems.isEmpty) { + return false; + } + return state.widgetVisibility[id] ?? true; + }).toList(); + + return ListView.separated( + separatorBuilder: (BuildContext context, int index) { + return const Divider(color: UiColors.border, height: 0.1); + }, + itemCount: visibleWidgets.length, + itemBuilder: (BuildContext context, int index) { + final String id = visibleWidgets[index]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (index != 0) const SizedBox(height: UiConstants.space8), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + ), + child: DashboardWidgetBuilder( + id: id, + state: state, + isEditMode: false, + ), + ), + const SizedBox(height: UiConstants.space8), + ], + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/coverage_widget.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/coverage_widget.dart index 63f9c95e..226134c4 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/coverage_widget.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/coverage_widget.dart @@ -2,6 +2,8 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'section_layout.dart'; + /// A widget that displays the daily coverage metrics. class CoverageWidget extends StatelessWidget { /// Creates a [CoverageWidget]. @@ -10,6 +12,7 @@ class CoverageWidget extends StatelessWidget { this.totalNeeded = 0, this.totalConfirmed = 0, this.coveragePercent = 0, + this.title, this.subtitle, }); @@ -22,84 +25,42 @@ class CoverageWidget extends StatelessWidget { /// The percentage of coverage (0-100). final int coveragePercent; + /// Optional title for the section. + final String? title; + /// Optional subtitle for the section. final String? subtitle; @override Widget build(BuildContext context) { - Color backgroundColor; - Color textColor; - - if (coveragePercent == 100) { - backgroundColor = UiColors.tagActive; - textColor = UiColors.textSuccess; - } else if (coveragePercent >= 40) { - backgroundColor = UiColors.tagPending; - textColor = UiColors.textWarning; - } else { - backgroundColor = UiColors.tagError; - textColor = UiColors.textError; - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - t.client_home.dashboard.todays_coverage, - style: UiTypography.footnote1b.copyWith( - color: UiColors.textPrimary, - letterSpacing: 0.5, - ), + return SectionLayout( + title: title, + action: totalNeeded > 0 || totalConfirmed > 0 || coveragePercent > 0 + ? t.client_home.dashboard.percent_covered(percent: coveragePercent) + : null, + child: Row( + children: [ + Expanded( + child: _MetricCard( + icon: UiIcons.target, + iconColor: UiColors.primary, + label: t.client_home.dashboard.metric_needed, + value: '$totalNeeded', ), - if (totalNeeded > 0 || totalConfirmed > 0 || coveragePercent > 0) - Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: - 2, // 2px is not in metrics, using hardcoded for small tweaks or space0/space1 - ), - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: UiConstants.radiusLg, - ), - child: Text( - t.client_home.dashboard.percent_covered( - percent: coveragePercent, - ), - style: UiTypography.footnote2b.copyWith(color: textColor), - ), - ), - ], - ), - if (subtitle != null) ...[ - Text(subtitle!, style: UiTypography.body2r.textSecondary), - ], - const SizedBox(height: UiConstants.space6), - Row( - children: [ + ), + const SizedBox(width: UiConstants.space2), + if (totalConfirmed != 0) Expanded( child: _MetricCard( - icon: UiIcons.target, - iconColor: UiColors.primary, - label: t.client_home.dashboard.metric_needed, - value: '$totalNeeded', + icon: UiIcons.success, + iconColor: UiColors.iconSuccess, + label: t.client_home.dashboard.metric_filled, + value: '$totalConfirmed', + valueColor: UiColors.textSuccess, ), ), - const SizedBox(width: UiConstants.space2), - if (totalConfirmed != 0) - Expanded( - child: _MetricCard( - icon: UiIcons.success, - iconColor: UiColors.iconSuccess, - label: t.client_home.dashboard.metric_filled, - value: '$totalConfirmed', - valueColor: UiColors.textSuccess, - ), - ), - const SizedBox(width: UiConstants.space2), + const SizedBox(width: UiConstants.space2), + if (totalConfirmed != 0) Expanded( child: _MetricCard( icon: UiIcons.error, @@ -109,9 +70,8 @@ class CoverageWidget extends StatelessWidget { valueColor: UiColors.textError, ), ), - ], - ), - ], + ], + ), ); } } diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/dashboard_widget_builder.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/dashboard_widget_builder.dart index 296b04d8..038c6238 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/dashboard_widget_builder.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/dashboard_widget_builder.dart @@ -35,7 +35,7 @@ class DashboardWidgetBuilder extends StatelessWidget { @override Widget build(BuildContext context) { final TranslationsClientHomeWidgetsEn i18n = t.client_home.widgets; - final Widget widgetContent = _buildWidgetContent(context); + final Widget widgetContent = _buildWidgetContent(context, i18n); if (isEditMode) { return DraggableWidgetWrapper( @@ -55,21 +55,27 @@ class DashboardWidgetBuilder extends StatelessWidget { } /// Builds the actual widget content based on the widget ID. - Widget _buildWidgetContent(BuildContext context) { + Widget _buildWidgetContent(BuildContext context, TranslationsClientHomeWidgetsEn i18n) { + final String title = _getWidgetTitle(i18n); // Only show subtitle in normal mode final String? subtitle = !isEditMode ? _getWidgetSubtitle(id) : null; switch (id) { case 'actions': - return ActionsWidget(subtitle: subtitle); + return ActionsWidget(title: title, subtitle: subtitle); case 'reorder': - return ReorderWidget(orders: state.reorderItems, subtitle: subtitle); + return ReorderWidget( + orders: state.reorderItems, + title: title, + subtitle: subtitle, + ); case 'spending': return SpendingWidget( weeklySpending: state.dashboardData.weeklySpending, next7DaysSpending: state.dashboardData.next7DaysSpending, weeklyShifts: state.dashboardData.weeklyShifts, next7DaysScheduled: state.dashboardData.next7DaysScheduled, + title: title, subtitle: subtitle, ); case 'coverage': @@ -82,11 +88,13 @@ class DashboardWidgetBuilder extends StatelessWidget { 100) .toInt() : 0, + title: title, subtitle: subtitle, ); case 'liveActivity': return LiveActivityWidget( onViewAllPressed: () => Modular.to.toClientCoverage(), + title: title, subtitle: subtitle, ); default: diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/live_activity_widget.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/live_activity_widget.dart index 75b7793d..fff0e7e1 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/live_activity_widget.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/live_activity_widget.dart @@ -1,9 +1,10 @@ import 'package:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; import 'package:krow_data_connect/krow_data_connect.dart' as dc; + import 'coverage_dashboard.dart'; +import 'section_layout.dart'; /// A widget that displays live activity information. class LiveActivityWidget extends StatefulWidget { @@ -12,11 +13,15 @@ class LiveActivityWidget extends StatefulWidget { const LiveActivityWidget({ super.key, required this.onViewAllPressed, + this.title, this.subtitle }); /// Callback when "View all" is pressed. final VoidCallback onViewAllPressed; + /// Optional title for the section. + final String? title; + /// Optional subtitle for the section. final String? subtitle; @@ -106,73 +111,46 @@ class _LiveActivityWidgetState extends State { Widget build(BuildContext context) { final TranslationsClientHomeEn i18n = t.client_home; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - i18n.widgets.live_activity.toUpperCase(), - style: UiTypography.footnote1b.textSecondary.copyWith( - letterSpacing: 0.5, - ), - ), - GestureDetector( - onTap: widget.onViewAllPressed, - child: Text( - i18n.dashboard.view_all, - style: UiTypography.footnote1m.copyWith( - color: UiColors.primary, - ), - ), - ), - ], - ), - if (widget.subtitle != null) ...[ - Text( - widget.subtitle!, - style: UiTypography.body2r.textSecondary, - ), - ], - const SizedBox(height: UiConstants.space6), - FutureBuilder<_LiveActivityData>( - future: _liveActivityFuture, - builder: (BuildContext context, - AsyncSnapshot<_LiveActivityData> snapshot) { - final _LiveActivityData data = - snapshot.data ?? _LiveActivityData.empty(); - final List> shifts = - >[ - { - 'workersNeeded': data.totalNeeded, - 'filled': data.totalAssigned, - 'hourlyRate': 1.0, - 'hours': data.totalCost, - 'status': 'OPEN', - 'date': DateTime.now().toIso8601String().split('T')[0], + return SectionLayout( + title: widget.title, + action: i18n.dashboard.view_all, + onAction: widget.onViewAllPressed, + child: FutureBuilder<_LiveActivityData>( + future: _liveActivityFuture, + builder: (BuildContext context, + AsyncSnapshot<_LiveActivityData> snapshot) { + final _LiveActivityData data = + snapshot.data ?? _LiveActivityData.empty(); + final List> shifts = + >[ + { + 'workersNeeded': data.totalNeeded, + 'filled': data.totalAssigned, + 'hourlyRate': 1.0, + 'hours': data.totalCost, + 'status': 'OPEN', + 'date': DateTime.now().toIso8601String().split('T')[0], + }, + ]; + final List> applications = + >[]; + for (int i = 0; i < data.checkedInCount; i += 1) { + applications.add( + { + 'status': 'CONFIRMED', + 'checkInTime': '09:00', }, - ]; - final List> applications = - >[]; - for (int i = 0; i < data.checkedInCount; i += 1) { - applications.add( - { - 'status': 'CONFIRMED', - 'checkInTime': '09:00', - }, - ); - } - for (int i = 0; i < data.lateCount; i += 1) { - applications.add({'status': 'LATE'}); - } - return CoverageDashboard( - shifts: shifts, - applications: applications, ); - }, - ), - ], + } + for (int i = 0; i < data.lateCount; i += 1) { + applications.add({'status': 'LATE'}); + } + return CoverageDashboard( + shifts: shifts, + applications: applications, + ); + }, + ), ); } } diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart index fb1da7d5..47e54e3e 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart @@ -5,14 +5,24 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'section_layout.dart'; + /// A widget that allows clients to reorder recent shifts. class ReorderWidget extends StatelessWidget { /// Creates a [ReorderWidget]. - const ReorderWidget({super.key, required this.orders, this.subtitle}); + const ReorderWidget({ + super.key, + required this.orders, + this.title, + this.subtitle, + }); /// Recent completed orders for reorder. final List orders; + /// Optional title for the section. + final String? title; + /// Optional subtitle for the section. final String? subtitle; @@ -26,147 +36,134 @@ class ReorderWidget extends StatelessWidget { final List recentOrders = orders; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - i18n.title, - style: UiTypography.footnote1b.textSecondary.copyWith( - letterSpacing: 0.5, - ), - ), - if (subtitle != null) ...[ - const SizedBox(height: UiConstants.space1), - Text(subtitle!, style: UiTypography.body2r.textSecondary), - ], - const SizedBox(height: UiConstants.space2), - SizedBox( - height: 164, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: recentOrders.length, - separatorBuilder: (BuildContext context, int index) => - const SizedBox(width: UiConstants.space3), - itemBuilder: (BuildContext context, int index) { - final ReorderItem order = recentOrders[index]; - final double totalCost = order.totalCost; + return SectionLayout( + title: title, + child: SizedBox( + height: 164, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: recentOrders.length, + separatorBuilder: (BuildContext context, int index) => + const SizedBox(width: UiConstants.space3), + itemBuilder: (BuildContext context, int index) { + final ReorderItem order = recentOrders[index]; + final double totalCost = order.totalCost; - return Container( - width: 260, - padding: const EdgeInsets.all(UiConstants.space3), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border, width: 0.6), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: UiColors.primary.withValues( - alpha: 0.1, - ), - borderRadius: UiConstants.radiusLg, - ), - child: const Icon( - UiIcons.building, - size: 16, - color: UiColors.primary, - ), - ), - const SizedBox(width: UiConstants.space2), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - order.title, - style: UiTypography.body2b, - overflow: TextOverflow.ellipsis, - ), - Text( - order.location, - style: - UiTypography.footnote1r.textSecondary, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, + return Container( + width: 260, + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border, width: 0.6), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( children: [ - Text( - '\$${totalCost.toStringAsFixed(0)}', - style: UiTypography.body1b, + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: UiColors.primary.withValues( + alpha: 0.1, + ), + borderRadius: UiConstants.radiusLg, + ), + child: const Icon( + UiIcons.building, + size: 16, + color: UiColors.primary, + ), ), - Text( - '${i18n.per_hr(amount: order.hourlyRate.toString())} · ${order.hours}h', - style: UiTypography.footnote2r.textSecondary, + const SizedBox(width: UiConstants.space2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + order.title, + style: UiTypography.body2b, + overflow: TextOverflow.ellipsis, + ), + Text( + order.location, + style: + UiTypography.footnote1r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ], + ), ), ], ), - ], - ), - const SizedBox(height: UiConstants.space3), - Row( - children: [ - _Badge( - icon: UiIcons.success, - text: order.type, - color: UiColors.primary, - bg: UiColors.buttonSecondaryStill, - textColor: UiColors.primary, - ), - const SizedBox(width: UiConstants.space2), - _Badge( - icon: UiIcons.building, - text: '${order.workers}', - color: UiColors.textSecondary, - bg: UiColors.buttonSecondaryStill, - textColor: UiColors.textSecondary, - ), - ], - ), - const Spacer(), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '\$${totalCost.toStringAsFixed(0)}', + style: UiTypography.body1b, + ), + Text( + '${i18n.per_hr(amount: order.hourlyRate.toString())} · ${order.hours}h', + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), + ], + ), + const SizedBox(height: UiConstants.space3), + Row( + children: [ + _Badge( + icon: UiIcons.success, + text: order.type, + color: UiColors.primary, + bg: UiColors.buttonSecondaryStill, + textColor: UiColors.primary, + ), + const SizedBox(width: UiConstants.space2), + _Badge( + icon: UiIcons.building, + text: '${order.workers}', + color: UiColors.textSecondary, + bg: UiColors.buttonSecondaryStill, + textColor: UiColors.textSecondary, + ), + ], + ), + const Spacer(), - UiButton.secondary( - size: UiButtonSize.small, - text: i18n.reorder_button, - leadingIcon: UiIcons.zap, - iconSize: 12, - fullWidth: true, - onPressed: () => - _handleReorderPressed(context, { - 'orderId': order.orderId, - 'title': order.title, - 'location': order.location, - 'hourlyRate': order.hourlyRate, - 'hours': order.hours, - 'workers': order.workers, - 'type': order.type, - 'totalCost': order.totalCost, - }), - ), - ], - ), - ); - }, - ), + UiButton.secondary( + size: UiButtonSize.small, + text: i18n.reorder_button, + leadingIcon: UiIcons.zap, + iconSize: 12, + fullWidth: true, + onPressed: () => + _handleReorderPressed(context, { + 'orderId': order.orderId, + 'title': order.title, + 'location': order.location, + 'hourlyRate': order.hourlyRate, + 'hours': order.hours, + 'workers': order.workers, + 'type': order.type, + 'totalCost': order.totalCost, + }), + ), + ], + ), + ); + }, ), - ], + ), ); } diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/section_header.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/section_header.dart new file mode 100644 index 00000000..d8438fa5 --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/section_header.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; + +import 'package:design_system/design_system.dart'; + +/// Section header widget for home page sections, using design system tokens. +class SectionHeader extends StatelessWidget { + /// Section title + final String title; + + /// Optional subtitle + final String? subtitle; + + /// Optional action label + final String? action; + + /// Optional action callback + final VoidCallback? onAction; + + /// Creates a [SectionHeader]. + const SectionHeader({ + super.key, + required this.title, + this.subtitle, + this.action, + this.onAction, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: action != null + ? Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: UiTypography.body1b), + if (onAction != null) + GestureDetector( + onTap: onAction, + child: Row( + children: [ + Text( + action ?? '', + style: UiTypography.body3r.textSecondary, + ), + const Icon( + UiIcons.chevronRight, + size: UiConstants.space4, + color: UiColors.iconSecondary, + ), + ], + ), + ) + else + Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: UiColors.primary.withValues(alpha: 0.08), + borderRadius: UiConstants.radiusMd, + border: Border.all( + color: UiColors.primary, + width: 0.5, + ), + ), + child: Text( + action!, + style: UiTypography.body3r.primary, + ), + ), + ], + ) + : Text(title, style: UiTypography.body1b), + ), + ], + ), + ), + if (subtitle != null) + Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: Text( + subtitle!, + style: UiTypography.body2r.textSecondary, + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/section_layout.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/section_layout.dart new file mode 100644 index 00000000..b4cdd88c --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/section_layout.dart @@ -0,0 +1,61 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'section_header.dart'; + +/// A common layout widget for home page sections. +/// +/// Provides consistent structure with optional header and content area. +/// Use this to ensure all sections follow the same layout pattern. +class SectionLayout extends StatelessWidget { + + /// Creates a [SectionLayout]. + const SectionLayout({ + this.title, + this.subtitle, + this.action, + this.onAction, + required this.child, + this.contentPadding, + super.key, + }); + /// The title of the section, displayed in the header. + final String? title; + + /// Optional subtitle for the section. + final String? subtitle; + + /// Optional action text/widget to display on the right side of the header. + final String? action; + + /// Optional callback when action is tapped. + final VoidCallback? onAction; + + /// The main content of the section. + final Widget child; + + /// Optional padding for the content area. + /// Defaults to no padding. + final EdgeInsetsGeometry? contentPadding; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) + Padding( + padding: contentPadding ?? EdgeInsets.zero, + child: SectionHeader( + title: title!, + subtitle: subtitle, + action: action, + onAction: onAction, + ), + ), + const SizedBox(height: UiConstants.space2), + child, + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/spending_widget.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/spending_widget.dart index f2beac80..6ecb66a8 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/spending_widget.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/spending_widget.dart @@ -2,6 +2,8 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'section_layout.dart'; + /// A widget that displays spending insights for the client. class SpendingWidget extends StatelessWidget { @@ -12,6 +14,7 @@ class SpendingWidget extends StatelessWidget { required this.next7DaysSpending, required this.weeklyShifts, required this.next7DaysScheduled, + this.title, this.subtitle, }); /// The spending this week. @@ -26,117 +29,103 @@ class SpendingWidget extends StatelessWidget { /// The number of scheduled shifts for next 7 days. final int next7DaysScheduled; + /// Optional title for the section. + final String? title; + /// Optional subtitle for the section. final String? subtitle; @override Widget build(BuildContext context) { - final TranslationsClientHomeEn i18n = t.client_home; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - i18n.widgets.spending.toUpperCase(), - style: UiTypography.footnote1b.textSecondary.copyWith( - letterSpacing: 0.5, + return SectionLayout( + title: title, + child: Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + UiColors.primary, + UiColors.primary.withValues(alpha: 0.85), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), - ), - if (subtitle != null) ...[ - Text( - subtitle!, - style: UiTypography.body2r.textSecondary, - ), - ], - const SizedBox(height: UiConstants.space6), - Container( - padding: const EdgeInsets.all(UiConstants.space3), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - UiColors.primary, - UiColors.primary.withValues(alpha: 0.85), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, + borderRadius: UiConstants.radiusLg, + boxShadow: [ + BoxShadow( + color: UiColors.primary.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 4), ), - borderRadius: UiConstants.radiusLg, - boxShadow: [ - BoxShadow( - color: UiColors.primary.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 4), - ), - ], - ), - child: Column( - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - t.client_home.dashboard.spending.this_week, - style: UiTypography.footnote2r.white.copyWith( - color: UiColors.white.withValues(alpha: 0.7), - fontSize: 9, - ), - ), - const SizedBox(height: UiConstants.space1), - Text( - '\$${weeklySpending.toStringAsFixed(0)}', - style: UiTypography.headline3m.copyWith( - color: UiColors.white, - fontWeight: FontWeight.bold, - ), - ), - Text( - t.client_home.dashboard.spending.shifts_count(count: weeklyShifts), - style: UiTypography.footnote2r.white.copyWith( - color: UiColors.white.withValues(alpha: 0.6), - fontSize: 9, - ), - ), - ], - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - t.client_home.dashboard.spending.next_7_days, - style: UiTypography.footnote2r.white.copyWith( - color: UiColors.white.withValues(alpha: 0.7), - fontSize: 9, - ), - ), - const SizedBox(height: UiConstants.space1), - Text( - '\$${next7DaysSpending.toStringAsFixed(0)}', - style: UiTypography.headline4m.copyWith( - color: UiColors.white, - fontWeight: FontWeight.bold, - ), - ), - Text( - t.client_home.dashboard.spending.scheduled_count(count: next7DaysScheduled), - style: UiTypography.footnote2r.white.copyWith( - color: UiColors.white.withValues(alpha: 0.6), - fontSize: 9, - ), - ), - ], - ), - ), - ], - ), - ], - ), + ], ), - ], + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.client_home.dashboard.spending.this_week, + style: UiTypography.footnote2r.white.copyWith( + color: UiColors.white.withValues(alpha: 0.7), + fontSize: 9, + ), + ), + const SizedBox(height: UiConstants.space1), + Text( + '\$${weeklySpending.toStringAsFixed(0)}', + style: UiTypography.headline3m.copyWith( + color: UiColors.white, + fontWeight: FontWeight.bold, + ), + ), + Text( + t.client_home.dashboard.spending.shifts_count(count: weeklyShifts), + style: UiTypography.footnote2r.white.copyWith( + color: UiColors.white.withValues(alpha: 0.6), + fontSize: 9, + ), + ), + ], + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + t.client_home.dashboard.spending.next_7_days, + style: UiTypography.footnote2r.white.copyWith( + color: UiColors.white.withValues(alpha: 0.7), + fontSize: 9, + ), + ), + const SizedBox(height: UiConstants.space1), + Text( + '\$${next7DaysSpending.toStringAsFixed(0)}', + style: UiTypography.headline4m.copyWith( + color: UiColors.white, + fontWeight: FontWeight.bold, + ), + ), + Text( + t.client_home.dashboard.spending.scheduled_count(count: next7DaysScheduled), + style: UiTypography.footnote2r.white.copyWith( + color: UiColors.white.withValues(alpha: 0.6), + fontSize: 9, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), ); } }