Merge dev into feature/session-persistence-new

This commit is contained in:
2026-03-04 10:19:28 +05:30
51 changed files with 1913 additions and 1364 deletions

View File

@@ -20,6 +20,7 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
@override @override
Future<bool> getProfileCompletion() async { Future<bool> getProfileCompletion() async {
return true;
return _service.run(() async { return _service.run(() async {
final String staffId = await _service.getStaffId(); final String staffId = await _service.getStaffId();

View File

@@ -402,7 +402,7 @@ class UiTypography {
/// Body 4 Medium - Font: Instrument Sans, Size: 14, Height: 1.5, Spacing: 0.05 (#121826) /// Body 4 Medium - Font: Instrument Sans, Size: 14, Height: 1.5, Spacing: 0.05 (#121826)
static final TextStyle body4m = _primaryBase.copyWith( static final TextStyle body4m = _primaryBase.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
fontSize: 12, fontSize: 10,
height: 1.5, height: 1.5,
letterSpacing: 0.05, letterSpacing: 0.05,
color: UiColors.textPrimary, color: UiColors.textPrimary,

View File

@@ -2,6 +2,13 @@ import 'package:equatable/equatable.dart';
/// Represents a staff member's benefit balance. /// Represents a staff member's benefit balance.
class Benefit extends Equatable { 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). /// The title of the benefit (e.g., Sick Leave, Holiday, Vacation).
final String title; final String title;
@@ -14,13 +21,6 @@ class Benefit extends Equatable {
/// The hours remaining. /// The hours remaining.
double get remainingHours => entitlementHours - usedHours; double get remainingHours => entitlementHours - usedHours;
/// Creates a [Benefit].
const Benefit({
required this.title,
required this.entitlementHours,
required this.usedHours,
});
@override @override
List<Object?> get props => [title, entitlementHours, usedHours]; List<Object?> get props => [title, entitlementHours, usedHours];
} }

View File

@@ -1,15 +1,12 @@
import 'package:core_localization/core_localization.dart'; import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import '../blocs/client_home_bloc.dart'; import '../blocs/client_home_bloc.dart';
import '../blocs/client_home_event.dart'; import '../widgets/client_home_body.dart';
import '../blocs/client_home_state.dart';
import '../widgets/client_home_edit_banner.dart'; import '../widgets/client_home_edit_banner.dart';
import '../widgets/client_home_header.dart'; import '../widgets/client_home_header.dart';
import '../widgets/dashboard_widget_builder.dart';
/// The main Home page for client users. /// The main Home page for client users.
/// ///
@@ -21,133 +18,23 @@ class ClientHomePage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsClientHomeEn i18n = t.client_home;
return BlocProvider<ClientHomeBloc>( return BlocProvider<ClientHomeBloc>(
create: (BuildContext context) => Modular.get<ClientHomeBloc>(), create: (BuildContext context) => Modular.get<ClientHomeBloc>(),
child: Scaffold( child: Scaffold(
body: SafeArea( body: SafeArea(
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
ClientHomeHeader(i18n: i18n), ClientHomeHeader(
ClientHomeEditBanner(i18n: i18n), i18n: t.client_home,
Flexible(
child: BlocConsumer<ClientHomeBloc, ClientHomeState>(
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);
},
),
), ),
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<ClientHomeBloc>(
context,
).add(ClientHomeWidgetReordered(oldIndex, newIndex));
},
children: state.widgetOrder.map((String id) {
return Container(
key: ValueKey<String>(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<String> 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: <Widget>[
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: <Widget>[
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<ClientHomeBloc>(context).add(ClientHomeStarted()),
),
],
),
);
}
} }

View File

@@ -4,10 +4,19 @@ import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'section_layout.dart';
/// A widget that displays quick actions for the client. /// A widget that displays quick actions for the client.
class ActionsWidget extends StatelessWidget { class ActionsWidget extends StatelessWidget {
/// Creates an [ActionsWidget]. /// 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. /// Optional subtitle for the section.
final String? subtitle; final String? subtitle;
@@ -17,38 +26,40 @@ class ActionsWidget extends StatelessWidget {
// Check if client_home exists in t // Check if client_home exists in t
final TranslationsClientHomeActionsEn i18n = t.client_home.actions; final TranslationsClientHomeActionsEn i18n = t.client_home.actions;
return Row( return SectionLayout(
spacing: UiConstants.space4, child: Row(
children: <Widget>[ spacing: UiConstants.space4,
Expanded( children: <Widget>[
child: _ActionCard( Expanded(
title: i18n.rapid, child: _ActionCard(
subtitle: i18n.rapid_subtitle, title: i18n.rapid,
icon: UiIcons.zap, subtitle: i18n.rapid_subtitle,
color: UiColors.tagError.withValues(alpha: 0.5), icon: UiIcons.zap,
borderColor: UiColors.borderError.withValues(alpha: 0.3), color: UiColors.tagError.withValues(alpha: 0.5),
iconBgColor: UiColors.white, borderColor: UiColors.borderError.withValues(alpha: 0.3),
iconColor: UiColors.textError, iconBgColor: UiColors.white,
textColor: UiColors.textError, iconColor: UiColors.textError,
subtitleColor: UiColors.textError.withValues(alpha: 0.8), textColor: UiColors.textError,
onTap: () => Modular.to.toCreateOrderRapid(), subtitleColor: UiColors.textError.withValues(alpha: 0.8),
onTap: () => Modular.to.toCreateOrderRapid(),
),
), ),
), Expanded(
Expanded( child: _ActionCard(
child: _ActionCard( title: i18n.create_order,
title: i18n.create_order, subtitle: i18n.create_order_subtitle,
subtitle: i18n.create_order_subtitle, icon: UiIcons.add,
icon: UiIcons.add, color: UiColors.white,
color: UiColors.white, borderColor: UiColors.border,
borderColor: UiColors.border, iconBgColor: UiColors.primaryForeground,
iconBgColor: UiColors.primaryForeground, iconColor: UiColors.primary,
iconColor: UiColors.primary, textColor: UiColors.textPrimary,
textColor: UiColors.textPrimary, subtitleColor: UiColors.textSecondary,
subtitleColor: UiColors.textSecondary, onTap: () => Modular.to.toCreateOrder(),
onTap: () => Modular.to.toCreateOrder(), ),
), ),
), ],
], ),
); );
} }
} }

View File

@@ -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<ClientHomeBloc, ClientHomeState>(
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);
},
),
);
}
}

View File

@@ -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<ClientHomeBloc>(context)
.add(ClientHomeWidgetReordered(oldIndex, newIndex));
},
children: state.widgetOrder.map((String id) {
return Container(
key: ValueKey<String>(id),
margin: const EdgeInsets.only(bottom: UiConstants.space4),
child: DashboardWidgetBuilder(
id: id,
state: state,
isEditMode: true,
),
);
}).toList(),
);
}
}

View File

@@ -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: <Widget>[
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<ClientHomeBloc>(context).add(ClientHomeStarted()),
),
],
),
);
}
}

View File

@@ -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<String> 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: <Widget>[
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),
],
);
},
);
}
}

View File

@@ -62,13 +62,6 @@ class CoverageDashboard extends StatelessWidget {
color: UiColors.white, color: UiColors.white,
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border, width: 0.5), border: Border.all(color: UiColors.border, width: 0.5),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.02),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
), ),
child: Column( child: Column(
children: <Widget>[ children: <Widget>[
@@ -171,17 +164,17 @@ class _StatusCard extends StatelessWidget {
Color textColor = UiColors.textPrimary; Color textColor = UiColors.textPrimary;
if (isWarning) { if (isWarning) {
bg = UiColors.tagPending; bg = UiColors.tagPending.withAlpha(80);
border = UiColors.borderStill; border = UiColors.textWarning.withAlpha(80);
iconColor = UiColors.textWarning; iconColor = UiColors.textWarning;
textColor = UiColors.textWarning; textColor = UiColors.textWarning;
} else if (isError) { } else if (isError) {
bg = UiColors.tagError; bg = UiColors.tagError.withAlpha(80);
border = UiColors.borderError; border = UiColors.borderError.withAlpha(80);
iconColor = UiColors.textError; iconColor = UiColors.textError;
textColor = UiColors.textError; textColor = UiColors.textError;
} else if (isInfo) { } else if (isInfo) {
bg = UiColors.tagInProgress; bg = UiColors.tagInProgress.withAlpha(80);
border = UiColors.primary.withValues(alpha: 0.2); border = UiColors.primary.withValues(alpha: 0.2);
iconColor = UiColors.primary; iconColor = UiColors.primary;
textColor = UiColors.primary; textColor = UiColors.primary;

View File

@@ -2,6 +2,8 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'section_layout.dart';
/// A widget that displays the daily coverage metrics. /// A widget that displays the daily coverage metrics.
class CoverageWidget extends StatelessWidget { class CoverageWidget extends StatelessWidget {
/// Creates a [CoverageWidget]. /// Creates a [CoverageWidget].
@@ -10,6 +12,7 @@ class CoverageWidget extends StatelessWidget {
this.totalNeeded = 0, this.totalNeeded = 0,
this.totalConfirmed = 0, this.totalConfirmed = 0,
this.coveragePercent = 0, this.coveragePercent = 0,
this.title,
this.subtitle, this.subtitle,
}); });
@@ -22,84 +25,43 @@ class CoverageWidget extends StatelessWidget {
/// The percentage of coverage (0-100). /// The percentage of coverage (0-100).
final int coveragePercent; final int coveragePercent;
/// Optional title for the section.
final String? title;
/// Optional subtitle for the section. /// Optional subtitle for the section.
final String? subtitle; final String? subtitle;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Color backgroundColor; return SectionLayout(
Color textColor; title: title,
subtitle: subtitle,
if (coveragePercent == 100) { action: totalNeeded > 0 || totalConfirmed > 0 || coveragePercent > 0
backgroundColor = UiColors.tagActive; ? t.client_home.dashboard.percent_covered(percent: coveragePercent)
textColor = UiColors.textSuccess; : null,
} else if (coveragePercent >= 40) { child: Row(
backgroundColor = UiColors.tagPending; children: <Widget>[
textColor = UiColors.textWarning; Expanded(
} else { child: _MetricCard(
backgroundColor = UiColors.tagError; icon: UiIcons.target,
textColor = UiColors.textError; iconColor: UiColors.primary,
} label: t.client_home.dashboard.metric_needed,
value: '$totalNeeded',
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
t.client_home.dashboard.todays_coverage,
style: UiTypography.footnote1b.copyWith(
color: UiColors.textPrimary,
letterSpacing: 0.5,
),
), ),
if (totalNeeded > 0 || totalConfirmed > 0 || coveragePercent > 0) ),
Container( const SizedBox(width: UiConstants.space2),
padding: const EdgeInsets.symmetric( if (totalConfirmed != 0)
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) ...<Widget>[
Text(subtitle!, style: UiTypography.body2r.textSecondary),
],
const SizedBox(height: UiConstants.space6),
Row(
children: <Widget>[
Expanded( Expanded(
child: _MetricCard( child: _MetricCard(
icon: UiIcons.target, icon: UiIcons.success,
iconColor: UiColors.primary, iconColor: UiColors.iconSuccess,
label: t.client_home.dashboard.metric_needed, label: t.client_home.dashboard.metric_filled,
value: '$totalNeeded', value: '$totalConfirmed',
valueColor: UiColors.textSuccess,
), ),
), ),
const SizedBox(width: UiConstants.space2), const SizedBox(width: UiConstants.space2),
if (totalConfirmed != 0) 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),
Expanded( Expanded(
child: _MetricCard( child: _MetricCard(
icon: UiIcons.error, icon: UiIcons.error,
@@ -109,9 +71,8 @@ class CoverageWidget extends StatelessWidget {
valueColor: UiColors.textError, valueColor: UiColors.textError,
), ),
), ),
], ],
), ),
],
); );
} }
} }

View File

@@ -35,7 +35,7 @@ class DashboardWidgetBuilder extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsClientHomeWidgetsEn i18n = t.client_home.widgets; final TranslationsClientHomeWidgetsEn i18n = t.client_home.widgets;
final Widget widgetContent = _buildWidgetContent(context); final Widget widgetContent = _buildWidgetContent(context, i18n);
if (isEditMode) { if (isEditMode) {
return DraggableWidgetWrapper( return DraggableWidgetWrapper(
@@ -55,21 +55,27 @@ class DashboardWidgetBuilder extends StatelessWidget {
} }
/// Builds the actual widget content based on the widget ID. /// 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 // Only show subtitle in normal mode
final String? subtitle = !isEditMode ? _getWidgetSubtitle(id) : null; final String? subtitle = !isEditMode ? _getWidgetSubtitle(id) : null;
switch (id) { switch (id) {
case 'actions': case 'actions':
return ActionsWidget(subtitle: subtitle); return ActionsWidget(title: title, subtitle: subtitle);
case 'reorder': case 'reorder':
return ReorderWidget(orders: state.reorderItems, subtitle: subtitle); return ReorderWidget(
orders: state.reorderItems,
title: title,
subtitle: subtitle,
);
case 'spending': case 'spending':
return SpendingWidget( return SpendingWidget(
weeklySpending: state.dashboardData.weeklySpending, weeklySpending: state.dashboardData.weeklySpending,
next7DaysSpending: state.dashboardData.next7DaysSpending, next7DaysSpending: state.dashboardData.next7DaysSpending,
weeklyShifts: state.dashboardData.weeklyShifts, weeklyShifts: state.dashboardData.weeklyShifts,
next7DaysScheduled: state.dashboardData.next7DaysScheduled, next7DaysScheduled: state.dashboardData.next7DaysScheduled,
title: title,
subtitle: subtitle, subtitle: subtitle,
); );
case 'coverage': case 'coverage':
@@ -82,11 +88,13 @@ class DashboardWidgetBuilder extends StatelessWidget {
100) 100)
.toInt() .toInt()
: 0, : 0,
title: title,
subtitle: subtitle, subtitle: subtitle,
); );
case 'liveActivity': case 'liveActivity':
return LiveActivityWidget( return LiveActivityWidget(
onViewAllPressed: () => Modular.to.toClientCoverage(), onViewAllPressed: () => Modular.to.toClientCoverage(),
title: title,
subtitle: subtitle, subtitle: subtitle,
); );
default: default:

View File

@@ -1,9 +1,10 @@
import 'package:core_localization/core_localization.dart'; import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'coverage_dashboard.dart'; import 'coverage_dashboard.dart';
import 'section_layout.dart';
/// A widget that displays live activity information. /// A widget that displays live activity information.
class LiveActivityWidget extends StatefulWidget { class LiveActivityWidget extends StatefulWidget {
@@ -12,11 +13,15 @@ class LiveActivityWidget extends StatefulWidget {
const LiveActivityWidget({ const LiveActivityWidget({
super.key, super.key,
required this.onViewAllPressed, required this.onViewAllPressed,
this.title,
this.subtitle this.subtitle
}); });
/// Callback when "View all" is pressed. /// Callback when "View all" is pressed.
final VoidCallback onViewAllPressed; final VoidCallback onViewAllPressed;
/// Optional title for the section.
final String? title;
/// Optional subtitle for the section. /// Optional subtitle for the section.
final String? subtitle; final String? subtitle;
@@ -106,73 +111,47 @@ class _LiveActivityWidgetState extends State<LiveActivityWidget> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsClientHomeEn i18n = t.client_home; final TranslationsClientHomeEn i18n = t.client_home;
return Column( return SectionLayout(
crossAxisAlignment: CrossAxisAlignment.start, title: widget.title,
children: <Widget>[ subtitle: widget.subtitle,
Row( action: i18n.dashboard.view_all,
mainAxisAlignment: MainAxisAlignment.spaceBetween, onAction: widget.onViewAllPressed,
children: <Widget>[ child: FutureBuilder<_LiveActivityData>(
Text( future: _liveActivityFuture,
i18n.widgets.live_activity.toUpperCase(), builder: (BuildContext context,
style: UiTypography.footnote1b.textSecondary.copyWith( AsyncSnapshot<_LiveActivityData> snapshot) {
letterSpacing: 0.5, final _LiveActivityData data =
), snapshot.data ?? _LiveActivityData.empty();
), final List<Map<String, Object>> shifts =
GestureDetector( <Map<String, Object>>[
onTap: widget.onViewAllPressed, <String, Object>{
child: Text( 'workersNeeded': data.totalNeeded,
i18n.dashboard.view_all, 'filled': data.totalAssigned,
style: UiTypography.footnote1m.copyWith( 'hourlyRate': 1.0,
color: UiColors.primary, 'hours': data.totalCost,
), 'status': 'OPEN',
), 'date': DateTime.now().toIso8601String().split('T')[0],
), },
], ];
), final List<Map<String, Object?>> applications =
if (widget.subtitle != null) ...<Widget>[ <Map<String, Object?>>[];
Text( for (int i = 0; i < data.checkedInCount; i += 1) {
widget.subtitle!, applications.add(
style: UiTypography.body2r.textSecondary, <String, Object?>{
), 'status': 'CONFIRMED',
], 'checkInTime': '09:00',
const SizedBox(height: UiConstants.space6),
FutureBuilder<_LiveActivityData>(
future: _liveActivityFuture,
builder: (BuildContext context,
AsyncSnapshot<_LiveActivityData> snapshot) {
final _LiveActivityData data =
snapshot.data ?? _LiveActivityData.empty();
final List<Map<String, Object>> shifts =
<Map<String, Object>>[
<String, Object>{
'workersNeeded': data.totalNeeded,
'filled': data.totalAssigned,
'hourlyRate': 1.0,
'hours': data.totalCost,
'status': 'OPEN',
'date': DateTime.now().toIso8601String().split('T')[0],
}, },
];
final List<Map<String, Object?>> applications =
<Map<String, Object?>>[];
for (int i = 0; i < data.checkedInCount; i += 1) {
applications.add(
<String, Object?>{
'status': 'CONFIRMED',
'checkInTime': '09:00',
},
);
}
for (int i = 0; i < data.lateCount; i += 1) {
applications.add(<String, Object?>{'status': 'LATE'});
}
return CoverageDashboard(
shifts: shifts,
applications: applications,
); );
}, }
), for (int i = 0; i < data.lateCount; i += 1) {
], applications.add(<String, Object?>{'status': 'LATE'});
}
return CoverageDashboard(
shifts: shifts,
applications: applications,
);
},
),
); );
} }
} }

View File

@@ -5,14 +5,24 @@ import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'section_layout.dart';
/// A widget that allows clients to reorder recent shifts. /// A widget that allows clients to reorder recent shifts.
class ReorderWidget extends StatelessWidget { class ReorderWidget extends StatelessWidget {
/// Creates a [ReorderWidget]. /// 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. /// Recent completed orders for reorder.
final List<ReorderItem> orders; final List<ReorderItem> orders;
/// Optional title for the section.
final String? title;
/// Optional subtitle for the section. /// Optional subtitle for the section.
final String? subtitle; final String? subtitle;
@@ -26,147 +36,135 @@ class ReorderWidget extends StatelessWidget {
final List<ReorderItem> recentOrders = orders; final List<ReorderItem> recentOrders = orders;
return Column( return SectionLayout(
crossAxisAlignment: CrossAxisAlignment.start, title: title,
children: <Widget>[ subtitle: subtitle,
Text( child: SizedBox(
i18n.title, height: 164,
style: UiTypography.footnote1b.textSecondary.copyWith( child: ListView.separated(
letterSpacing: 0.5, scrollDirection: Axis.horizontal,
), itemCount: recentOrders.length,
), separatorBuilder: (BuildContext context, int index) =>
if (subtitle != null) ...<Widget>[ const SizedBox(width: UiConstants.space3),
const SizedBox(height: UiConstants.space1), itemBuilder: (BuildContext context, int index) {
Text(subtitle!, style: UiTypography.body2r.textSecondary), final ReorderItem order = recentOrders[index];
], final double totalCost = order.totalCost;
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 Container( return Container(
width: 260, width: 260,
padding: const EdgeInsets.all(UiConstants.space3), padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.white, color: UiColors.white,
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border, width: 0.6), border: Border.all(color: UiColors.border, width: 0.6),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
child: Row( child: Row(
children: <Widget>[
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: <Widget>[
Text(
order.title,
style: UiTypography.body2b,
overflow: TextOverflow.ellipsis,
),
Text(
order.location,
style:
UiTypography.footnote1r.textSecondary,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[ children: <Widget>[
Text( Container(
'\$${totalCost.toStringAsFixed(0)}', width: 36,
style: UiTypography.body1b, 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( const SizedBox(width: UiConstants.space2),
'${i18n.per_hr(amount: order.hourlyRate.toString())} · ${order.hours}h', Expanded(
style: UiTypography.footnote2r.textSecondary, child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
order.title,
style: UiTypography.body2b,
overflow: TextOverflow.ellipsis,
),
Text(
order.location,
style:
UiTypography.footnote1r.textSecondary,
overflow: TextOverflow.ellipsis,
),
],
),
), ),
], ],
), ),
], ),
), Column(
const SizedBox(height: UiConstants.space3), crossAxisAlignment: CrossAxisAlignment.end,
Row( children: <Widget>[
children: <Widget>[ Text(
_Badge( '\$${totalCost.toStringAsFixed(0)}',
icon: UiIcons.success, style: UiTypography.body1b,
text: order.type, ),
color: UiColors.primary, Text(
bg: UiColors.buttonSecondaryStill, '${i18n.per_hr(amount: order.hourlyRate.toString())} · ${order.hours}h',
textColor: UiColors.primary, style: UiTypography.footnote2r.textSecondary,
), ),
const SizedBox(width: UiConstants.space2), ],
_Badge( ),
icon: UiIcons.building, ],
text: '${order.workers}', ),
color: UiColors.textSecondary, const SizedBox(height: UiConstants.space3),
bg: UiColors.buttonSecondaryStill, Row(
textColor: UiColors.textSecondary, children: <Widget>[
), _Badge(
], icon: UiIcons.success,
), text: order.type,
const Spacer(), 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( UiButton.secondary(
size: UiButtonSize.small, size: UiButtonSize.small,
text: i18n.reorder_button, text: i18n.reorder_button,
leadingIcon: UiIcons.zap, leadingIcon: UiIcons.zap,
iconSize: 12, iconSize: 12,
fullWidth: true, fullWidth: true,
onPressed: () => onPressed: () =>
_handleReorderPressed(context, <String, dynamic>{ _handleReorderPressed(context, <String, dynamic>{
'orderId': order.orderId, 'orderId': order.orderId,
'title': order.title, 'title': order.title,
'location': order.location, 'location': order.location,
'hourlyRate': order.hourlyRate, 'hourlyRate': order.hourlyRate,
'hours': order.hours, 'hours': order.hours,
'workers': order.workers, 'workers': order.workers,
'type': order.type, 'type': order.type,
'totalCost': order.totalCost, 'totalCost': order.totalCost,
}), }),
), ),
], ],
), ),
); );
}, },
),
), ),
], ),
); );
} }

View File

@@ -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: <Widget>[
Padding(
padding: subtitle != null
? EdgeInsets.zero
: const EdgeInsets.only(bottom: UiConstants.space3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Expanded(
child: action != null
? Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(title, style: UiTypography.body1b),
if (onAction != null)
GestureDetector(
onTap: onAction,
child: Row(
children: <Widget>[
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,
),
),
],
);
}
}

View File

@@ -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: <Widget>[
if (title != null)
Padding(
padding: contentPadding ?? EdgeInsets.zero,
child: SectionHeader(
title: title!,
subtitle: subtitle,
action: action,
onAction: onAction,
),
),
const SizedBox(height: UiConstants.space2),
child,
],
);
}
}

View File

@@ -2,6 +2,8 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'section_layout.dart';
/// A widget that displays spending insights for the client. /// A widget that displays spending insights for the client.
class SpendingWidget extends StatelessWidget { class SpendingWidget extends StatelessWidget {
@@ -12,6 +14,7 @@ class SpendingWidget extends StatelessWidget {
required this.next7DaysSpending, required this.next7DaysSpending,
required this.weeklyShifts, required this.weeklyShifts,
required this.next7DaysScheduled, required this.next7DaysScheduled,
this.title,
this.subtitle, this.subtitle,
}); });
/// The spending this week. /// The spending this week.
@@ -26,117 +29,104 @@ class SpendingWidget extends StatelessWidget {
/// The number of scheduled shifts for next 7 days. /// The number of scheduled shifts for next 7 days.
final int next7DaysScheduled; final int next7DaysScheduled;
/// Optional title for the section.
final String? title;
/// Optional subtitle for the section. /// Optional subtitle for the section.
final String? subtitle; final String? subtitle;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsClientHomeEn i18n = t.client_home; return SectionLayout(
title: title,
return Column( subtitle: subtitle,
crossAxisAlignment: CrossAxisAlignment.start, child: Container(
children: <Widget>[ padding: const EdgeInsets.all(UiConstants.space3),
Text( decoration: BoxDecoration(
i18n.widgets.spending.toUpperCase(), gradient: LinearGradient(
style: UiTypography.footnote1b.textSecondary.copyWith( colors: <Color>[
letterSpacing: 0.5, UiColors.primary,
UiColors.primary.withValues(alpha: 0.85),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
), ),
), borderRadius: UiConstants.radiusLg,
if (subtitle != null) ...<Widget>[ boxShadow: <BoxShadow>[
Text( BoxShadow(
subtitle!, color: UiColors.primary.withValues(alpha: 0.3),
style: UiTypography.body2r.textSecondary, blurRadius: 4,
), offset: const Offset(0, 4),
],
const SizedBox(height: UiConstants.space6),
Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: <Color>[
UiColors.primary,
UiColors.primary.withValues(alpha: 0.85),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
), ),
borderRadius: UiConstants.radiusLg, ],
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.primary.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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: <Widget>[
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: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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: <Widget>[
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,
),
),
],
),
),
],
),
],
),
),
); );
} }
} }

View File

@@ -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<BenefitsOverviewState>
with BlocErrorHandler<BenefitsOverviewState> {
final HomeRepository _repository;
BenefitsOverviewCubit({required HomeRepository repository})
: _repository = repository,
super(const BenefitsOverviewState.initial());
Future<void> 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,
);
},
);
}
}

View File

@@ -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<Benefit> 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<Benefit>? benefits,
String? errorMessage,
}) {
return BenefitsOverviewState(
status: status ?? this.status,
benefits: benefits ?? this.benefits,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [status, benefits, errorMessage];
}

View File

@@ -1,12 +1,10 @@
import 'dart:math' as math;
import 'package:core_localization/core_localization.dart'; import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:staff_home/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart';
import 'package:staff_home/src/presentation/blocs/home_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. /// Page displaying a detailed overview of the worker's benefits.
class BenefitsOverviewPage extends StatelessWidget { class BenefitsOverviewPage extends StatelessWidget {
@@ -15,33 +13,37 @@ class BenefitsOverviewPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<HomeCubit>.value( return Scaffold(
value: Modular.get<HomeCubit>(), appBar: UiAppBar(
child: Scaffold( title: t.staff.home.benefits.overview.title,
backgroundColor: const Color(0xFFF8FAFC), subtitle: t.staff.home.benefits.overview.subtitle,
appBar: _buildAppBar(context), showBackButton: true,
body: BlocBuilder<HomeCubit, HomeState>( ),
body: BlocProvider<BenefitsOverviewCubit>(
create: (context) =>
Modular.get<BenefitsOverviewCubit>()..loadBenefits(),
child: BlocBuilder<BenefitsOverviewCubit, BenefitsOverviewState>(
builder: (context, state) { builder: (context, state) {
if (state.status == HomeStatus.loading || if (state.status == BenefitsOverviewStatus.loading ||
state.status == HomeStatus.initial) { state.status == BenefitsOverviewStatus.initial) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (state.status == HomeStatus.error) { if (state.status == BenefitsOverviewStatus.error) {
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(UiConstants.space6), padding: const EdgeInsets.all(UiConstants.space6),
child: Text( child: Text(
state.errorMessage ?? t.staff.home.benefits.overview.subtitle, state.errorMessage ??
t.staff.home.benefits.overview.subtitle,
style: UiTypography.body1r.textSecondary, style: UiTypography.body1r.textSecondary,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
); );
} }
final benefits = state.benefits; if (state.benefits.isEmpty) {
if (benefits.isEmpty) {
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(UiConstants.space6), padding: const EdgeInsets.all(UiConstants.space6),
@@ -53,401 +55,11 @@ class BenefitsOverviewPage extends StatelessWidget {
), ),
); );
} }
return ListView.builder( return BenefitsOverviewBody(benefits: state.benefits);
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]),
);
},
);
}, },
), ),
), ),
); );
} }
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,
),
),
),
],
);
}
} }

View File

@@ -4,16 +4,15 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart';
import 'package:staff_home/src/presentation/blocs/home_cubit.dart'; import 'package:staff_home/src/presentation/widgets/home_page/benefits_section.dart';
import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.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/home_header.dart';
import 'package:staff_home/src/presentation/widgets/home_page/placeholder_banner.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/quick_actions_section.dart';
import 'package:staff_home/src/presentation/widgets/home_page/recommended_shift_card.dart'; import 'package:staff_home/src/presentation/widgets/home_page/recommended_shifts_section.dart';
import 'package:staff_home/src/presentation/widgets/home_page/section_header.dart'; import 'package:staff_home/src/presentation/widgets/home_page/todays_shifts_section.dart';
import 'package:staff_home/src/presentation/widgets/shift_card.dart'; import 'package:staff_home/src/presentation/widgets/home_page/tomorrows_shifts_section.dart';
import 'package:staff_home/src/presentation/widgets/worker/benefits_widget.dart';
/// The home page for the staff worker application. /// The home page for the staff worker application.
/// ///
@@ -36,9 +35,6 @@ class WorkerHomePage extends StatelessWidget {
final t = Translations.of(context); final t = Translations.of(context);
final i18n = t.staff.home; final i18n = t.staff.home;
final bannersI18n = i18n.banners; final bannersI18n = i18n.banners;
final quickI18n = i18n.quick_actions;
final sectionsI18n = i18n.sections;
final emptyI18n = i18n.empty_states;
return BlocProvider<HomeCubit>.value( return BlocProvider<HomeCubit>.value(
value: Modular.get<HomeCubit>()..loadShifts(), value: Modular.get<HomeCubit>()..loadShifts(),
@@ -67,8 +63,7 @@ class WorkerHomePage extends StatelessWidget {
builder: (context, state) { builder: (context, state) {
if (!state.isProfileComplete) { if (!state.isProfileComplete) {
return SizedBox( return SizedBox(
height: MediaQuery.of(context).size.height - height: MediaQuery.of(context).size.height - 300,
300,
child: Column( child: Column(
children: [ children: [
PlaceholderBanner( PlaceholderBanner(
@@ -85,7 +80,8 @@ class WorkerHomePage extends StatelessWidget {
child: UiEmptyState( child: UiEmptyState(
icon: UiIcons.users, icon: UiIcons.users,
title: 'Complete Your Profile', 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( return Column(
children: [ children: [
// Quick Actions // Quick Actions
Row( const QuickActionsSection(),
mainAxisAlignment: MainAxisAlignment.spaceBetween, const FullWidthDivider(),
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),
// Today's Shifts // Today's Shifts
BlocBuilder<HomeCubit, HomeState>( const TodaysShiftsSection(),
builder: (context, state) { const FullWidthDivider(),
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),
// Tomorrow's Shifts // Tomorrow's Shifts
BlocBuilder<HomeCubit, HomeState>( const TomorrowsShiftsSection(),
builder: (context, state) { const FullWidthDivider(),
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),
// Recommended Shifts // Recommended Shifts
SectionHeader(title: sectionsI18n.recommended_for_you), const RecommendedShiftsSection(),
BlocBuilder<HomeCubit, HomeState>( const FullWidthDivider(),
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),
// Benefits // Benefits
BlocBuilder<HomeCubit, HomeState>( const BenefitsSection(),
buildWhen: (previous, current) =>
previous.benefits != current.benefits,
builder: (context, state) {
return BenefitsWidget(benefits: state.benefits);
},
),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
], ],
); );

View File

@@ -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<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) ...[
_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,
),
),
),
],
);
}
}

View File

@@ -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),
// ],
],
),
);
}
}

View File

@@ -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 '';
}
}

View File

@@ -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<Benefit> 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]),
);
},
);
}
}

View File

@@ -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;
}

View File

@@ -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,
),
),
),
],
),
);
}
}

View File

@@ -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,
),
],
),
);
}
}

View File

@@ -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<HomeCubit, HomeState>(
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),
);
},
);
}
}

View File

@@ -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),
],
);
}
}

View File

@@ -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(),
),
),
],
);
}
}

View File

@@ -13,68 +13,34 @@ class RecommendedShiftCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final recI18n = t.staff.home.recommended_card; final recI18n = t.staff.home.recommended_card;
final size = MediaQuery.sizeOf(context);
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
Modular.to.toShiftDetails(shift); Modular.to.toShiftDetails(shift);
}, },
child: Container( child: Container(
width: 300, width: size.width * 0.8,
padding: const EdgeInsets.all(UiConstants.space4), padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.white, color: UiColors.white,
borderRadius: BorderRadius.circular(UiConstants.radiusBase), borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border), border: Border.all(color: UiColors.border, width: 0.5),
boxShadow: [
BoxShadow(
color: UiColors.black.withValues(alpha: 0.02),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
), ),
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Row( Row(
children: [ crossAxisAlignment: CrossAxisAlignment.center,
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,
children: [ children: [
Container( Container(
width: UiConstants.space10, width: UiConstants.space10,
height: UiConstants.space10, height: UiConstants.space10,
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.tagInProgress, color: UiColors.tagInProgress,
borderRadius: BorderRadius.circular( borderRadius: UiConstants.radiusLg,
UiConstants.radiusBase,
),
), ),
child: const Icon( child: const Icon(
UiIcons.calendar, UiIcons.calendar,
@@ -85,10 +51,11 @@ class RecommendedShiftCard extends StatelessWidget {
const SizedBox(width: UiConstants.space3), const SizedBox(width: UiConstants.space3),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: UiConstants.space1,
children: [ children: [
Flexible( Flexible(
child: Text( child: Text(
@@ -99,13 +66,13 @@ class RecommendedShiftCard extends StatelessWidget {
), ),
Text( Text(
'\$${shift.hourlyRate}/h', '\$${shift.hourlyRate}/h',
style: UiTypography.headline4m.textPrimary, style: UiTypography.headline4b,
), ),
], ],
), ),
const SizedBox(height: 2),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: UiConstants.space1,
children: [ children: [
Text( Text(
shift.clientName, shift.clientName,

View File

@@ -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<HomeCubit, HomeState>(
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],
),
),
),
);
},
),
);
}
}

View File

@@ -39,11 +39,14 @@ class SectionHeader extends StatelessWidget {
onTap: onAction, onTap: onAction,
child: Row( child: Row(
children: [ children: [
Text(action ?? '', style: UiTypography.body3r), Text(
action ?? '',
style: UiTypography.body3r.textSecondary,
),
const Icon( const Icon(
UiIcons.chevronRight, UiIcons.chevronRight,
size: UiConstants.space4, size: UiConstants.space4,
color: UiColors.primary, color: UiColors.iconSecondary,
), ),
], ],
), ),

View File

@@ -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,
],
);
}
}

View File

@@ -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<HomeCubit, HomeState>(
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(),
),
);
},
);
}
}

View File

@@ -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<HomeCubit, HomeState>(
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(),
),
);
},
);
}
}

View File

@@ -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<Benefit> 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>[
BoxShadow(
color: UiColors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
i18n.title,
style: UiTypography.body1b.textPrimary,
),
GestureDetector(
onTap: () => Modular.to.toBenefits(),
child: Row(
children: <Widget>[
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: <Widget>[
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: <Widget>[
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;
}

View File

@@ -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: <Widget>[
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: <Widget>[
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,
),
],
);
}
}

View File

@@ -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: <Widget>[
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),
),
],
),
);
}
}

View File

@@ -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<Benefit> 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(),
),
);
}
}

View File

@@ -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;
}

View File

@@ -4,7 +4,8 @@ import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.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/data/repositories/home_repository_impl.dart';
import 'package:staff_home/src/domain/repositories/home_repository.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/benefits_overview_page.dart';
import 'package:staff_home/src/presentation/pages/worker_home_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( i.addSingleton(
() => HomeCubit( () => HomeCubit(
repository: i.get<HomeRepository>(), repository: i.get<HomeRepository>(),
getProfileCompletion: i.get<GetProfileCompletionUseCase>(), getProfileCompletion: i.get<GetProfileCompletionUseCase>(),
), ),
); );
// Cubit for benefits overview page
i.addLazySingleton(
() => BenefitsOverviewCubit(repository: i.get<HomeRepository>()),
);
} }
@override @override

View File

@@ -17,6 +17,10 @@ class StaffMainCubit extends Cubit<StaffMainState> implements Disposable {
final GetProfileCompletionUseCase _getProfileCompletionUsecase; final GetProfileCompletionUseCase _getProfileCompletionUsecase;
bool _isLoadingCompletion = false; bool _isLoadingCompletion = false;
static const List<String> _hideBottomPaths = <String>[
StaffPaths.benefits,
];
void _onRouteChanged() { void _onRouteChanged() {
if (isClosed) return; if (isClosed) return;
@@ -40,8 +44,10 @@ class StaffMainCubit extends Cubit<StaffMainState> implements Disposable {
newIndex = 4; newIndex = 4;
} }
if (newIndex != state.currentIndex) { final bool showBottomBar = !_hideBottomPaths.any(path.contains);
emit(state.copyWith(currentIndex: newIndex));
if (newIndex != state.currentIndex || showBottomBar != state.showBottomBar) {
emit(state.copyWith(currentIndex: newIndex, showBottomBar: showBottomBar));
} }
} }

View File

@@ -4,18 +4,25 @@ class StaffMainState extends Equatable {
const StaffMainState({ const StaffMainState({
this.currentIndex = 2, // Default to Home this.currentIndex = 2, // Default to Home
this.isProfileComplete = false, this.isProfileComplete = false,
this.showBottomBar = true,
}); });
final int currentIndex; final int currentIndex;
final bool isProfileComplete; final bool isProfileComplete;
final bool showBottomBar;
StaffMainState copyWith({int? currentIndex, bool? isProfileComplete}) { StaffMainState copyWith({
int? currentIndex,
bool? isProfileComplete,
bool? showBottomBar,
}) {
return StaffMainState( return StaffMainState(
currentIndex: currentIndex ?? this.currentIndex, currentIndex: currentIndex ?? this.currentIndex,
isProfileComplete: isProfileComplete ?? this.isProfileComplete, isProfileComplete: isProfileComplete ?? this.isProfileComplete,
showBottomBar: showBottomBar ?? this.showBottomBar,
); );
} }
@override @override
List<Object> get props => <Object>[currentIndex, isProfileComplete]; List<Object> get props => <Object>[currentIndex, isProfileComplete, showBottomBar];
} }

View File

@@ -24,6 +24,7 @@ class StaffMainPage extends StatelessWidget {
body: const RouterOutlet(), body: const RouterOutlet(),
bottomNavigationBar: BlocBuilder<StaffMainCubit, StaffMainState>( bottomNavigationBar: BlocBuilder<StaffMainCubit, StaffMainState>(
builder: (BuildContext context, StaffMainState state) { builder: (BuildContext context, StaffMainState state) {
if (!state.showBottomBar) return const SizedBox.shrink();
return StaffMainBottomBar( return StaffMainBottomBar(
currentIndex: state.currentIndex, currentIndex: state.currentIndex,
onTap: (int index) { onTap: (int index) {

View File

@@ -1,5 +1,5 @@
specVersion: "v1" specVersion: "v1"
serviceId: "krow-workforce-db" serviceId: "krow-workforce-db-validation"
location: "us-central1" location: "us-central1"
schema: schema:
source: "./schema" source: "./schema"
@@ -7,7 +7,7 @@ schema:
postgresql: postgresql:
database: "krow_db" database: "krow_db"
cloudSql: cloudSql:
instanceId: "krow-sql" instanceId: "krow-sql-validation"
# schemaValidation: "STRICT" # STRICT mode makes Postgres schema match Data Connect exactly. # schemaValidation: "STRICT" # STRICT mode makes Postgres schema match Data Connect exactly.
# schemaValidation: "COMPATIBLE" # COMPATIBLE mode makes Postgres schema compatible with Data Connect. # schemaValidation: "COMPATIBLE" # COMPATIBLE mode makes Postgres schema compatible with Data Connect.
connectorDirs: ["./connector"] connectorDirs: ["./connector"]

View File

@@ -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.* *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.*

View File

@@ -7,7 +7,7 @@
# make dataconnect-clean DC_ENV=validation # make dataconnect-clean DC_ENV=validation
# make dataconnect-generate-sdk DC_ENV=dev # make dataconnect-generate-sdk DC_ENV=dev
# #
DC_ENV ?= dev DC_ENV ?= validation
DC_LOCATION ?= us-central1 DC_LOCATION ?= us-central1
DC_CONNECTOR_ID ?= example DC_CONNECTOR_ID ?= example