Merge dev into feature/session-persistence-new
This commit is contained in:
@@ -20,6 +20,7 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
||||
|
||||
@override
|
||||
Future<bool> getProfileCompletion() async {
|
||||
return true;
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
|
||||
@@ -402,7 +402,7 @@ class UiTypography {
|
||||
/// Body 4 Medium - Font: Instrument Sans, Size: 14, Height: 1.5, Spacing: 0.05 (#121826)
|
||||
static final TextStyle body4m = _primaryBase.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
fontSize: 10,
|
||||
height: 1.5,
|
||||
letterSpacing: 0.05,
|
||||
color: UiColors.textPrimary,
|
||||
|
||||
@@ -2,6 +2,13 @@ import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Represents a staff member's benefit balance.
|
||||
class Benefit extends Equatable {
|
||||
/// Creates a [Benefit].
|
||||
const Benefit({
|
||||
required this.title,
|
||||
required this.entitlementHours,
|
||||
required this.usedHours,
|
||||
});
|
||||
|
||||
/// The title of the benefit (e.g., Sick Leave, Holiday, Vacation).
|
||||
final String title;
|
||||
|
||||
@@ -14,13 +21,6 @@ class Benefit extends Equatable {
|
||||
/// The hours remaining.
|
||||
double get remainingHours => entitlementHours - usedHours;
|
||||
|
||||
/// Creates a [Benefit].
|
||||
const Benefit({
|
||||
required this.title,
|
||||
required this.entitlementHours,
|
||||
required this.usedHours,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [title, entitlementHours, usedHours];
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
|
||||
import '../blocs/client_home_bloc.dart';
|
||||
import '../blocs/client_home_event.dart';
|
||||
import '../blocs/client_home_state.dart';
|
||||
import '../widgets/client_home_body.dart';
|
||||
import '../widgets/client_home_edit_banner.dart';
|
||||
import '../widgets/client_home_header.dart';
|
||||
import '../widgets/dashboard_widget_builder.dart';
|
||||
|
||||
/// The main Home page for client users.
|
||||
///
|
||||
@@ -21,133 +18,23 @@ class ClientHomePage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientHomeEn i18n = t.client_home;
|
||||
|
||||
return BlocProvider<ClientHomeBloc>(
|
||||
create: (BuildContext context) => Modular.get<ClientHomeBloc>(),
|
||||
child: Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
ClientHomeHeader(i18n: i18n),
|
||||
ClientHomeEditBanner(i18n: i18n),
|
||||
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);
|
||||
},
|
||||
),
|
||||
ClientHomeHeader(
|
||||
i18n: t.client_home,
|
||||
),
|
||||
ClientHomeEditBanner(
|
||||
i18n: t.client_home,
|
||||
),
|
||||
const ClientHomeBody(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the widget list in edit mode with drag-and-drop support.
|
||||
Widget _buildEditModeList(BuildContext context, ClientHomeState state) {
|
||||
return ReorderableListView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
UiConstants.space4,
|
||||
0,
|
||||
UiConstants.space4,
|
||||
100,
|
||||
),
|
||||
onReorder: (int oldIndex, int newIndex) {
|
||||
BlocProvider.of<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()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,19 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'section_layout.dart';
|
||||
|
||||
/// A widget that displays quick actions for the client.
|
||||
class ActionsWidget extends StatelessWidget {
|
||||
/// Creates an [ActionsWidget].
|
||||
const ActionsWidget({super.key, this.subtitle});
|
||||
const ActionsWidget({
|
||||
super.key,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
/// Optional title for the section.
|
||||
final String? title;
|
||||
|
||||
/// Optional subtitle for the section.
|
||||
final String? subtitle;
|
||||
@@ -17,38 +26,40 @@ class ActionsWidget extends StatelessWidget {
|
||||
// Check if client_home exists in t
|
||||
final TranslationsClientHomeActionsEn i18n = t.client_home.actions;
|
||||
|
||||
return Row(
|
||||
spacing: UiConstants.space4,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: _ActionCard(
|
||||
title: i18n.rapid,
|
||||
subtitle: i18n.rapid_subtitle,
|
||||
icon: UiIcons.zap,
|
||||
color: UiColors.tagError.withValues(alpha: 0.5),
|
||||
borderColor: UiColors.borderError.withValues(alpha: 0.3),
|
||||
iconBgColor: UiColors.white,
|
||||
iconColor: UiColors.textError,
|
||||
textColor: UiColors.textError,
|
||||
subtitleColor: UiColors.textError.withValues(alpha: 0.8),
|
||||
onTap: () => Modular.to.toCreateOrderRapid(),
|
||||
return SectionLayout(
|
||||
child: Row(
|
||||
spacing: UiConstants.space4,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: _ActionCard(
|
||||
title: i18n.rapid,
|
||||
subtitle: i18n.rapid_subtitle,
|
||||
icon: UiIcons.zap,
|
||||
color: UiColors.tagError.withValues(alpha: 0.5),
|
||||
borderColor: UiColors.borderError.withValues(alpha: 0.3),
|
||||
iconBgColor: UiColors.white,
|
||||
iconColor: UiColors.textError,
|
||||
textColor: UiColors.textError,
|
||||
subtitleColor: UiColors.textError.withValues(alpha: 0.8),
|
||||
onTap: () => Modular.to.toCreateOrderRapid(),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _ActionCard(
|
||||
title: i18n.create_order,
|
||||
subtitle: i18n.create_order_subtitle,
|
||||
icon: UiIcons.add,
|
||||
color: UiColors.white,
|
||||
borderColor: UiColors.border,
|
||||
iconBgColor: UiColors.primaryForeground,
|
||||
iconColor: UiColors.primary,
|
||||
textColor: UiColors.textPrimary,
|
||||
subtitleColor: UiColors.textSecondary,
|
||||
onTap: () => Modular.to.toCreateOrder(),
|
||||
Expanded(
|
||||
child: _ActionCard(
|
||||
title: i18n.create_order,
|
||||
subtitle: i18n.create_order_subtitle,
|
||||
icon: UiIcons.add,
|
||||
color: UiColors.white,
|
||||
borderColor: UiColors.border,
|
||||
iconBgColor: UiColors.primaryForeground,
|
||||
iconColor: UiColors.primary,
|
||||
textColor: UiColors.textPrimary,
|
||||
subtitleColor: UiColors.textSecondary,
|
||||
onTap: () => Modular.to.toCreateOrder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -62,13 +62,6 @@ class CoverageDashboard extends StatelessWidget {
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border, width: 0.5),
|
||||
boxShadow: <BoxShadow>[
|
||||
BoxShadow(
|
||||
color: UiColors.black.withValues(alpha: 0.02),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
@@ -171,17 +164,17 @@ class _StatusCard extends StatelessWidget {
|
||||
Color textColor = UiColors.textPrimary;
|
||||
|
||||
if (isWarning) {
|
||||
bg = UiColors.tagPending;
|
||||
border = UiColors.borderStill;
|
||||
bg = UiColors.tagPending.withAlpha(80);
|
||||
border = UiColors.textWarning.withAlpha(80);
|
||||
iconColor = UiColors.textWarning;
|
||||
textColor = UiColors.textWarning;
|
||||
} else if (isError) {
|
||||
bg = UiColors.tagError;
|
||||
border = UiColors.borderError;
|
||||
bg = UiColors.tagError.withAlpha(80);
|
||||
border = UiColors.borderError.withAlpha(80);
|
||||
iconColor = UiColors.textError;
|
||||
textColor = UiColors.textError;
|
||||
} else if (isInfo) {
|
||||
bg = UiColors.tagInProgress;
|
||||
bg = UiColors.tagInProgress.withAlpha(80);
|
||||
border = UiColors.primary.withValues(alpha: 0.2);
|
||||
iconColor = UiColors.primary;
|
||||
textColor = UiColors.primary;
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'section_layout.dart';
|
||||
|
||||
/// A widget that displays the daily coverage metrics.
|
||||
class CoverageWidget extends StatelessWidget {
|
||||
/// Creates a [CoverageWidget].
|
||||
@@ -10,6 +12,7 @@ class CoverageWidget extends StatelessWidget {
|
||||
this.totalNeeded = 0,
|
||||
this.totalConfirmed = 0,
|
||||
this.coveragePercent = 0,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
@@ -22,84 +25,43 @@ class CoverageWidget extends StatelessWidget {
|
||||
/// The percentage of coverage (0-100).
|
||||
final int coveragePercent;
|
||||
|
||||
/// Optional title for the section.
|
||||
final String? title;
|
||||
|
||||
/// Optional subtitle for the section.
|
||||
final String? subtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color backgroundColor;
|
||||
Color textColor;
|
||||
|
||||
if (coveragePercent == 100) {
|
||||
backgroundColor = UiColors.tagActive;
|
||||
textColor = UiColors.textSuccess;
|
||||
} else if (coveragePercent >= 40) {
|
||||
backgroundColor = UiColors.tagPending;
|
||||
textColor = UiColors.textWarning;
|
||||
} else {
|
||||
backgroundColor = UiColors.tagError;
|
||||
textColor = UiColors.textError;
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
t.client_home.dashboard.todays_coverage,
|
||||
style: UiTypography.footnote1b.copyWith(
|
||||
color: UiColors.textPrimary,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
return SectionLayout(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
action: totalNeeded > 0 || totalConfirmed > 0 || coveragePercent > 0
|
||||
? t.client_home.dashboard.percent_covered(percent: coveragePercent)
|
||||
: null,
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: _MetricCard(
|
||||
icon: UiIcons.target,
|
||||
iconColor: UiColors.primary,
|
||||
label: t.client_home.dashboard.metric_needed,
|
||||
value: '$totalNeeded',
|
||||
),
|
||||
if (totalNeeded > 0 || totalConfirmed > 0 || coveragePercent > 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical:
|
||||
2, // 2px is not in metrics, using hardcoded for small tweaks or space0/space1
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Text(
|
||||
t.client_home.dashboard.percent_covered(
|
||||
percent: coveragePercent,
|
||||
),
|
||||
style: UiTypography.footnote2b.copyWith(color: textColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (subtitle != null) ...<Widget>[
|
||||
Text(subtitle!, style: UiTypography.body2r.textSecondary),
|
||||
],
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
if (totalConfirmed != 0)
|
||||
Expanded(
|
||||
child: _MetricCard(
|
||||
icon: UiIcons.target,
|
||||
iconColor: UiColors.primary,
|
||||
label: t.client_home.dashboard.metric_needed,
|
||||
value: '$totalNeeded',
|
||||
icon: UiIcons.success,
|
||||
iconColor: UiColors.iconSuccess,
|
||||
label: t.client_home.dashboard.metric_filled,
|
||||
value: '$totalConfirmed',
|
||||
valueColor: UiColors.textSuccess,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
if (totalConfirmed != 0)
|
||||
Expanded(
|
||||
child: _MetricCard(
|
||||
icon: UiIcons.success,
|
||||
iconColor: UiColors.iconSuccess,
|
||||
label: t.client_home.dashboard.metric_filled,
|
||||
value: '$totalConfirmed',
|
||||
valueColor: UiColors.textSuccess,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
if (totalConfirmed != 0)
|
||||
Expanded(
|
||||
child: _MetricCard(
|
||||
icon: UiIcons.error,
|
||||
@@ -109,9 +71,8 @@ class CoverageWidget extends StatelessWidget {
|
||||
valueColor: UiColors.textError,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class DashboardWidgetBuilder extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientHomeWidgetsEn i18n = t.client_home.widgets;
|
||||
final Widget widgetContent = _buildWidgetContent(context);
|
||||
final Widget widgetContent = _buildWidgetContent(context, i18n);
|
||||
|
||||
if (isEditMode) {
|
||||
return DraggableWidgetWrapper(
|
||||
@@ -55,21 +55,27 @@ class DashboardWidgetBuilder extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// Builds the actual widget content based on the widget ID.
|
||||
Widget _buildWidgetContent(BuildContext context) {
|
||||
Widget _buildWidgetContent(BuildContext context, TranslationsClientHomeWidgetsEn i18n) {
|
||||
final String title = _getWidgetTitle(i18n);
|
||||
// Only show subtitle in normal mode
|
||||
final String? subtitle = !isEditMode ? _getWidgetSubtitle(id) : null;
|
||||
|
||||
switch (id) {
|
||||
case 'actions':
|
||||
return ActionsWidget(subtitle: subtitle);
|
||||
return ActionsWidget(title: title, subtitle: subtitle);
|
||||
case 'reorder':
|
||||
return ReorderWidget(orders: state.reorderItems, subtitle: subtitle);
|
||||
return ReorderWidget(
|
||||
orders: state.reorderItems,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
);
|
||||
case 'spending':
|
||||
return SpendingWidget(
|
||||
weeklySpending: state.dashboardData.weeklySpending,
|
||||
next7DaysSpending: state.dashboardData.next7DaysSpending,
|
||||
weeklyShifts: state.dashboardData.weeklyShifts,
|
||||
next7DaysScheduled: state.dashboardData.next7DaysScheduled,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
);
|
||||
case 'coverage':
|
||||
@@ -82,11 +88,13 @@ class DashboardWidgetBuilder extends StatelessWidget {
|
||||
100)
|
||||
.toInt()
|
||||
: 0,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
);
|
||||
case 'liveActivity':
|
||||
return LiveActivityWidget(
|
||||
onViewAllPressed: () => Modular.to.toClientCoverage(),
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
);
|
||||
default:
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
|
||||
import 'coverage_dashboard.dart';
|
||||
import 'section_layout.dart';
|
||||
|
||||
/// A widget that displays live activity information.
|
||||
class LiveActivityWidget extends StatefulWidget {
|
||||
@@ -12,11 +13,15 @@ class LiveActivityWidget extends StatefulWidget {
|
||||
const LiveActivityWidget({
|
||||
super.key,
|
||||
required this.onViewAllPressed,
|
||||
this.title,
|
||||
this.subtitle
|
||||
});
|
||||
/// Callback when "View all" is pressed.
|
||||
final VoidCallback onViewAllPressed;
|
||||
|
||||
/// Optional title for the section.
|
||||
final String? title;
|
||||
|
||||
/// Optional subtitle for the section.
|
||||
final String? subtitle;
|
||||
|
||||
@@ -106,73 +111,47 @@ class _LiveActivityWidgetState extends State<LiveActivityWidget> {
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientHomeEn i18n = t.client_home;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
i18n.widgets.live_activity.toUpperCase(),
|
||||
style: UiTypography.footnote1b.textSecondary.copyWith(
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: widget.onViewAllPressed,
|
||||
child: Text(
|
||||
i18n.dashboard.view_all,
|
||||
style: UiTypography.footnote1m.copyWith(
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.subtitle != null) ...<Widget>[
|
||||
Text(
|
||||
widget.subtitle!,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
FutureBuilder<_LiveActivityData>(
|
||||
future: _liveActivityFuture,
|
||||
builder: (BuildContext context,
|
||||
AsyncSnapshot<_LiveActivityData> snapshot) {
|
||||
final _LiveActivityData data =
|
||||
snapshot.data ?? _LiveActivityData.empty();
|
||||
final List<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],
|
||||
return SectionLayout(
|
||||
title: widget.title,
|
||||
subtitle: widget.subtitle,
|
||||
action: i18n.dashboard.view_all,
|
||||
onAction: widget.onViewAllPressed,
|
||||
child: FutureBuilder<_LiveActivityData>(
|
||||
future: _liveActivityFuture,
|
||||
builder: (BuildContext context,
|
||||
AsyncSnapshot<_LiveActivityData> snapshot) {
|
||||
final _LiveActivityData data =
|
||||
snapshot.data ?? _LiveActivityData.empty();
|
||||
final List<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',
|
||||
},
|
||||
];
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,24 @@ import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'section_layout.dart';
|
||||
|
||||
/// A widget that allows clients to reorder recent shifts.
|
||||
class ReorderWidget extends StatelessWidget {
|
||||
/// Creates a [ReorderWidget].
|
||||
const ReorderWidget({super.key, required this.orders, this.subtitle});
|
||||
const ReorderWidget({
|
||||
super.key,
|
||||
required this.orders,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
/// Recent completed orders for reorder.
|
||||
final List<ReorderItem> orders;
|
||||
|
||||
/// Optional title for the section.
|
||||
final String? title;
|
||||
|
||||
/// Optional subtitle for the section.
|
||||
final String? subtitle;
|
||||
|
||||
@@ -26,147 +36,135 @@ class ReorderWidget extends StatelessWidget {
|
||||
|
||||
final List<ReorderItem> recentOrders = orders;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
i18n.title,
|
||||
style: UiTypography.footnote1b.textSecondary.copyWith(
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(subtitle!, style: UiTypography.body2r.textSecondary),
|
||||
],
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
SizedBox(
|
||||
height: 164,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: recentOrders.length,
|
||||
separatorBuilder: (BuildContext context, int index) =>
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final ReorderItem order = recentOrders[index];
|
||||
final double totalCost = order.totalCost;
|
||||
return SectionLayout(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
child: SizedBox(
|
||||
height: 164,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: recentOrders.length,
|
||||
separatorBuilder: (BuildContext context, int index) =>
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final ReorderItem order = recentOrders[index];
|
||||
final double totalCost = order.totalCost;
|
||||
|
||||
return Container(
|
||||
width: 260,
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border, width: 0.6),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
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,
|
||||
return Container(
|
||||
width: 260,
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border, width: 0.6),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'\$${totalCost.toStringAsFixed(0)}',
|
||||
style: UiTypography.body1b,
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withValues(
|
||||
alpha: 0.1,
|
||||
),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.building,
|
||||
size: 16,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${i18n.per_hr(amount: order.hourlyRate.toString())} · ${order.hours}h',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
order.title,
|
||||
style: UiTypography.body2b,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
order.location,
|
||||
style:
|
||||
UiTypography.footnote1r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
_Badge(
|
||||
icon: UiIcons.success,
|
||||
text: order.type,
|
||||
color: UiColors.primary,
|
||||
bg: UiColors.buttonSecondaryStill,
|
||||
textColor: UiColors.primary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
_Badge(
|
||||
icon: UiIcons.building,
|
||||
text: '${order.workers}',
|
||||
color: UiColors.textSecondary,
|
||||
bg: UiColors.buttonSecondaryStill,
|
||||
textColor: UiColors.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'\$${totalCost.toStringAsFixed(0)}',
|
||||
style: UiTypography.body1b,
|
||||
),
|
||||
Text(
|
||||
'${i18n.per_hr(amount: order.hourlyRate.toString())} · ${order.hours}h',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
_Badge(
|
||||
icon: UiIcons.success,
|
||||
text: order.type,
|
||||
color: UiColors.primary,
|
||||
bg: UiColors.buttonSecondaryStill,
|
||||
textColor: UiColors.primary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
_Badge(
|
||||
icon: UiIcons.building,
|
||||
text: '${order.workers}',
|
||||
color: UiColors.textSecondary,
|
||||
bg: UiColors.buttonSecondaryStill,
|
||||
textColor: UiColors.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
UiButton.secondary(
|
||||
size: UiButtonSize.small,
|
||||
text: i18n.reorder_button,
|
||||
leadingIcon: UiIcons.zap,
|
||||
iconSize: 12,
|
||||
fullWidth: true,
|
||||
onPressed: () =>
|
||||
_handleReorderPressed(context, <String, dynamic>{
|
||||
'orderId': order.orderId,
|
||||
'title': order.title,
|
||||
'location': order.location,
|
||||
'hourlyRate': order.hourlyRate,
|
||||
'hours': order.hours,
|
||||
'workers': order.workers,
|
||||
'type': order.type,
|
||||
'totalCost': order.totalCost,
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
UiButton.secondary(
|
||||
size: UiButtonSize.small,
|
||||
text: i18n.reorder_button,
|
||||
leadingIcon: UiIcons.zap,
|
||||
iconSize: 12,
|
||||
fullWidth: true,
|
||||
onPressed: () =>
|
||||
_handleReorderPressed(context, <String, dynamic>{
|
||||
'orderId': order.orderId,
|
||||
'title': order.title,
|
||||
'location': order.location,
|
||||
'hourlyRate': order.hourlyRate,
|
||||
'hours': order.hours,
|
||||
'workers': order.workers,
|
||||
'type': order.type,
|
||||
'totalCost': order.totalCost,
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'section_layout.dart';
|
||||
|
||||
/// A widget that displays spending insights for the client.
|
||||
class SpendingWidget extends StatelessWidget {
|
||||
|
||||
@@ -12,6 +14,7 @@ class SpendingWidget extends StatelessWidget {
|
||||
required this.next7DaysSpending,
|
||||
required this.weeklyShifts,
|
||||
required this.next7DaysScheduled,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
});
|
||||
/// The spending this week.
|
||||
@@ -26,117 +29,104 @@ class SpendingWidget extends StatelessWidget {
|
||||
/// The number of scheduled shifts for next 7 days.
|
||||
final int next7DaysScheduled;
|
||||
|
||||
/// Optional title for the section.
|
||||
final String? title;
|
||||
|
||||
/// Optional subtitle for the section.
|
||||
final String? subtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsClientHomeEn i18n = t.client_home;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
i18n.widgets.spending.toUpperCase(),
|
||||
style: UiTypography.footnote1b.textSecondary.copyWith(
|
||||
letterSpacing: 0.5,
|
||||
return SectionLayout(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space3),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: <Color>[
|
||||
UiColors.primary,
|
||||
UiColors.primary.withValues(alpha: 0.85),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...<Widget>[
|
||||
Text(
|
||||
subtitle!,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
],
|
||||
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),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:staff_home/src/presentation/blocs/home_cubit.dart';
|
||||
import 'package:staff_home/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/benefits_overview/benefits_overview_body.dart';
|
||||
|
||||
/// Page displaying a detailed overview of the worker's benefits.
|
||||
class BenefitsOverviewPage extends StatelessWidget {
|
||||
@@ -15,33 +13,37 @@ class BenefitsOverviewPage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<HomeCubit>.value(
|
||||
value: Modular.get<HomeCubit>(),
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFFF8FAFC),
|
||||
appBar: _buildAppBar(context),
|
||||
body: BlocBuilder<HomeCubit, HomeState>(
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
title: t.staff.home.benefits.overview.title,
|
||||
subtitle: t.staff.home.benefits.overview.subtitle,
|
||||
showBackButton: true,
|
||||
),
|
||||
body: BlocProvider<BenefitsOverviewCubit>(
|
||||
create: (context) =>
|
||||
Modular.get<BenefitsOverviewCubit>()..loadBenefits(),
|
||||
child: BlocBuilder<BenefitsOverviewCubit, BenefitsOverviewState>(
|
||||
builder: (context, state) {
|
||||
if (state.status == HomeStatus.loading ||
|
||||
state.status == HomeStatus.initial) {
|
||||
if (state.status == BenefitsOverviewStatus.loading ||
|
||||
state.status == BenefitsOverviewStatus.initial) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state.status == HomeStatus.error) {
|
||||
|
||||
if (state.status == BenefitsOverviewStatus.error) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
child: Text(
|
||||
state.errorMessage ?? t.staff.home.benefits.overview.subtitle,
|
||||
state.errorMessage ??
|
||||
t.staff.home.benefits.overview.subtitle,
|
||||
style: UiTypography.body1r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final benefits = state.benefits;
|
||||
if (benefits.isEmpty) {
|
||||
|
||||
if (state.benefits.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
@@ -53,401 +55,11 @@ class BenefitsOverviewPage extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.only(
|
||||
left: UiConstants.space4,
|
||||
right: UiConstants.space4,
|
||||
top: UiConstants.space6,
|
||||
bottom: 120,
|
||||
),
|
||||
itemCount: benefits.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||
child: _BenefitCard(benefit: benefits[index]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return BenefitsOverviewBody(benefits: state.benefits);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSizeWidget _buildAppBar(BuildContext context) {
|
||||
return AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(UiIcons.chevronLeft, color: UiColors.iconPrimary),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
centerTitle: true,
|
||||
title: Column(
|
||||
children: [
|
||||
Text(
|
||||
t.staff.home.benefits.overview.title,
|
||||
style: UiTypography.title2b.textPrimary,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
t.staff.home.benefits.overview.subtitle,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1),
|
||||
child: Container(color: UiColors.border.withOpacity(0.5), height: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BenefitCard extends StatelessWidget {
|
||||
final Benefit benefit;
|
||||
|
||||
const _BenefitCard({required this.benefit});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isSickLeave = benefit.title.toLowerCase().contains('sick');
|
||||
final bool isVacation = benefit.title.toLowerCase().contains('vacation');
|
||||
final bool isHolidays = benefit.title.toLowerCase().contains('holiday');
|
||||
|
||||
final i18n = t.staff.home.benefits.overview;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border.withOpacity(0.5)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: UiColors.black.withOpacity(0.02),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_buildProgressCircle(),
|
||||
const SizedBox(width: UiConstants.space4),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
benefit.title,
|
||||
style: UiTypography.body1b.textPrimary,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_getSubtitle(benefit.title),
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
_buildStatsRow(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
if (isSickLeave) ...[
|
||||
_AccordionHistory(label: i18n.sick_leave_history),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
],
|
||||
if (isVacation || isHolidays) ...[
|
||||
_buildComplianceBanner(i18n.compliance_banner),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
],
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: UiButton.primary(
|
||||
text: i18n.request_payment(benefit: benefit.title),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF0038A8),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
// TODO: Implement payment request
|
||||
UiSnackbar.show(context, message: i18n.request_submitted(benefit: benefit.title), type: UiSnackbarType.success);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressCircle() {
|
||||
final double progress = benefit.entitlementHours > 0
|
||||
? (benefit.remainingHours / benefit.entitlementHours)
|
||||
: 0.0;
|
||||
|
||||
final bool isSickLeave = benefit.title.toLowerCase().contains('sick');
|
||||
final Color circleColor = isSickLeave ? const Color(0xFF2563EB) : const Color(0xFF10B981);
|
||||
|
||||
return SizedBox(
|
||||
width: 72,
|
||||
height: 72,
|
||||
child: CustomPaint(
|
||||
painter: _CircularProgressPainter(
|
||||
progress: progress,
|
||||
color: circleColor,
|
||||
backgroundColor: const Color(0xFFE2E8F0),
|
||||
strokeWidth: 6,
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'${benefit.remainingHours.toInt()}/${benefit.entitlementHours.toInt()}',
|
||||
style: UiTypography.body2b.textPrimary.copyWith(fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
t.client_billing.hours_suffix,
|
||||
style: UiTypography.footnote2r.textTertiary.copyWith(fontSize: 9),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatsRow() {
|
||||
final i18n = t.staff.home.benefits.overview;
|
||||
return Row(
|
||||
children: [
|
||||
_buildStatChip(
|
||||
i18n.entitlement,
|
||||
'${benefit.entitlementHours.toInt()}',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildStatChip(
|
||||
i18n.used,
|
||||
'${benefit.usedHours.toInt()}',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildStatChip(
|
||||
i18n.remaining,
|
||||
'${benefit.remainingHours.toInt()}',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatChip(String label, String value) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF1F5F9),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: UiTypography.footnote2r.textTertiary.copyWith(
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$value ${t.staff.home.benefits.overview.hours}',
|
||||
style: UiTypography.footnote2b.textPrimary.copyWith(
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getSubtitle(String title) {
|
||||
final i18n = t.staff.home.benefits.overview;
|
||||
if (title.toLowerCase().contains('sick')) {
|
||||
return i18n.sick_leave_subtitle;
|
||||
} else if (title.toLowerCase().contains('vacation')) {
|
||||
return i18n.vacation_subtitle;
|
||||
} else if (title.toLowerCase().contains('holiday')) {
|
||||
return i18n.holidays_subtitle;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
Widget _buildComplianceBanner(String text) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFECFDF5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(UiIcons.checkCircle, size: 16, color: Color(0xFF10B981)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: UiTypography.footnote1r.copyWith(
|
||||
color: const Color(0xFF065F46),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CircularProgressPainter extends CustomPainter {
|
||||
final double progress;
|
||||
final Color color;
|
||||
final Color backgroundColor;
|
||||
final double strokeWidth;
|
||||
|
||||
_CircularProgressPainter({
|
||||
required this.progress,
|
||||
required this.color,
|
||||
required this.backgroundColor,
|
||||
required this.strokeWidth,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = (size.width - strokeWidth) / 2;
|
||||
|
||||
final backgroundPaint = Paint()
|
||||
..color = backgroundColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth;
|
||||
canvas.drawCircle(center, radius, backgroundPaint);
|
||||
|
||||
final progressPaint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth
|
||||
..strokeCap = StrokeCap.round;
|
||||
final sweepAngle = 2 * math.pi * progress;
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: radius),
|
||||
-math.pi / 2,
|
||||
sweepAngle,
|
||||
false,
|
||||
progressPaint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
}
|
||||
|
||||
class _AccordionHistory extends StatefulWidget {
|
||||
final String label;
|
||||
|
||||
const _AccordionHistory({required this.label});
|
||||
|
||||
@override
|
||||
State<_AccordionHistory> createState() => _AccordionHistoryState();
|
||||
}
|
||||
|
||||
class _AccordionHistoryState extends State<_AccordionHistory> {
|
||||
bool _isExpanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Divider(height: 1, color: Color(0xFFE2E8F0)),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_isExpanded = !_isExpanded;
|
||||
});
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
widget.label,
|
||||
style: UiTypography.footnote2b.textSecondary.copyWith(
|
||||
letterSpacing: 0.5,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
_isExpanded ? UiIcons.chevronUp : UiIcons.chevronDown,
|
||||
size: 16,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isExpanded) ...[
|
||||
_buildHistoryItem('1 Jan, 2024', 'Pending', const Color(0xFFF1F5F9), const Color(0xFF64748B)),
|
||||
const SizedBox(height: 14),
|
||||
_buildHistoryItem('28 Jan, 2024', 'Submitted', const Color(0xFFECFDF5), const Color(0xFF10B981)),
|
||||
const SizedBox(height: 14),
|
||||
_buildHistoryItem('5 Feb, 2024', 'Submitted', const Color(0xFFECFDF5), const Color(0xFF10B981)),
|
||||
const SizedBox(height: 14),
|
||||
_buildHistoryItem('28 Jan, 2024', 'Submitted', const Color(0xFFECFDF5), const Color(0xFF10B981)),
|
||||
const SizedBox(height: 14),
|
||||
_buildHistoryItem('5 Feb, 2024', 'Submitted', const Color(0xFFECFDF5), const Color(0xFF10B981)),
|
||||
const SizedBox(height: 4),
|
||||
]
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryItem(String date, String status, Color bgColor, Color textColor) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
date,
|
||||
style: UiTypography.footnote1r.textSecondary.copyWith(
|
||||
fontSize: 12,
|
||||
color: const Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: status == 'Pending' ? Border.all(color: const Color(0xFFE2E8F0)) : null,
|
||||
),
|
||||
child: Text(
|
||||
status,
|
||||
style: UiTypography.footnote2m.copyWith(
|
||||
color: textColor,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,15 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import 'package:staff_home/src/presentation/blocs/home_cubit.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart';
|
||||
import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/benefits_section.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/full_width_divider.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/home_header.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/placeholder_banner.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/quick_action_item.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/recommended_shift_card.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/section_header.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/shift_card.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/worker/benefits_widget.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/quick_actions_section.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/recommended_shifts_section.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/todays_shifts_section.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/tomorrows_shifts_section.dart';
|
||||
|
||||
/// The home page for the staff worker application.
|
||||
///
|
||||
@@ -36,9 +35,6 @@ class WorkerHomePage extends StatelessWidget {
|
||||
final t = Translations.of(context);
|
||||
final i18n = t.staff.home;
|
||||
final bannersI18n = i18n.banners;
|
||||
final quickI18n = i18n.quick_actions;
|
||||
final sectionsI18n = i18n.sections;
|
||||
final emptyI18n = i18n.empty_states;
|
||||
|
||||
return BlocProvider<HomeCubit>.value(
|
||||
value: Modular.get<HomeCubit>()..loadShifts(),
|
||||
@@ -67,8 +63,7 @@ class WorkerHomePage extends StatelessWidget {
|
||||
builder: (context, state) {
|
||||
if (!state.isProfileComplete) {
|
||||
return SizedBox(
|
||||
height: MediaQuery.of(context).size.height -
|
||||
300,
|
||||
height: MediaQuery.of(context).size.height - 300,
|
||||
child: Column(
|
||||
children: [
|
||||
PlaceholderBanner(
|
||||
@@ -85,7 +80,8 @@ class WorkerHomePage extends StatelessWidget {
|
||||
child: UiEmptyState(
|
||||
icon: UiIcons.users,
|
||||
title: 'Complete Your Profile',
|
||||
description: 'Finish setting up your profile to unlock shifts, view earnings, and start earning today.',
|
||||
description:
|
||||
'Finish setting up your profile to unlock shifts, view earnings, and start earning today.',
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -96,147 +92,23 @@ class WorkerHomePage extends StatelessWidget {
|
||||
return Column(
|
||||
children: [
|
||||
// Quick Actions
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: QuickActionItem(
|
||||
icon: UiIcons.search,
|
||||
label: quickI18n.find_shifts,
|
||||
onTap: () => Modular.to.toShifts(),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: QuickActionItem(
|
||||
icon: UiIcons.calendar,
|
||||
label: quickI18n.availability,
|
||||
onTap: () => Modular.to.toAvailability(),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: QuickActionItem(
|
||||
icon: UiIcons.dollar,
|
||||
label: quickI18n.earnings,
|
||||
onTap: () => Modular.to.toPayments(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
const QuickActionsSection(),
|
||||
const FullWidthDivider(),
|
||||
|
||||
// Today's Shifts
|
||||
BlocBuilder<HomeCubit, HomeState>(
|
||||
builder: (context, state) {
|
||||
final shifts = state.todayShifts;
|
||||
return Column(
|
||||
children: [
|
||||
SectionHeader(
|
||||
title: sectionsI18n.todays_shift,
|
||||
action: shifts.isNotEmpty
|
||||
? sectionsI18n.scheduled_count(
|
||||
count: shifts.length,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
if (state.status == HomeStatus.loading)
|
||||
const Center(
|
||||
child: SizedBox(
|
||||
height: UiConstants.space10,
|
||||
width: UiConstants.space10,
|
||||
child: CircularProgressIndicator(
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (shifts.isEmpty)
|
||||
EmptyStateWidget(
|
||||
message: emptyI18n.no_shifts_today,
|
||||
actionLink: emptyI18n.find_shifts_cta,
|
||||
onAction: () =>
|
||||
Modular.to.toShifts(initialTab: 'find'),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: shifts
|
||||
.map(
|
||||
(shift) => ShiftCard(
|
||||
shift: shift,
|
||||
compact: true,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
const TodaysShiftsSection(),
|
||||
const FullWidthDivider(),
|
||||
|
||||
// Tomorrow's Shifts
|
||||
BlocBuilder<HomeCubit, HomeState>(
|
||||
builder: (context, state) {
|
||||
final shifts = state.tomorrowShifts;
|
||||
return Column(
|
||||
children: [
|
||||
SectionHeader(title: sectionsI18n.tomorrow),
|
||||
if (shifts.isEmpty)
|
||||
EmptyStateWidget(
|
||||
message: emptyI18n.no_shifts_tomorrow,
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: shifts
|
||||
.map(
|
||||
(shift) => ShiftCard(
|
||||
shift: shift,
|
||||
compact: true,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
const TomorrowsShiftsSection(),
|
||||
const FullWidthDivider(),
|
||||
|
||||
// Recommended Shifts
|
||||
SectionHeader(title: sectionsI18n.recommended_for_you),
|
||||
BlocBuilder<HomeCubit, HomeState>(
|
||||
builder: (context, state) {
|
||||
if (state.recommendedShifts.isEmpty) {
|
||||
return EmptyStateWidget(
|
||||
message: emptyI18n.no_recommended_shifts,
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
height: 160,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: state.recommendedShifts.length,
|
||||
clipBehavior: Clip.none,
|
||||
itemBuilder: (context, index) => Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: UiConstants.space3,
|
||||
),
|
||||
child: RecommendedShiftCard(
|
||||
shift: state.recommendedShifts[index],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
const RecommendedShiftsSection(),
|
||||
const FullWidthDivider(),
|
||||
|
||||
// Benefits
|
||||
BlocBuilder<HomeCubit, HomeState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.benefits != current.benefits,
|
||||
builder: (context, state) {
|
||||
return BenefitsWidget(benefits: state.benefits);
|
||||
},
|
||||
),
|
||||
const BenefitsSection(),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
// ],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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]),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,68 +13,34 @@ class RecommendedShiftCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final recI18n = t.staff.home.recommended_card;
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Modular.to.toShiftDetails(shift);
|
||||
},
|
||||
child: Container(
|
||||
width: 300,
|
||||
width: size.width * 0.8,
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: UiColors.black.withValues(alpha: 0.02),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
border: Border.all(color: UiColors.border, width: 0.5),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
recI18n.act_now,
|
||||
style: UiTypography.body3m.copyWith(
|
||||
color: UiColors.textError,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space2,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.tagInProgress,
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
child: Text(
|
||||
recI18n.one_day,
|
||||
style: UiTypography.body3m.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: UiConstants.space10,
|
||||
height: UiConstants.space10,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.tagInProgress,
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase,
|
||||
),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.calendar,
|
||||
@@ -85,10 +51,11 @@ class RecommendedShiftCard extends StatelessWidget {
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: UiConstants.space1,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
@@ -99,13 +66,13 @@ class RecommendedShiftCard extends StatelessWidget {
|
||||
),
|
||||
Text(
|
||||
'\$${shift.hourlyRate}/h',
|
||||
style: UiTypography.headline4m.textPrimary,
|
||||
style: UiTypography.headline4b,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: UiConstants.space1,
|
||||
children: [
|
||||
Text(
|
||||
shift.clientName,
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -39,11 +39,14 @@ class SectionHeader extends StatelessWidget {
|
||||
onTap: onAction,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(action ?? '', style: UiTypography.body3r),
|
||||
Text(
|
||||
action ?? '',
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
const Icon(
|
||||
UiIcons.chevronRight,
|
||||
size: UiConstants.space4,
|
||||
color: UiColors.primary,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:staff_home/src/data/repositories/home_repository_impl.dart';
|
||||
import 'package:staff_home/src/domain/repositories/home_repository.dart';
|
||||
import 'package:staff_home/src/presentation/blocs/home_cubit.dart';
|
||||
import 'package:staff_home/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart';
|
||||
import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart';
|
||||
import 'package:staff_home/src/presentation/pages/benefits_overview_page.dart';
|
||||
import 'package:staff_home/src/presentation/pages/worker_home_page.dart';
|
||||
|
||||
@@ -31,13 +32,18 @@ class StaffHomeModule extends Module {
|
||||
),
|
||||
);
|
||||
|
||||
// Presentation layer - Cubit
|
||||
// Presentation layer - Cubits
|
||||
i.addSingleton(
|
||||
() => HomeCubit(
|
||||
repository: i.get<HomeRepository>(),
|
||||
getProfileCompletion: i.get<GetProfileCompletionUseCase>(),
|
||||
),
|
||||
);
|
||||
|
||||
// Cubit for benefits overview page
|
||||
i.addLazySingleton(
|
||||
() => BenefitsOverviewCubit(repository: i.get<HomeRepository>()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -17,6 +17,10 @@ class StaffMainCubit extends Cubit<StaffMainState> implements Disposable {
|
||||
final GetProfileCompletionUseCase _getProfileCompletionUsecase;
|
||||
bool _isLoadingCompletion = false;
|
||||
|
||||
static const List<String> _hideBottomPaths = <String>[
|
||||
StaffPaths.benefits,
|
||||
];
|
||||
|
||||
void _onRouteChanged() {
|
||||
if (isClosed) return;
|
||||
|
||||
@@ -40,8 +44,10 @@ class StaffMainCubit extends Cubit<StaffMainState> implements Disposable {
|
||||
newIndex = 4;
|
||||
}
|
||||
|
||||
if (newIndex != state.currentIndex) {
|
||||
emit(state.copyWith(currentIndex: newIndex));
|
||||
final bool showBottomBar = !_hideBottomPaths.any(path.contains);
|
||||
|
||||
if (newIndex != state.currentIndex || showBottomBar != state.showBottomBar) {
|
||||
emit(state.copyWith(currentIndex: newIndex, showBottomBar: showBottomBar));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,18 +4,25 @@ class StaffMainState extends Equatable {
|
||||
const StaffMainState({
|
||||
this.currentIndex = 2, // Default to Home
|
||||
this.isProfileComplete = false,
|
||||
this.showBottomBar = true,
|
||||
});
|
||||
|
||||
final int currentIndex;
|
||||
final bool isProfileComplete;
|
||||
final bool showBottomBar;
|
||||
|
||||
StaffMainState copyWith({int? currentIndex, bool? isProfileComplete}) {
|
||||
StaffMainState copyWith({
|
||||
int? currentIndex,
|
||||
bool? isProfileComplete,
|
||||
bool? showBottomBar,
|
||||
}) {
|
||||
return StaffMainState(
|
||||
currentIndex: currentIndex ?? this.currentIndex,
|
||||
isProfileComplete: isProfileComplete ?? this.isProfileComplete,
|
||||
showBottomBar: showBottomBar ?? this.showBottomBar,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object> get props => <Object>[currentIndex, isProfileComplete];
|
||||
List<Object> get props => <Object>[currentIndex, isProfileComplete, showBottomBar];
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ class StaffMainPage extends StatelessWidget {
|
||||
body: const RouterOutlet(),
|
||||
bottomNavigationBar: BlocBuilder<StaffMainCubit, StaffMainState>(
|
||||
builder: (BuildContext context, StaffMainState state) {
|
||||
if (!state.showBottomBar) return const SizedBox.shrink();
|
||||
return StaffMainBottomBar(
|
||||
currentIndex: state.currentIndex,
|
||||
onTap: (int index) {
|
||||
|
||||
Reference in New Issue
Block a user