diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart index 770f1d68..e5f0f4d5 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart @@ -20,6 +20,7 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { @override Future getProfileCompletion() async { + return true; return _service.run(() async { final String staffId = await _service.getStaffId(); diff --git a/apps/mobile/packages/design_system/lib/src/ui_typography.dart b/apps/mobile/packages/design_system/lib/src/ui_typography.dart index eb436569..42567ce4 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_typography.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_typography.dart @@ -402,7 +402,7 @@ class UiTypography { /// Body 4 Medium - Font: Instrument Sans, Size: 14, Height: 1.5, Spacing: 0.05 (#121826) static final TextStyle body4m = _primaryBase.copyWith( fontWeight: FontWeight.w500, - fontSize: 12, + fontSize: 10, height: 1.5, letterSpacing: 0.05, color: UiColors.textPrimary, diff --git a/apps/mobile/packages/domain/lib/src/entities/benefits/benefit.dart b/apps/mobile/packages/domain/lib/src/entities/benefits/benefit.dart index 26eba20c..15bb6f99 100644 --- a/apps/mobile/packages/domain/lib/src/entities/benefits/benefit.dart +++ b/apps/mobile/packages/domain/lib/src/entities/benefits/benefit.dart @@ -2,6 +2,13 @@ import 'package:equatable/equatable.dart'; /// Represents a staff member's benefit balance. class Benefit extends Equatable { + /// Creates a [Benefit]. + const Benefit({ + required this.title, + required this.entitlementHours, + required this.usedHours, + }); + /// The title of the benefit (e.g., Sick Leave, Holiday, Vacation). final String title; @@ -14,13 +21,6 @@ class Benefit extends Equatable { /// The hours remaining. double get remainingHours => entitlementHours - usedHours; - /// Creates a [Benefit]. - const Benefit({ - required this.title, - required this.entitlementHours, - required this.usedHours, - }); - @override List get props => [title, entitlementHours, usedHours]; } 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..652504fa 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,40 @@ 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( + 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_dashboard.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/coverage_dashboard.dart index ac6fe59b..2e9dd11a 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/coverage_dashboard.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/coverage_dashboard.dart @@ -62,13 +62,6 @@ class CoverageDashboard extends StatelessWidget { color: UiColors.white, borderRadius: UiConstants.radiusLg, border: Border.all(color: UiColors.border, width: 0.5), - boxShadow: [ - BoxShadow( - color: UiColors.black.withValues(alpha: 0.02), - blurRadius: 4, - offset: const Offset(0, 1), - ), - ], ), child: Column( children: [ @@ -171,17 +164,17 @@ class _StatusCard extends StatelessWidget { Color textColor = UiColors.textPrimary; if (isWarning) { - bg = UiColors.tagPending; - border = UiColors.borderStill; + bg = UiColors.tagPending.withAlpha(80); + border = UiColors.textWarning.withAlpha(80); iconColor = UiColors.textWarning; textColor = UiColors.textWarning; } else if (isError) { - bg = UiColors.tagError; - border = UiColors.borderError; + bg = UiColors.tagError.withAlpha(80); + border = UiColors.borderError.withAlpha(80); iconColor = UiColors.textError; textColor = UiColors.textError; } else if (isInfo) { - bg = UiColors.tagInProgress; + bg = UiColors.tagInProgress.withAlpha(80); border = UiColors.primary.withValues(alpha: 0.2); iconColor = UiColors.primary; textColor = UiColors.primary; 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..c933a611 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,43 @@ 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, + subtitle: subtitle, + 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 +71,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..4aef5629 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,47 @@ 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, + subtitle: widget.subtitle, + 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..2d0baa23 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,135 @@ 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, + subtitle: subtitle, + 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..2066daed --- /dev/null +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/section_header.dart @@ -0,0 +1,100 @@ +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 { + + /// Creates a [SectionHeader]. + const SectionHeader({ + super.key, + required this.title, + this.subtitle, + this.action, + this.onAction, + }); + /// Section title + final String title; + + /// Optional subtitle + final String? subtitle; + + /// Optional action label + final String? action; + + /// Optional action callback + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: subtitle != null + ? EdgeInsets.zero + : 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..0ebb262b 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,104 @@ 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, + subtitle: subtitle, + 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, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), ); } } diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart new file mode 100644 index 00000000..f6f1bffb --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart @@ -0,0 +1,42 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_home/src/domain/repositories/home_repository.dart'; + +part 'benefits_overview_state.dart'; + +/// Cubit to manage benefits overview page state. +class BenefitsOverviewCubit extends Cubit + with BlocErrorHandler { + final HomeRepository _repository; + + BenefitsOverviewCubit({required HomeRepository repository}) + : _repository = repository, + super(const BenefitsOverviewState.initial()); + + Future loadBenefits() async { + if (isClosed) return; + emit(state.copyWith(status: BenefitsOverviewStatus.loading)); + await handleError( + emit: emit, + action: () async { + final benefits = await _repository.getBenefits(); + if (isClosed) return; + emit( + state.copyWith( + status: BenefitsOverviewStatus.loaded, + benefits: benefits, + ), + ); + }, + onError: (String errorKey) { + if (isClosed) return state; + return state.copyWith( + status: BenefitsOverviewStatus.error, + errorMessage: errorKey, + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_state.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_state.dart new file mode 100644 index 00000000..768a2146 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_state.dart @@ -0,0 +1,33 @@ +part of 'benefits_overview_cubit.dart'; + +enum BenefitsOverviewStatus { initial, loading, loaded, error } + +class BenefitsOverviewState extends Equatable { + final BenefitsOverviewStatus status; + final List benefits; + final String? errorMessage; + + const BenefitsOverviewState({ + required this.status, + this.benefits = const [], + this.errorMessage, + }); + + const BenefitsOverviewState.initial() + : this(status: BenefitsOverviewStatus.initial); + + BenefitsOverviewState copyWith({ + BenefitsOverviewStatus? status, + List? benefits, + String? errorMessage, + }) { + return BenefitsOverviewState( + status: status ?? this.status, + benefits: benefits ?? this.benefits, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [status, benefits, errorMessage]; +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_cubit.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home/home_cubit.dart similarity index 100% rename from apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_cubit.dart rename to apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home/home_cubit.dart diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_state.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home/home_state.dart similarity index 100% rename from apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_state.dart rename to apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home/home_state.dart diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart index b017c9f2..fad93b89 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart @@ -1,12 +1,10 @@ -import 'dart:math' as math; - 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 'package:krow_domain/krow_domain.dart'; -import 'package:staff_home/src/presentation/blocs/home_cubit.dart'; +import 'package:staff_home/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart'; +import 'package:staff_home/src/presentation/widgets/benefits_overview/benefits_overview_body.dart'; /// Page displaying a detailed overview of the worker's benefits. class BenefitsOverviewPage extends StatelessWidget { @@ -15,33 +13,37 @@ class BenefitsOverviewPage extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider.value( - value: Modular.get(), - child: Scaffold( - backgroundColor: const Color(0xFFF8FAFC), - appBar: _buildAppBar(context), - body: BlocBuilder( + return Scaffold( + appBar: UiAppBar( + title: t.staff.home.benefits.overview.title, + subtitle: t.staff.home.benefits.overview.subtitle, + showBackButton: true, + ), + body: BlocProvider( + create: (context) => + Modular.get()..loadBenefits(), + child: BlocBuilder( builder: (context, state) { - if (state.status == HomeStatus.loading || - state.status == HomeStatus.initial) { + if (state.status == BenefitsOverviewStatus.loading || + state.status == BenefitsOverviewStatus.initial) { return const Center(child: CircularProgressIndicator()); } - - if (state.status == HomeStatus.error) { + + if (state.status == BenefitsOverviewStatus.error) { return Center( child: Padding( padding: const EdgeInsets.all(UiConstants.space6), child: Text( - state.errorMessage ?? t.staff.home.benefits.overview.subtitle, + state.errorMessage ?? + t.staff.home.benefits.overview.subtitle, style: UiTypography.body1r.textSecondary, textAlign: TextAlign.center, ), ), ); } - - final benefits = state.benefits; - if (benefits.isEmpty) { + + if (state.benefits.isEmpty) { return Center( child: Padding( padding: const EdgeInsets.all(UiConstants.space6), @@ -53,401 +55,11 @@ class BenefitsOverviewPage extends StatelessWidget { ), ); } - - return ListView.builder( - padding: const EdgeInsets.only( - left: UiConstants.space4, - right: UiConstants.space4, - top: UiConstants.space6, - bottom: 120, - ), - itemCount: benefits.length, - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space4), - child: _BenefitCard(benefit: benefits[index]), - ); - }, - ); + + return BenefitsOverviewBody(benefits: state.benefits); }, ), ), ); } - - PreferredSizeWidget _buildAppBar(BuildContext context) { - return AppBar( - backgroundColor: Colors.white, - elevation: 0, - leading: IconButton( - icon: const Icon(UiIcons.chevronLeft, color: UiColors.iconPrimary), - onPressed: () => Navigator.of(context).pop(), - ), - centerTitle: true, - title: Column( - children: [ - Text( - t.staff.home.benefits.overview.title, - style: UiTypography.title2b.textPrimary, - ), - const SizedBox(height: 2), - Text( - t.staff.home.benefits.overview.subtitle, - style: UiTypography.footnote2r.textSecondary, - ), - ], - ), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1), - child: Container(color: UiColors.border.withOpacity(0.5), height: 1), - ), - ); - } - } - - class _BenefitCard extends StatelessWidget { - final Benefit benefit; - - const _BenefitCard({required this.benefit}); - - @override - Widget build(BuildContext context) { - final bool isSickLeave = benefit.title.toLowerCase().contains('sick'); - final bool isVacation = benefit.title.toLowerCase().contains('vacation'); - final bool isHolidays = benefit.title.toLowerCase().contains('holiday'); - - final i18n = t.staff.home.benefits.overview; - - return Container( - padding: const EdgeInsets.all(UiConstants.space6), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border.withOpacity(0.5)), - boxShadow: [ - BoxShadow( - color: UiColors.black.withOpacity(0.02), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - _buildProgressCircle(), - const SizedBox(width: UiConstants.space4), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - benefit.title, - style: UiTypography.body1b.textPrimary, - ), - const SizedBox(height: 4), - Text( - _getSubtitle(benefit.title), - style: UiTypography.footnote2r.textSecondary, - ), - const SizedBox(height: UiConstants.space4), - _buildStatsRow(), - ], - ), - ), - ], - ), - const SizedBox(height: UiConstants.space6), - if (isSickLeave) ...[ - _AccordionHistory(label: i18n.sick_leave_history), - const SizedBox(height: UiConstants.space6), - ], - if (isVacation || isHolidays) ...[ - _buildComplianceBanner(i18n.compliance_banner), - const SizedBox(height: UiConstants.space6), - ], - SizedBox( - width: double.infinity, - child: UiButton.primary( - text: i18n.request_payment(benefit: benefit.title), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF0038A8), - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - onPressed: () { - // TODO: Implement payment request - UiSnackbar.show(context, message: i18n.request_submitted(benefit: benefit.title), type: UiSnackbarType.success); - }, - ), - ), - ], - ), - ); - } - - Widget _buildProgressCircle() { - final double progress = benefit.entitlementHours > 0 - ? (benefit.remainingHours / benefit.entitlementHours) - : 0.0; - - final bool isSickLeave = benefit.title.toLowerCase().contains('sick'); - final Color circleColor = isSickLeave ? const Color(0xFF2563EB) : const Color(0xFF10B981); - - return SizedBox( - width: 72, - height: 72, - child: CustomPaint( - painter: _CircularProgressPainter( - progress: progress, - color: circleColor, - backgroundColor: const Color(0xFFE2E8F0), - strokeWidth: 6, - ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '${benefit.remainingHours.toInt()}/${benefit.entitlementHours.toInt()}', - style: UiTypography.body2b.textPrimary.copyWith(fontSize: 14), - ), - Text( - t.client_billing.hours_suffix, - style: UiTypography.footnote2r.textTertiary.copyWith(fontSize: 9), - ), - ], - ), - ), - ), - ); - } - - Widget _buildStatsRow() { - final i18n = t.staff.home.benefits.overview; - return Row( - children: [ - _buildStatChip( - i18n.entitlement, - '${benefit.entitlementHours.toInt()}', - ), - const SizedBox(width: 8), - _buildStatChip( - i18n.used, - '${benefit.usedHours.toInt()}', - ), - const SizedBox(width: 8), - _buildStatChip( - i18n.remaining, - '${benefit.remainingHours.toInt()}', - ), - ], - ); - } - - Widget _buildStatChip(String label, String value) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFFF1F5F9), - borderRadius: BorderRadius.circular(6), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: UiTypography.footnote2r.textTertiary.copyWith( - fontSize: 10, - ), - ), - Text( - '$value ${t.staff.home.benefits.overview.hours}', - style: UiTypography.footnote2b.textPrimary.copyWith( - fontSize: 12, - ), - ), - ], - ), - ); - } - - String _getSubtitle(String title) { - final i18n = t.staff.home.benefits.overview; - if (title.toLowerCase().contains('sick')) { - return i18n.sick_leave_subtitle; - } else if (title.toLowerCase().contains('vacation')) { - return i18n.vacation_subtitle; - } else if (title.toLowerCase().contains('holiday')) { - return i18n.holidays_subtitle; - } - return ''; - } - - Widget _buildComplianceBanner(String text) { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFECFDF5), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(UiIcons.checkCircle, size: 16, color: Color(0xFF10B981)), - const SizedBox(width: 8), - Expanded( - child: Text( - text, - style: UiTypography.footnote1r.copyWith( - color: const Color(0xFF065F46), - fontSize: 11, - ), - ), - ), - ], - ), - ); - } - } - -class _CircularProgressPainter extends CustomPainter { - final double progress; - final Color color; - final Color backgroundColor; - final double strokeWidth; - - _CircularProgressPainter({ - required this.progress, - required this.color, - required this.backgroundColor, - required this.strokeWidth, - }); - - @override - void paint(Canvas canvas, Size size) { - final center = Offset(size.width / 2, size.height / 2); - final radius = (size.width - strokeWidth) / 2; - - final backgroundPaint = Paint() - ..color = backgroundColor - ..style = PaintingStyle.stroke - ..strokeWidth = strokeWidth; - canvas.drawCircle(center, radius, backgroundPaint); - - final progressPaint = Paint() - ..color = color - ..style = PaintingStyle.stroke - ..strokeWidth = strokeWidth - ..strokeCap = StrokeCap.round; - final sweepAngle = 2 * math.pi * progress; - canvas.drawArc( - Rect.fromCircle(center: center, radius: radius), - -math.pi / 2, - sweepAngle, - false, - progressPaint, - ); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => true; -} - -class _AccordionHistory extends StatefulWidget { - final String label; - - const _AccordionHistory({required this.label}); - - @override - State<_AccordionHistory> createState() => _AccordionHistoryState(); -} - -class _AccordionHistoryState extends State<_AccordionHistory> { - bool _isExpanded = false; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Divider(height: 1, color: Color(0xFFE2E8F0)), - InkWell( - onTap: () { - setState(() { - _isExpanded = !_isExpanded; - }); - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - widget.label, - style: UiTypography.footnote2b.textSecondary.copyWith( - letterSpacing: 0.5, - fontSize: 11, - ), - ), - Icon( - _isExpanded ? UiIcons.chevronUp : UiIcons.chevronDown, - size: 16, - color: UiColors.iconSecondary, - ), - ], - ), - ), - ), - if (_isExpanded) ...[ - _buildHistoryItem('1 Jan, 2024', 'Pending', const Color(0xFFF1F5F9), const Color(0xFF64748B)), - const SizedBox(height: 14), - _buildHistoryItem('28 Jan, 2024', 'Submitted', const Color(0xFFECFDF5), const Color(0xFF10B981)), - const SizedBox(height: 14), - _buildHistoryItem('5 Feb, 2024', 'Submitted', const Color(0xFFECFDF5), const Color(0xFF10B981)), - const SizedBox(height: 14), - _buildHistoryItem('28 Jan, 2024', 'Submitted', const Color(0xFFECFDF5), const Color(0xFF10B981)), - const SizedBox(height: 14), - _buildHistoryItem('5 Feb, 2024', 'Submitted', const Color(0xFFECFDF5), const Color(0xFF10B981)), - const SizedBox(height: 4), - ] - ], - ); - } - - Widget _buildHistoryItem(String date, String status, Color bgColor, Color textColor) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - date, - style: UiTypography.footnote1r.textSecondary.copyWith( - fontSize: 12, - color: const Color(0xFF64748B), - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(12), - border: status == 'Pending' ? Border.all(color: const Color(0xFFE2E8F0)) : null, - ), - child: Text( - status, - style: UiTypography.footnote2m.copyWith( - color: textColor, - fontSize: 10, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ); - } } 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 d6ac2559..5045548b 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 @@ -4,16 +4,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; - -import 'package:staff_home/src/presentation/blocs/home_cubit.dart'; -import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart'; +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/placeholder_banner.dart'; -import 'package:staff_home/src/presentation/widgets/home_page/quick_action_item.dart'; -import 'package:staff_home/src/presentation/widgets/home_page/recommended_shift_card.dart'; -import 'package:staff_home/src/presentation/widgets/home_page/section_header.dart'; -import 'package:staff_home/src/presentation/widgets/shift_card.dart'; -import 'package:staff_home/src/presentation/widgets/worker/benefits_widget.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'; +import 'package:staff_home/src/presentation/widgets/home_page/tomorrows_shifts_section.dart'; /// The home page for the staff worker application. /// @@ -36,9 +35,6 @@ class WorkerHomePage extends StatelessWidget { final t = Translations.of(context); final i18n = t.staff.home; final bannersI18n = i18n.banners; - final quickI18n = i18n.quick_actions; - final sectionsI18n = i18n.sections; - final emptyI18n = i18n.empty_states; return BlocProvider.value( value: Modular.get()..loadShifts(), @@ -67,8 +63,7 @@ class WorkerHomePage extends StatelessWidget { builder: (context, state) { if (!state.isProfileComplete) { return SizedBox( - height: MediaQuery.of(context).size.height - - 300, + height: MediaQuery.of(context).size.height - 300, child: Column( children: [ PlaceholderBanner( @@ -85,7 +80,8 @@ class WorkerHomePage extends StatelessWidget { child: UiEmptyState( icon: UiIcons.users, title: 'Complete Your Profile', - description: 'Finish setting up your profile to unlock shifts, view earnings, and start earning today.', + description: + 'Finish setting up your profile to unlock shifts, view earnings, and start earning today.', ), ), ], @@ -96,147 +92,23 @@ class WorkerHomePage extends StatelessWidget { return Column( children: [ // Quick Actions - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: QuickActionItem( - icon: UiIcons.search, - label: quickI18n.find_shifts, - onTap: () => Modular.to.toShifts(), - ), - ), - Expanded( - child: QuickActionItem( - icon: UiIcons.calendar, - label: quickI18n.availability, - onTap: () => Modular.to.toAvailability(), - ), - ), - Expanded( - child: QuickActionItem( - icon: UiIcons.dollar, - label: quickI18n.earnings, - onTap: () => Modular.to.toPayments(), - ), - ), - ], - ), - const SizedBox(height: UiConstants.space6), + const QuickActionsSection(), + const FullWidthDivider(), // Today's Shifts - BlocBuilder( - builder: (context, state) { - final shifts = state.todayShifts; - return Column( - children: [ - SectionHeader( - title: sectionsI18n.todays_shift, - action: shifts.isNotEmpty - ? sectionsI18n.scheduled_count( - count: shifts.length, - ) - : null, - ), - if (state.status == HomeStatus.loading) - const Center( - child: SizedBox( - height: UiConstants.space10, - width: UiConstants.space10, - child: CircularProgressIndicator( - color: UiColors.primary, - ), - ), - ) - else if (shifts.isEmpty) - EmptyStateWidget( - message: emptyI18n.no_shifts_today, - actionLink: emptyI18n.find_shifts_cta, - onAction: () => - Modular.to.toShifts(initialTab: 'find'), - ) - else - Column( - children: shifts - .map( - (shift) => ShiftCard( - shift: shift, - compact: true, - ), - ) - .toList(), - ), - ], - ); - }, - ), - const SizedBox(height: UiConstants.space3), + const TodaysShiftsSection(), + const FullWidthDivider(), // Tomorrow's Shifts - BlocBuilder( - builder: (context, state) { - final shifts = state.tomorrowShifts; - return Column( - children: [ - SectionHeader(title: sectionsI18n.tomorrow), - if (shifts.isEmpty) - EmptyStateWidget( - message: emptyI18n.no_shifts_tomorrow, - ) - else - Column( - children: shifts - .map( - (shift) => ShiftCard( - shift: shift, - compact: true, - ), - ) - .toList(), - ), - ], - ); - }, - ), - const SizedBox(height: UiConstants.space3), + const TomorrowsShiftsSection(), + const FullWidthDivider(), // Recommended Shifts - SectionHeader(title: sectionsI18n.recommended_for_you), - BlocBuilder( - builder: (context, state) { - if (state.recommendedShifts.isEmpty) { - return EmptyStateWidget( - message: emptyI18n.no_recommended_shifts, - ); - } - return SizedBox( - height: 160, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: state.recommendedShifts.length, - clipBehavior: Clip.none, - itemBuilder: (context, index) => Padding( - padding: const EdgeInsets.only( - right: UiConstants.space3, - ), - child: RecommendedShiftCard( - shift: state.recommendedShifts[index], - ), - ), - ), - ); - }, - ), - const SizedBox(height: UiConstants.space6), + const RecommendedShiftsSection(), + const FullWidthDivider(), // Benefits - BlocBuilder( - buildWhen: (previous, current) => - previous.benefits != current.benefits, - builder: (context, state) { - return BenefitsWidget(benefits: state.benefits); - }, - ), + const BenefitsSection(), const SizedBox(height: UiConstants.space6), ], ); diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/accordion_history.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/accordion_history.dart new file mode 100644 index 00000000..89ce65e2 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/accordion_history.dart @@ -0,0 +1,140 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Widget displaying collapsible benefit history. +class AccordionHistory extends StatefulWidget { + /// The label for the accordion header. + final String label; + + /// Creates an [AccordionHistory]. + const AccordionHistory({required this.label, super.key}); + + @override + State createState() => _AccordionHistoryState(); +} + +class _AccordionHistoryState extends State { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(height: 1, color: Color(0xFFE2E8F0)), + InkWell( + onTap: () { + setState(() { + _isExpanded = !_isExpanded; + }); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + widget.label, + style: UiTypography.footnote2b.textSecondary.copyWith( + letterSpacing: 0.5, + fontSize: 11, + ), + ), + Icon( + _isExpanded ? UiIcons.chevronUp : UiIcons.chevronDown, + size: 16, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ), + if (_isExpanded) ...[ + _HistoryItem( + date: '1 Jan, 2024', + status: 'Pending', + bgColor: const Color(0xFFF1F5F9), + textColor: const Color(0xFF64748B), + ), + const SizedBox(height: 14), + _HistoryItem( + date: '28 Jan, 2024', + status: 'Submitted', + bgColor: const Color(0xFFECFDF5), + textColor: const Color(0xFF10B981), + ), + const SizedBox(height: 14), + _HistoryItem( + date: '5 Feb, 2024', + status: 'Submitted', + bgColor: const Color(0xFFECFDF5), + textColor: const Color(0xFF10B981), + ), + const SizedBox(height: 14), + _HistoryItem( + date: '28 Jan, 2024', + status: 'Submitted', + bgColor: const Color(0xFFECFDF5), + textColor: const Color(0xFF10B981), + ), + const SizedBox(height: 14), + _HistoryItem( + date: '5 Feb, 2024', + status: 'Submitted', + bgColor: const Color(0xFFECFDF5), + textColor: const Color(0xFF10B981), + ), + const SizedBox(height: 4), + ], + ], + ); + } +} + +class _HistoryItem extends StatelessWidget { + final String date; + final String status; + final Color bgColor; + final Color textColor; + + const _HistoryItem({ + required this.date, + required this.status, + required this.bgColor, + required this.textColor, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + date, + style: UiTypography.footnote1r.textSecondary.copyWith( + fontSize: 12, + color: const Color(0xFF64748B), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(12), + border: status == 'Pending' + ? Border.all(color: const Color(0xFFE2E8F0)) + : null, + ), + child: Text( + status, + style: UiTypography.footnote2m.copyWith( + color: textColor, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card.dart new file mode 100644 index 00000000..1294f979 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card.dart @@ -0,0 +1,47 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_home/src/presentation/widgets/benefits_overview/benefit_card_header.dart'; + +/// Card widget displaying detailed benefit information. +class BenefitCard extends StatelessWidget { + /// The benefit to display. + final Benefit benefit; + + /// Creates a [BenefitCard]. + const BenefitCard({required this.benefit, super.key}); + + @override + Widget build(BuildContext context) { + final bool isSickLeave = benefit.title.toLowerCase().contains('sick'); + final bool isVacation = benefit.title.toLowerCase().contains('vacation'); + final bool isHolidays = benefit.title.toLowerCase().contains('holiday'); + + final i18n = t.staff.home.benefits.overview; + + return Container( + padding: const EdgeInsets.all(UiConstants.space6), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border, width: 0.5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BenefitCardHeader(benefit: benefit), + // const SizedBox(height: UiConstants.space6), + // if (isSickLeave) ...[ + // AccordionHistory(label: i18n.sick_leave_history), + // const SizedBox(height: UiConstants.space6), + // ], + // if (isVacation || isHolidays) ...[ + // ComplianceBanner(text: i18n.compliance_banner), + // const SizedBox(height: UiConstants.space6), + // ], + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card_header.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card_header.dart new file mode 100644 index 00000000..3be875c0 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card_header.dart @@ -0,0 +1,111 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_home/src/presentation/widgets/benefits_overview/circular_progress_painter.dart'; +import 'package:staff_home/src/presentation/widgets/benefits_overview/stat_chip.dart'; + +/// Header section of a benefit card showing progress circle, title, and stats. +class BenefitCardHeader extends StatelessWidget { + /// The benefit to display. + final Benefit benefit; + + /// Creates a [BenefitCardHeader]. + const BenefitCardHeader({required this.benefit, super.key}); + + @override + Widget build(BuildContext context) { + final i18n = t.staff.home.benefits.overview; + + return Row( + children: [ + _buildProgressCircle(), + const SizedBox(width: UiConstants.space4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + benefit.title, + style: UiTypography.body1b.textPrimary, + ), + if (_getSubtitle(benefit.title).isNotEmpty) ...[ + const SizedBox(height: UiConstants.space2), + Text( + _getSubtitle(benefit.title), + style: UiTypography.body3r.textSecondary, + ), + ], + const SizedBox(height: UiConstants.space4), + _buildStatsRow(i18n), + ], + ), + ), + ], + ); + } + + Widget _buildProgressCircle() { + final double progress = benefit.entitlementHours > 0 + ? (benefit.remainingHours / benefit.entitlementHours) + : 0.0; + + return SizedBox( + width: 72, + height: 72, + child: CustomPaint( + painter: CircularProgressPainter( + progress: progress, + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${benefit.remainingHours.toInt()}/${benefit.entitlementHours.toInt()}', + style: UiTypography.body2b.textPrimary.copyWith(fontSize: 14), + ), + Text( + t.client_billing.hours_suffix, + style: UiTypography.footnote1r.textSecondary + ), + ], + ), + ), + ), + ); + } + + Widget _buildStatsRow(dynamic i18n) { + return Row( + children: [ + StatChip( + label: i18n.entitlement, + value: '${benefit.entitlementHours.toInt()}', + ), + const SizedBox(width: 8), + StatChip( + label: i18n.used, + value: '${benefit.usedHours.toInt()}', + ), + const SizedBox(width: 8), + StatChip( + label: i18n.remaining, + value: '${benefit.remainingHours.toInt()}', + ), + ], + ); + } + + String _getSubtitle(String title) { + final i18n = t.staff.home.benefits.overview; + if (title.toLowerCase().contains('sick')) { + return i18n.sick_leave_subtitle; + } else if (title.toLowerCase().contains('vacation')) { + return i18n.vacation_subtitle; + } else if (title.toLowerCase().contains('holiday')) { + return i18n.holidays_subtitle; + } + return ''; + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefits_overview_body.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefits_overview_body.dart new file mode 100644 index 00000000..94d9d8e8 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefits_overview_body.dart @@ -0,0 +1,32 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_home/src/presentation/widgets/benefits_overview/benefit_card.dart'; + +/// Body widget displaying a list of benefit cards. +class BenefitsOverviewBody extends StatelessWidget { + /// The list of benefits to display. + final List benefits; + + /// Creates a [BenefitsOverviewBody]. + const BenefitsOverviewBody({required this.benefits, super.key}); + + @override + Widget build(BuildContext context) { + return ListView.builder( + padding: const EdgeInsets.only( + left: UiConstants.space4, + right: UiConstants.space4, + top: UiConstants.space6, + bottom: 120, + ), + itemCount: benefits.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space4), + child: BenefitCard(benefit: benefits[index]), + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/circular_progress_painter.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/circular_progress_painter.dart new file mode 100644 index 00000000..fe711cc2 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/circular_progress_painter.dart @@ -0,0 +1,48 @@ +import 'dart:math' as math; + +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Custom painter for circular progress indicators. +class CircularProgressPainter extends CustomPainter { + final double progress; + final Color color; + final Color backgroundColor; + final double strokeWidth; + + CircularProgressPainter({ + required this.progress, + this.strokeWidth = UiConstants.space1, + this.color = UiColors.primary, + this.backgroundColor = UiColors.primaryInverse, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = (size.width - strokeWidth) / 2; + + final backgroundPaint = Paint() + ..color = backgroundColor + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth; + canvas.drawCircle(center, radius, backgroundPaint); + + final progressPaint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + final sweepAngle = 2 * math.pi * progress; + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -math.pi / 2, + sweepAngle, + false, + progressPaint, + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/compliance_banner.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/compliance_banner.dart new file mode 100644 index 00000000..170ef438 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/compliance_banner.dart @@ -0,0 +1,45 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Widget displaying a compliance information banner. +class ComplianceBanner extends StatelessWidget { + /// The text to display in the banner. + final String text; + + /// Creates a [ComplianceBanner]. + const ComplianceBanner({ + required this.text, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFECFDF5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + UiIcons.checkCircle, + size: 16, + color: Color(0xFF10B981), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: UiTypography.footnote1r.copyWith( + color: const Color(0xFF065F46), + fontSize: 11, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/stat_chip.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/stat_chip.dart new file mode 100644 index 00000000..dd14350f --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/stat_chip.dart @@ -0,0 +1,44 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Widget displaying a single statistic chip. +class StatChip extends StatelessWidget { + /// The label for the stat (e.g., "Entitlement", "Used", "Remaining"). + final String label; + + /// The numeric value to display. + final String value; + + /// Creates a [StatChip]. + const StatChip({ + required this.label, + required this.value, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: UiColors.primaryForeground, + borderRadius: BorderRadius.circular(6), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: UiTypography.footnote2r.textSecondary, + ), + Text( + '$value ${t.staff.home.benefits.overview.hours}', + style: UiTypography.footnote1b.textPrimary, + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/benefits_section.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/benefits_section.dart new file mode 100644 index 00000000..edcd4caa --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/benefits_section.dart @@ -0,0 +1,40 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; + +import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart'; +import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dart'; +import 'package:staff_home/src/presentation/widgets/worker/worker_benefits/benefits_widget.dart'; + +/// A widget that displays the benefits section. +/// +/// Shows available benefits for the worker with state management +/// via BLoC to rebuild only when benefits data changes. +class BenefitsSection extends StatelessWidget { + /// Creates a [BenefitsSection]. + const BenefitsSection({super.key}); + + @override + Widget build(BuildContext context) { + final i18n = t.staff.home.benefits; + + return BlocBuilder( + buildWhen: (previous, current) => + previous.benefits != current.benefits, + builder: (context, state) { + if (state.benefits.isEmpty) { + return const SizedBox.shrink(); + } + + return SectionLayout( + title: i18n.title, + action: i18n.view_all, + onAction: () => Modular.to.toBenefits(), + child: BenefitsWidget(benefits: state.benefits), + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/full_width_divider.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/full_width_divider.dart new file mode 100644 index 00000000..3ffaf542 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/full_width_divider.dart @@ -0,0 +1,27 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A divider that extends to full screen width, breaking out of parent padding. +/// +/// This widget uses Transform.translate to shift the divider horizontally +/// to span the entire device width. +class FullWidthDivider extends StatelessWidget { + /// Creates a [FullWidthDivider]. + const FullWidthDivider({super.key}); + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + + return Column( + children: [ + const SizedBox(height: UiConstants.space10), + Transform.translate( + offset: const Offset(-UiConstants.space4, 0), + child: SizedBox(width: screenWidth, child: const Divider()), + ), + const SizedBox(height: UiConstants.space10), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/quick_actions_section.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/quick_actions_section.dart new file mode 100644 index 00000000..a137e28a --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/quick_actions_section.dart @@ -0,0 +1,48 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:staff_home/src/presentation/widgets/home_page/quick_action_item.dart'; + +/// A widget that displays quick action buttons for common tasks. +/// +/// This section provides easy access to frequently used features like +/// finding shifts, setting availability, and viewing earnings. +class QuickActionsSection extends StatelessWidget { + /// Creates a [QuickActionsSection]. + const QuickActionsSection({super.key}); + + @override + Widget build(BuildContext context) { + final t = Translations.of(context); + final quickI18n = t.staff.home.quick_actions; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: QuickActionItem( + icon: UiIcons.search, + label: quickI18n.find_shifts, + onTap: () => Modular.to.toShifts(), + ), + ), + Expanded( + child: QuickActionItem( + icon: UiIcons.calendar, + label: quickI18n.availability, + onTap: () => Modular.to.toAvailability(), + ), + ), + Expanded( + child: QuickActionItem( + icon: UiIcons.dollar, + label: quickI18n.earnings, + onTap: () => Modular.to.toPayments(), + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart index 1dd260f2..51f863d3 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart @@ -13,68 +13,34 @@ class RecommendedShiftCard extends StatelessWidget { @override Widget build(BuildContext context) { final recI18n = t.staff.home.recommended_card; + final size = MediaQuery.sizeOf(context); return GestureDetector( onTap: () { Modular.to.toShiftDetails(shift); }, child: Container( - width: 300, + width: size.width * 0.8, padding: const EdgeInsets.all(UiConstants.space4), decoration: BoxDecoration( color: UiColors.white, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all(color: UiColors.border), - boxShadow: [ - BoxShadow( - color: UiColors.black.withValues(alpha: 0.02), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border, width: 0.5), ), child: SingleChildScrollView( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Row( - children: [ - Text( - recI18n.act_now, - style: UiTypography.body3m.copyWith( - color: UiColors.textError, - ), - ), - const SizedBox(width: UiConstants.space2), - Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: 2, - ), - decoration: BoxDecoration( - color: UiColors.tagInProgress, - borderRadius: UiConstants.radiusFull, - ), - child: Text( - recI18n.one_day, - style: UiTypography.body3m.textPrimary, - ), - ), - ], - ), - const SizedBox(height: UiConstants.space3), - Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( width: UiConstants.space10, height: UiConstants.space10, decoration: BoxDecoration( color: UiColors.tagInProgress, - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, - ), + borderRadius: UiConstants.radiusLg, ), child: const Icon( UiIcons.calendar, @@ -85,10 +51,11 @@ class RecommendedShiftCard extends StatelessWidget { const SizedBox(width: UiConstants.space3), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: UiConstants.space1, children: [ Flexible( child: Text( @@ -99,13 +66,13 @@ class RecommendedShiftCard extends StatelessWidget { ), Text( '\$${shift.hourlyRate}/h', - style: UiTypography.headline4m.textPrimary, + style: UiTypography.headline4b, ), ], ), - const SizedBox(height: 2), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: UiConstants.space1, children: [ Text( shift.clientName, diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shifts_section.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shifts_section.dart new file mode 100644 index 00000000..0410bc1f --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shifts_section.dart @@ -0,0 +1,51 @@ +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:staff_home/src/presentation/blocs/home/home_cubit.dart'; +import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart'; +import 'package:staff_home/src/presentation/widgets/home_page/recommended_shift_card.dart'; +import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dart'; + +/// A widget that displays recommended shifts section. +/// +/// Shows a horizontal scrolling list of shifts recommended for the worker +/// based on their profile and preferences. +class RecommendedShiftsSection extends StatelessWidget { + /// Creates a [RecommendedShiftsSection]. + const RecommendedShiftsSection({super.key}); + + @override + Widget build(BuildContext context) { + final t = Translations.of(context); + final sectionsI18n = t.staff.home.sections; + final emptyI18n = t.staff.home.empty_states; + final size = MediaQuery.sizeOf(context); + + return SectionLayout( + title: sectionsI18n.recommended_for_you, + child: BlocBuilder( + builder: (context, state) { + if (state.recommendedShifts.isEmpty) { + return EmptyStateWidget(message: emptyI18n.no_recommended_shifts); + } + return SizedBox( + height: size.height * 0.15, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: state.recommendedShifts.length, + clipBehavior: Clip.none, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.only(right: UiConstants.space3), + child: RecommendedShiftCard( + shift: state.recommendedShifts[index], + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/section_header.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/section_header.dart index c5e7f4fa..8bf10f5f 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/section_header.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/section_header.dart @@ -39,11 +39,14 @@ class SectionHeader extends StatelessWidget { onTap: onAction, child: Row( children: [ - Text(action ?? '', style: UiTypography.body3r), + Text( + action ?? '', + style: UiTypography.body3r.textSecondary, + ), const Icon( UiIcons.chevronRight, size: UiConstants.space4, - color: UiColors.primary, + color: UiColors.iconSecondary, ), ], ), diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/section_layout.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/section_layout.dart new file mode 100644 index 00000000..4d21c539 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/section_layout.dart @@ -0,0 +1,56 @@ +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 { + /// The title of the section, displayed in the header. + final String? title; + + /// 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; + + /// Creates a [SectionLayout]. + const SectionLayout({ + this.title, + this.action, + this.onAction, + required this.child, + this.contentPadding, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) + Padding( + padding: contentPadding ?? EdgeInsets.zero, + child: SectionHeader( + title: title!, + action: action, + onAction: onAction, + ), + ), + const SizedBox(height: UiConstants.space2), + child, + ], + ); + } +} 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 new file mode 100644 index 00000000..764da501 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/todays_shifts_section.dart @@ -0,0 +1,68 @@ +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 'package:krow_core/core.dart'; + +import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart'; +import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart'; +import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dart'; +import 'package:staff_home/src/presentation/widgets/shift_card.dart'; + +/// A widget that displays today's shifts section. +/// +/// Shows a list of shifts scheduled for today, with loading state +/// and empty state handling. +class TodaysShiftsSection extends StatelessWidget { + /// Creates a [TodaysShiftsSection]. + const TodaysShiftsSection({super.key}); + + @override + Widget build(BuildContext context) { + final t = Translations.of(context); + final sectionsI18n = t.staff.home.sections; + final emptyI18n = t.staff.home.empty_states; + + return BlocBuilder( + builder: (context, state) { + final shifts = state.todayShifts; + return SectionLayout( + title: sectionsI18n.todays_shift, + action: shifts.isNotEmpty + ? sectionsI18n.scheduled_count( + count: shifts.length, + ) + : null, + child: state.status == HomeStatus.loading + ? const Center( + child: SizedBox( + height: UiConstants.space10, + width: UiConstants.space10, + child: CircularProgressIndicator( + color: UiColors.primary, + ), + ), + ) + : shifts.isEmpty + ? EmptyStateWidget( + message: emptyI18n.no_shifts_today, + actionLink: emptyI18n.find_shifts_cta, + onAction: () => + Modular.to.toShifts(initialTab: 'find'), + ) + : Column( + children: shifts + .map( + (shift) => ShiftCard( + shift: shift, + compact: true, + ), + ) + .toList(), + ), + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/tomorrows_shifts_section.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/tomorrows_shifts_section.dart new file mode 100644 index 00000000..66cf393f --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/tomorrows_shifts_section.dart @@ -0,0 +1,47 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart'; +import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart'; +import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dart'; +import 'package:staff_home/src/presentation/widgets/shift_card.dart'; + +/// A widget that displays tomorrow's shifts section. +/// +/// Shows a list of shifts scheduled for tomorrow with empty state handling. +class TomorrowsShiftsSection extends StatelessWidget { + /// Creates a [TomorrowsShiftsSection]. + const TomorrowsShiftsSection({super.key}); + + @override + Widget build(BuildContext context) { + final t = Translations.of(context); + final sectionsI18n = t.staff.home.sections; + final emptyI18n = t.staff.home.empty_states; + + return BlocBuilder( + builder: (context, state) { + final shifts = state.tomorrowShifts; + + return SectionLayout( + title: sectionsI18n.tomorrow, + child: shifts.isEmpty + ? EmptyStateWidget( + message: emptyI18n.no_shifts_tomorrow, + ) + : Column( + children: shifts + .map( + (shift) => ShiftCard( + shift: shift, + compact: true, + ), + ) + .toList(), + ), + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/benefits_widget.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/benefits_widget.dart deleted file mode 100644 index 84031223..00000000 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/benefits_widget.dart +++ /dev/null @@ -1,202 +0,0 @@ -import 'dart:math' as math; - -import 'package:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; - -/// Widget for displaying staff benefits, using design system tokens. -class BenefitsWidget extends StatelessWidget { - /// The list of benefits to display. - final List benefits; - - /// Creates a [BenefitsWidget]. - const BenefitsWidget({ - required this.benefits, - super.key, - }); - - @override - Widget build(BuildContext context) { - final i18n = t.staff.home.benefits; - - if (benefits.isEmpty) { - return const SizedBox.shrink(); - } - - return Container( - padding: const EdgeInsets.all(UiConstants.space5), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border.withOpacity(0.5)), - boxShadow: [ - BoxShadow( - color: UiColors.black.withOpacity(0.03), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - i18n.title, - style: UiTypography.body1b.textPrimary, - ), - GestureDetector( - onTap: () => Modular.to.toBenefits(), - child: Row( - children: [ - Text( - i18n.view_all, - style: UiTypography.footnote2r.copyWith( - color: const Color(0xFF2563EB), - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(width: 4), - const Icon( - UiIcons.chevronRight, - size: 14, - color: Color(0xFF2563EB), - ), - ], - ), - ), - ], - ), - const SizedBox(height: UiConstants.space6), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: benefits.map((Benefit benefit) { - return Expanded( - child: _BenefitItem( - label: benefit.title, - remaining: benefit.remainingHours, - total: benefit.entitlementHours, - used: benefit.usedHours, - color: const Color(0xFF2563EB), - ), - ); - }).toList(), - ), - ], - ), - ); - } -} - -class _BenefitItem extends StatelessWidget { - final String label; - final double remaining; - final double total; - final double used; - final Color color; - - const _BenefitItem({ - required this.label, - required this.remaining, - required this.total, - required this.used, - required this.color, - }); - - @override - Widget build(BuildContext context) { - final double progress = total > 0 ? (remaining / total) : 0.0; - - return Column( - children: [ - SizedBox( - width: 64, - height: 64, - child: CustomPaint( - painter: _CircularProgressPainter( - progress: progress, - color: color, - backgroundColor: const Color(0xFFE2E8F0), - strokeWidth: 5, - ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '${remaining.toInt()}/${total.toInt()}', - style: UiTypography.body2b.textPrimary.copyWith( - fontSize: 12, - letterSpacing: -0.5, - ), - ), - Text( - 'hours', - style: UiTypography.footnote2r.textTertiary.copyWith( - fontSize: 8, - ), - ), - ], - ), - ), - ), - ), - const SizedBox(height: UiConstants.space3), - Text( - label, - style: UiTypography.footnote2r.textSecondary.copyWith( - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ], - ); - } -} - -class _CircularProgressPainter extends CustomPainter { - final double progress; - final Color color; - final Color backgroundColor; - final double strokeWidth; - - _CircularProgressPainter({ - required this.progress, - required this.color, - required this.backgroundColor, - required this.strokeWidth, - }); - - @override - void paint(Canvas canvas, Size size) { - final center = Offset(size.width / 2, size.height / 2); - final radius = (size.width - strokeWidth) / 2; - - final backgroundPaint = Paint() - ..color = backgroundColor - ..style = PaintingStyle.stroke - ..strokeWidth = strokeWidth; - canvas.drawCircle(center, radius, backgroundPaint); - - final progressPaint = Paint() - ..color = color - ..style = PaintingStyle.stroke - ..strokeWidth = strokeWidth - ..strokeCap = StrokeCap.round; - final sweepAngle = 2 * math.pi * progress; - canvas.drawArc( - Rect.fromCircle(center: center, radius: radius), - -math.pi / 2, - sweepAngle, - false, - progressPaint, - ); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => true; -} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/worker_benefits/benefit_item.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/worker_benefits/benefit_item.dart new file mode 100644 index 00000000..9fa913fa --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/worker_benefits/benefit_item.dart @@ -0,0 +1,72 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:staff_home/src/presentation/widgets/worker/worker_benefits/circular_progress_painter.dart'; + +/// A widget that displays a single benefit item with circular progress. +/// +/// Shows remaining hours, total hours, and a progress indicator. +class BenefitItem extends StatelessWidget { + /// The label of the benefit (e.g., "Sick Leave", "PTO"). + final String label; + + /// The remaining hours available. + final double remaining; + + /// The total hours entitled. + final double total; + + /// The hours already used. + final double used; + + /// Creates a [BenefitItem]. + const BenefitItem({ + required this.label, + required this.remaining, + required this.total, + required this.used, + super.key, + }); + + @override + Widget build(BuildContext context) { + final double progress = total > 0 ? (remaining / total) : 0.0; + + return Column( + children: [ + SizedBox( + width: 64, + height: 64, + child: CustomPaint( + painter: CircularProgressPainter( + progress: progress, + color: UiColors.primary, + backgroundColor: UiColors.primaryInverse, + strokeWidth: 5, + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${remaining.toInt()}/${total.toInt()}', + style: UiTypography.body2b + ), + Text( + 'hours', + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), + ), + ), + ), + const SizedBox(height: UiConstants.space2), + Text( + label, + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/worker_benefits/benefits_view_all_link.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/worker_benefits/benefits_view_all_link.dart new file mode 100644 index 00000000..98b13050 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/worker_benefits/benefits_view_all_link.dart @@ -0,0 +1,39 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; + +/// A link widget that navigates to the full benefits page. +/// +/// Displays "View all" text with a chevron icon. +class BenefitsViewAllLink extends StatelessWidget { + /// Creates a [BenefitsViewAllLink]. + const BenefitsViewAllLink({super.key}); + + @override + Widget build(BuildContext context) { + final i18n = t.staff.home.benefits; + + return GestureDetector( + onTap: () => Modular.to.toBenefits(), + child: Row( + children: [ + Text( + i18n.view_all, + style: UiTypography.footnote2r.copyWith( + color: const Color(0xFF2563EB), + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 4), + const Icon( + UiIcons.chevronRight, + size: 14, + color: Color(0xFF2563EB), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/worker_benefits/benefits_widget.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/worker_benefits/benefits_widget.dart new file mode 100644 index 00000000..a66bd82b --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/worker_benefits/benefits_widget.dart @@ -0,0 +1,38 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_home/src/presentation/widgets/worker/worker_benefits/benefit_item.dart'; +/// Widget for displaying staff benefits, using design system tokens. +/// +/// Shows a list of benefits with circular progress indicators. +class BenefitsWidget extends StatelessWidget { + /// The list of benefits to display. + final List benefits; + + /// Creates a [BenefitsWidget]. + const BenefitsWidget({required this.benefits, super.key}); + + @override + Widget build(BuildContext context) { + if (benefits.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(top: UiConstants.space4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: benefits.map((Benefit benefit) { + return Expanded( + child: BenefitItem( + label: benefit.title, + remaining: benefit.remainingHours, + total: benefit.entitlementHours, + used: benefit.usedHours, + ), + ); + }).toList(), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/worker_benefits/circular_progress_painter.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/worker_benefits/circular_progress_painter.dart new file mode 100644 index 00000000..38b38ed0 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/worker_benefits/circular_progress_painter.dart @@ -0,0 +1,59 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// A custom painter for drawing circular progress indicators. +/// +/// Draws a background circle and a progress arc on top of it. +class CircularProgressPainter extends CustomPainter { + /// The progress value (0.0 to 1.0). + final double progress; + + /// The color of the progress arc. + final Color color; + + /// The color of the background circle. + final Color backgroundColor; + + /// The width of the stroke. + final double strokeWidth; + + /// Creates a [CircularProgressPainter]. + CircularProgressPainter({ + required this.progress, + required this.color, + required this.backgroundColor, + required this.strokeWidth, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = (size.width - strokeWidth) / 2; + + // Draw background circle + final backgroundPaint = Paint() + ..color = backgroundColor + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth; + canvas.drawCircle(center, radius, backgroundPaint); + + // Draw progress arc + final progressPaint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + final sweepAngle = 2 * math.pi * progress; + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -math.pi / 2, + sweepAngle, + false, + progressPaint, + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart b/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart index 74cc76c4..0b319174 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart @@ -4,7 +4,8 @@ import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:staff_home/src/data/repositories/home_repository_impl.dart'; import 'package:staff_home/src/domain/repositories/home_repository.dart'; -import 'package:staff_home/src/presentation/blocs/home_cubit.dart'; +import 'package:staff_home/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart'; +import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart'; import 'package:staff_home/src/presentation/pages/benefits_overview_page.dart'; import 'package:staff_home/src/presentation/pages/worker_home_page.dart'; @@ -31,13 +32,18 @@ class StaffHomeModule extends Module { ), ); - // Presentation layer - Cubit + // Presentation layer - Cubits i.addSingleton( () => HomeCubit( repository: i.get(), getProfileCompletion: i.get(), ), ); + + // Cubit for benefits overview page + i.addLazySingleton( + () => BenefitsOverviewCubit(repository: i.get()), + ); } @override diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart index 814b5932..6e10209b 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_cubit.dart @@ -17,6 +17,10 @@ class StaffMainCubit extends Cubit implements Disposable { final GetProfileCompletionUseCase _getProfileCompletionUsecase; bool _isLoadingCompletion = false; + static const List _hideBottomPaths = [ + StaffPaths.benefits, + ]; + void _onRouteChanged() { if (isClosed) return; @@ -40,8 +44,10 @@ class StaffMainCubit extends Cubit implements Disposable { newIndex = 4; } - if (newIndex != state.currentIndex) { - emit(state.copyWith(currentIndex: newIndex)); + final bool showBottomBar = !_hideBottomPaths.any(path.contains); + + if (newIndex != state.currentIndex || showBottomBar != state.showBottomBar) { + emit(state.copyWith(currentIndex: newIndex, showBottomBar: showBottomBar)); } } diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_state.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_state.dart index 0903b877..86667bfd 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_state.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/blocs/staff_main_state.dart @@ -4,18 +4,25 @@ class StaffMainState extends Equatable { const StaffMainState({ this.currentIndex = 2, // Default to Home this.isProfileComplete = false, + this.showBottomBar = true, }); final int currentIndex; final bool isProfileComplete; + final bool showBottomBar; - StaffMainState copyWith({int? currentIndex, bool? isProfileComplete}) { + StaffMainState copyWith({ + int? currentIndex, + bool? isProfileComplete, + bool? showBottomBar, + }) { return StaffMainState( currentIndex: currentIndex ?? this.currentIndex, isProfileComplete: isProfileComplete ?? this.isProfileComplete, + showBottomBar: showBottomBar ?? this.showBottomBar, ); } @override - List get props => [currentIndex, isProfileComplete]; + List get props => [currentIndex, isProfileComplete, showBottomBar]; } diff --git a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/pages/staff_main_page.dart b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/pages/staff_main_page.dart index 10ae9f8f..bfcae589 100644 --- a/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/pages/staff_main_page.dart +++ b/apps/mobile/packages/features/staff/staff_main/lib/src/presentation/pages/staff_main_page.dart @@ -24,6 +24,7 @@ class StaffMainPage extends StatelessWidget { body: const RouterOutlet(), bottomNavigationBar: BlocBuilder( builder: (BuildContext context, StaffMainState state) { + if (!state.showBottomBar) return const SizedBox.shrink(); return StaffMainBottomBar( currentIndex: state.currentIndex, onTap: (int index) { diff --git a/backend/dataconnect/dataconnect.yaml b/backend/dataconnect/dataconnect.yaml index 39e01fdb..9e1775d6 100644 --- a/backend/dataconnect/dataconnect.yaml +++ b/backend/dataconnect/dataconnect.yaml @@ -1,5 +1,5 @@ specVersion: "v1" -serviceId: "krow-workforce-db" +serviceId: "krow-workforce-db-validation" location: "us-central1" schema: source: "./schema" @@ -7,7 +7,7 @@ schema: postgresql: database: "krow_db" cloudSql: - instanceId: "krow-sql" + instanceId: "krow-sql-validation" # schemaValidation: "STRICT" # STRICT mode makes Postgres schema match Data Connect exactly. # schemaValidation: "COMPATIBLE" # COMPATIBLE mode makes Postgres schema compatible with Data Connect. connectorDirs: ["./connector"] diff --git a/docs/MOBILE/04-use-case-completion-audit.md b/docs/MOBILE/04-use-case-completion-audit.md index d215b556..d17b86a0 100644 --- a/docs/MOBILE/04-use-case-completion-audit.md +++ b/docs/MOBILE/04-use-case-completion-audit.md @@ -316,4 +316,3 @@ --- *This document was generated by comprehensive code analysis of `apps/mobile/apps/` and `apps/mobile/packages/features/` cross-referenced against use case documentation in `docs/ARCHITECTURE/`. All status determinations are based on actual implementation presence: feature packages, page files, BLoC/Cubit classes, use case classes, and data layer components.* - diff --git a/makefiles/dataconnect.mk b/makefiles/dataconnect.mk index 4c9f7ef9..9006a982 100644 --- a/makefiles/dataconnect.mk +++ b/makefiles/dataconnect.mk @@ -7,7 +7,7 @@ # make dataconnect-clean DC_ENV=validation # make dataconnect-generate-sdk DC_ENV=dev # -DC_ENV ?= dev +DC_ENV ?= validation DC_LOCATION ?= us-central1 DC_CONNECTOR_ID ?= example