diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart new file mode 100644 index 00000000..f6f1bffb --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart @@ -0,0 +1,42 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_home/src/domain/repositories/home_repository.dart'; + +part 'benefits_overview_state.dart'; + +/// Cubit to manage benefits overview page state. +class BenefitsOverviewCubit extends Cubit + with BlocErrorHandler { + final HomeRepository _repository; + + BenefitsOverviewCubit({required HomeRepository repository}) + : _repository = repository, + super(const BenefitsOverviewState.initial()); + + Future loadBenefits() async { + if (isClosed) return; + emit(state.copyWith(status: BenefitsOverviewStatus.loading)); + await handleError( + emit: emit, + action: () async { + final benefits = await _repository.getBenefits(); + if (isClosed) return; + emit( + state.copyWith( + status: BenefitsOverviewStatus.loaded, + benefits: benefits, + ), + ); + }, + onError: (String errorKey) { + if (isClosed) return state; + return state.copyWith( + status: BenefitsOverviewStatus.error, + errorMessage: errorKey, + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_state.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_state.dart new file mode 100644 index 00000000..768a2146 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/benefits_overview/benefits_overview_state.dart @@ -0,0 +1,33 @@ +part of 'benefits_overview_cubit.dart'; + +enum BenefitsOverviewStatus { initial, loading, loaded, error } + +class BenefitsOverviewState extends Equatable { + final BenefitsOverviewStatus status; + final List benefits; + final String? errorMessage; + + const BenefitsOverviewState({ + required this.status, + this.benefits = const [], + this.errorMessage, + }); + + const BenefitsOverviewState.initial() + : this(status: BenefitsOverviewStatus.initial); + + BenefitsOverviewState copyWith({ + BenefitsOverviewStatus? status, + List? benefits, + String? errorMessage, + }) { + return BenefitsOverviewState( + status: status ?? this.status, + benefits: benefits ?? this.benefits, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [status, benefits, errorMessage]; +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_cubit.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home/home_cubit.dart similarity index 100% rename from apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_cubit.dart rename to apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home/home_cubit.dart diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_state.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home/home_state.dart similarity index 100% rename from apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_state.dart rename to apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home/home_state.dart diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart index b017c9f2..fad93b89 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart @@ -1,12 +1,10 @@ -import 'dart:math' as math; - import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_domain/krow_domain.dart'; -import 'package:staff_home/src/presentation/blocs/home_cubit.dart'; +import 'package:staff_home/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart'; +import 'package:staff_home/src/presentation/widgets/benefits_overview/benefits_overview_body.dart'; /// Page displaying a detailed overview of the worker's benefits. class BenefitsOverviewPage extends StatelessWidget { @@ -15,33 +13,37 @@ class BenefitsOverviewPage extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider.value( - value: Modular.get(), - child: Scaffold( - backgroundColor: const Color(0xFFF8FAFC), - appBar: _buildAppBar(context), - body: BlocBuilder( + return Scaffold( + appBar: UiAppBar( + title: t.staff.home.benefits.overview.title, + subtitle: t.staff.home.benefits.overview.subtitle, + showBackButton: true, + ), + body: BlocProvider( + create: (context) => + Modular.get()..loadBenefits(), + child: BlocBuilder( builder: (context, state) { - if (state.status == HomeStatus.loading || - state.status == HomeStatus.initial) { + if (state.status == BenefitsOverviewStatus.loading || + state.status == BenefitsOverviewStatus.initial) { return const Center(child: CircularProgressIndicator()); } - - if (state.status == HomeStatus.error) { + + if (state.status == BenefitsOverviewStatus.error) { return Center( child: Padding( padding: const EdgeInsets.all(UiConstants.space6), child: Text( - state.errorMessage ?? t.staff.home.benefits.overview.subtitle, + state.errorMessage ?? + t.staff.home.benefits.overview.subtitle, style: UiTypography.body1r.textSecondary, textAlign: TextAlign.center, ), ), ); } - - final benefits = state.benefits; - if (benefits.isEmpty) { + + if (state.benefits.isEmpty) { return Center( child: Padding( padding: const EdgeInsets.all(UiConstants.space6), @@ -53,401 +55,11 @@ class BenefitsOverviewPage extends StatelessWidget { ), ); } - - return ListView.builder( - padding: const EdgeInsets.only( - left: UiConstants.space4, - right: UiConstants.space4, - top: UiConstants.space6, - bottom: 120, - ), - itemCount: benefits.length, - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space4), - child: _BenefitCard(benefit: benefits[index]), - ); - }, - ); + + return BenefitsOverviewBody(benefits: state.benefits); }, ), ), ); } - - PreferredSizeWidget _buildAppBar(BuildContext context) { - return AppBar( - backgroundColor: Colors.white, - elevation: 0, - leading: IconButton( - icon: const Icon(UiIcons.chevronLeft, color: UiColors.iconPrimary), - onPressed: () => Navigator.of(context).pop(), - ), - centerTitle: true, - title: Column( - children: [ - Text( - t.staff.home.benefits.overview.title, - style: UiTypography.title2b.textPrimary, - ), - const SizedBox(height: 2), - Text( - t.staff.home.benefits.overview.subtitle, - style: UiTypography.footnote2r.textSecondary, - ), - ], - ), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1), - child: Container(color: UiColors.border.withOpacity(0.5), height: 1), - ), - ); - } - } - - class _BenefitCard extends StatelessWidget { - final Benefit benefit; - - const _BenefitCard({required this.benefit}); - - @override - Widget build(BuildContext context) { - final bool isSickLeave = benefit.title.toLowerCase().contains('sick'); - final bool isVacation = benefit.title.toLowerCase().contains('vacation'); - final bool isHolidays = benefit.title.toLowerCase().contains('holiday'); - - final i18n = t.staff.home.benefits.overview; - - return Container( - padding: const EdgeInsets.all(UiConstants.space6), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border.withOpacity(0.5)), - boxShadow: [ - BoxShadow( - color: UiColors.black.withOpacity(0.02), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - _buildProgressCircle(), - const SizedBox(width: UiConstants.space4), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - benefit.title, - style: UiTypography.body1b.textPrimary, - ), - const SizedBox(height: 4), - Text( - _getSubtitle(benefit.title), - style: UiTypography.footnote2r.textSecondary, - ), - const SizedBox(height: UiConstants.space4), - _buildStatsRow(), - ], - ), - ), - ], - ), - const SizedBox(height: UiConstants.space6), - if (isSickLeave) ...[ - _AccordionHistory(label: i18n.sick_leave_history), - const SizedBox(height: UiConstants.space6), - ], - if (isVacation || isHolidays) ...[ - _buildComplianceBanner(i18n.compliance_banner), - const SizedBox(height: UiConstants.space6), - ], - SizedBox( - width: double.infinity, - child: UiButton.primary( - text: i18n.request_payment(benefit: benefit.title), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF0038A8), - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - onPressed: () { - // TODO: Implement payment request - UiSnackbar.show(context, message: i18n.request_submitted(benefit: benefit.title), type: UiSnackbarType.success); - }, - ), - ), - ], - ), - ); - } - - Widget _buildProgressCircle() { - final double progress = benefit.entitlementHours > 0 - ? (benefit.remainingHours / benefit.entitlementHours) - : 0.0; - - final bool isSickLeave = benefit.title.toLowerCase().contains('sick'); - final Color circleColor = isSickLeave ? const Color(0xFF2563EB) : const Color(0xFF10B981); - - return SizedBox( - width: 72, - height: 72, - child: CustomPaint( - painter: _CircularProgressPainter( - progress: progress, - color: circleColor, - backgroundColor: const Color(0xFFE2E8F0), - strokeWidth: 6, - ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '${benefit.remainingHours.toInt()}/${benefit.entitlementHours.toInt()}', - style: UiTypography.body2b.textPrimary.copyWith(fontSize: 14), - ), - Text( - t.client_billing.hours_suffix, - style: UiTypography.footnote2r.textTertiary.copyWith(fontSize: 9), - ), - ], - ), - ), - ), - ); - } - - Widget _buildStatsRow() { - final i18n = t.staff.home.benefits.overview; - return Row( - children: [ - _buildStatChip( - i18n.entitlement, - '${benefit.entitlementHours.toInt()}', - ), - const SizedBox(width: 8), - _buildStatChip( - i18n.used, - '${benefit.usedHours.toInt()}', - ), - const SizedBox(width: 8), - _buildStatChip( - i18n.remaining, - '${benefit.remainingHours.toInt()}', - ), - ], - ); - } - - Widget _buildStatChip(String label, String value) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFFF1F5F9), - borderRadius: BorderRadius.circular(6), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: UiTypography.footnote2r.textTertiary.copyWith( - fontSize: 10, - ), - ), - Text( - '$value ${t.staff.home.benefits.overview.hours}', - style: UiTypography.footnote2b.textPrimary.copyWith( - fontSize: 12, - ), - ), - ], - ), - ); - } - - String _getSubtitle(String title) { - final i18n = t.staff.home.benefits.overview; - if (title.toLowerCase().contains('sick')) { - return i18n.sick_leave_subtitle; - } else if (title.toLowerCase().contains('vacation')) { - return i18n.vacation_subtitle; - } else if (title.toLowerCase().contains('holiday')) { - return i18n.holidays_subtitle; - } - return ''; - } - - Widget _buildComplianceBanner(String text) { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFECFDF5), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(UiIcons.checkCircle, size: 16, color: Color(0xFF10B981)), - const SizedBox(width: 8), - Expanded( - child: Text( - text, - style: UiTypography.footnote1r.copyWith( - color: const Color(0xFF065F46), - fontSize: 11, - ), - ), - ), - ], - ), - ); - } - } - -class _CircularProgressPainter extends CustomPainter { - final double progress; - final Color color; - final Color backgroundColor; - final double strokeWidth; - - _CircularProgressPainter({ - required this.progress, - required this.color, - required this.backgroundColor, - required this.strokeWidth, - }); - - @override - void paint(Canvas canvas, Size size) { - final center = Offset(size.width / 2, size.height / 2); - final radius = (size.width - strokeWidth) / 2; - - final backgroundPaint = Paint() - ..color = backgroundColor - ..style = PaintingStyle.stroke - ..strokeWidth = strokeWidth; - canvas.drawCircle(center, radius, backgroundPaint); - - final progressPaint = Paint() - ..color = color - ..style = PaintingStyle.stroke - ..strokeWidth = strokeWidth - ..strokeCap = StrokeCap.round; - final sweepAngle = 2 * math.pi * progress; - canvas.drawArc( - Rect.fromCircle(center: center, radius: radius), - -math.pi / 2, - sweepAngle, - false, - progressPaint, - ); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => true; -} - -class _AccordionHistory extends StatefulWidget { - final String label; - - const _AccordionHistory({required this.label}); - - @override - State<_AccordionHistory> createState() => _AccordionHistoryState(); -} - -class _AccordionHistoryState extends State<_AccordionHistory> { - bool _isExpanded = false; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Divider(height: 1, color: Color(0xFFE2E8F0)), - InkWell( - onTap: () { - setState(() { - _isExpanded = !_isExpanded; - }); - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - widget.label, - style: UiTypography.footnote2b.textSecondary.copyWith( - letterSpacing: 0.5, - fontSize: 11, - ), - ), - Icon( - _isExpanded ? UiIcons.chevronUp : UiIcons.chevronDown, - size: 16, - color: UiColors.iconSecondary, - ), - ], - ), - ), - ), - if (_isExpanded) ...[ - _buildHistoryItem('1 Jan, 2024', 'Pending', const Color(0xFFF1F5F9), const Color(0xFF64748B)), - const SizedBox(height: 14), - _buildHistoryItem('28 Jan, 2024', 'Submitted', const Color(0xFFECFDF5), const Color(0xFF10B981)), - const SizedBox(height: 14), - _buildHistoryItem('5 Feb, 2024', 'Submitted', const Color(0xFFECFDF5), const Color(0xFF10B981)), - const SizedBox(height: 14), - _buildHistoryItem('28 Jan, 2024', 'Submitted', const Color(0xFFECFDF5), const Color(0xFF10B981)), - const SizedBox(height: 14), - _buildHistoryItem('5 Feb, 2024', 'Submitted', const Color(0xFFECFDF5), const Color(0xFF10B981)), - const SizedBox(height: 4), - ] - ], - ); - } - - Widget _buildHistoryItem(String date, String status, Color bgColor, Color textColor) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - date, - style: UiTypography.footnote1r.textSecondary.copyWith( - fontSize: 12, - color: const Color(0xFF64748B), - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(12), - border: status == 'Pending' ? Border.all(color: const Color(0xFFE2E8F0)) : null, - ), - child: Text( - status, - style: UiTypography.footnote2m.copyWith( - color: textColor, - fontSize: 10, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ); - } } diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart index 2a30f19b..5045548b 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart @@ -4,7 +4,7 @@ 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/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'; diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/accordion_history.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/accordion_history.dart new file mode 100644 index 00000000..89ce65e2 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/accordion_history.dart @@ -0,0 +1,140 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Widget displaying collapsible benefit history. +class AccordionHistory extends StatefulWidget { + /// The label for the accordion header. + final String label; + + /// Creates an [AccordionHistory]. + const AccordionHistory({required this.label, super.key}); + + @override + State createState() => _AccordionHistoryState(); +} + +class _AccordionHistoryState extends State { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(height: 1, color: Color(0xFFE2E8F0)), + InkWell( + onTap: () { + setState(() { + _isExpanded = !_isExpanded; + }); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + widget.label, + style: UiTypography.footnote2b.textSecondary.copyWith( + letterSpacing: 0.5, + fontSize: 11, + ), + ), + Icon( + _isExpanded ? UiIcons.chevronUp : UiIcons.chevronDown, + size: 16, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ), + if (_isExpanded) ...[ + _HistoryItem( + date: '1 Jan, 2024', + status: 'Pending', + bgColor: const Color(0xFFF1F5F9), + textColor: const Color(0xFF64748B), + ), + const SizedBox(height: 14), + _HistoryItem( + date: '28 Jan, 2024', + status: 'Submitted', + bgColor: const Color(0xFFECFDF5), + textColor: const Color(0xFF10B981), + ), + const SizedBox(height: 14), + _HistoryItem( + date: '5 Feb, 2024', + status: 'Submitted', + bgColor: const Color(0xFFECFDF5), + textColor: const Color(0xFF10B981), + ), + const SizedBox(height: 14), + _HistoryItem( + date: '28 Jan, 2024', + status: 'Submitted', + bgColor: const Color(0xFFECFDF5), + textColor: const Color(0xFF10B981), + ), + const SizedBox(height: 14), + _HistoryItem( + date: '5 Feb, 2024', + status: 'Submitted', + bgColor: const Color(0xFFECFDF5), + textColor: const Color(0xFF10B981), + ), + const SizedBox(height: 4), + ], + ], + ); + } +} + +class _HistoryItem extends StatelessWidget { + final String date; + final String status; + final Color bgColor; + final Color textColor; + + const _HistoryItem({ + required this.date, + required this.status, + required this.bgColor, + required this.textColor, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + date, + style: UiTypography.footnote1r.textSecondary.copyWith( + fontSize: 12, + color: const Color(0xFF64748B), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(12), + border: status == 'Pending' + ? Border.all(color: const Color(0xFFE2E8F0)) + : null, + ), + child: Text( + status, + style: UiTypography.footnote2m.copyWith( + color: textColor, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card.dart new file mode 100644 index 00000000..fac3ed40 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card.dart @@ -0,0 +1,77 @@ +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/accordion_history.dart'; +import 'package:staff_home/src/presentation/widgets/benefits_overview/benefit_card_header.dart'; +import 'package:staff_home/src/presentation/widgets/benefits_overview/compliance_banner.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: 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: [ + 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), + ], + 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, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card_header.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card_header.dart new file mode 100644 index 00000000..54791f75 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefit_card_header.dart @@ -0,0 +1,118 @@ +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, + ), + const SizedBox(height: 4), + Text( + _getSubtitle(benefit.title), + style: UiTypography.footnote2r.textSecondary, + ), + const SizedBox(height: UiConstants.space4), + _buildStatsRow(i18n), + ], + ), + ), + ], + ); + } + + 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(dynamic i18n) { + return Row( + children: [ + StatChip( + label: i18n.entitlement, + value: '${benefit.entitlementHours.toInt()}', + ), + const SizedBox(width: 8), + StatChip( + label: i18n.used, + value: '${benefit.usedHours.toInt()}', + ), + const SizedBox(width: 8), + StatChip( + label: i18n.remaining, + value: '${benefit.remainingHours.toInt()}', + ), + ], + ); + } + + String _getSubtitle(String title) { + final i18n = t.staff.home.benefits.overview; + if (title.toLowerCase().contains('sick')) { + return i18n.sick_leave_subtitle; + } else if (title.toLowerCase().contains('vacation')) { + return i18n.vacation_subtitle; + } else if (title.toLowerCase().contains('holiday')) { + return i18n.holidays_subtitle; + } + return ''; + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefits_overview_body.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefits_overview_body.dart new file mode 100644 index 00000000..94d9d8e8 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/benefits_overview_body.dart @@ -0,0 +1,32 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_home/src/presentation/widgets/benefits_overview/benefit_card.dart'; + +/// Body widget displaying a list of benefit cards. +class BenefitsOverviewBody extends StatelessWidget { + /// The list of benefits to display. + final List benefits; + + /// Creates a [BenefitsOverviewBody]. + const BenefitsOverviewBody({required this.benefits, super.key}); + + @override + Widget build(BuildContext context) { + return ListView.builder( + padding: const EdgeInsets.only( + left: UiConstants.space4, + right: UiConstants.space4, + top: UiConstants.space6, + bottom: 120, + ), + itemCount: benefits.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space4), + child: BenefitCard(benefit: benefits[index]), + ); + }, + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/circular_progress_painter.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/circular_progress_painter.dart new file mode 100644 index 00000000..bb3dd55b --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/circular_progress_painter.dart @@ -0,0 +1,47 @@ +import 'dart:math' as math; + +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, + required this.color, + required this.backgroundColor, + required this.strokeWidth, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = (size.width - strokeWidth) / 2; + + final backgroundPaint = Paint() + ..color = backgroundColor + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth; + canvas.drawCircle(center, radius, backgroundPaint); + + final progressPaint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + final sweepAngle = 2 * math.pi * progress; + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -math.pi / 2, + sweepAngle, + false, + progressPaint, + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/compliance_banner.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/compliance_banner.dart new file mode 100644 index 00000000..170ef438 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/compliance_banner.dart @@ -0,0 +1,45 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Widget displaying a compliance information banner. +class ComplianceBanner extends StatelessWidget { + /// The text to display in the banner. + final String text; + + /// Creates a [ComplianceBanner]. + const ComplianceBanner({ + required this.text, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFECFDF5), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + UiIcons.checkCircle, + size: 16, + color: Color(0xFF10B981), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: UiTypography.footnote1r.copyWith( + color: const Color(0xFF065F46), + fontSize: 11, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/stat_chip.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/stat_chip.dart new file mode 100644 index 00000000..8f551871 --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/benefits_overview/stat_chip.dart @@ -0,0 +1,44 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Widget displaying a single statistic chip. +class StatChip extends StatelessWidget { + /// The label for the stat (e.g., "Entitlement", "Used", "Remaining"). + final String label; + + /// The numeric value to display. + final String value; + + /// Creates a [StatChip]. + const StatChip({ + required this.label, + required this.value, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: 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), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/benefits_section.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/benefits_section.dart index cb8baedb..edcd4caa 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/benefits_section.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/benefits_section.dart @@ -4,7 +4,7 @@ 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/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'; diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart index d3016ba5..51f863d3 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shift_card.dart @@ -80,7 +80,7 @@ class RecommendedShiftCard extends StatelessWidget { ), Text( '\$${shift.hourlyRate.toStringAsFixed(0)}/hr', - style: UiTypography.body3r, + style: UiTypography.body3r.textSecondary, ), ], ), diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shifts_section.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shifts_section.dart index 2dc63e0c..0410bc1f 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shifts_section.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/recommended_shifts_section.dart @@ -3,7 +3,7 @@ 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_cubit.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'; diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/todays_shifts_section.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/todays_shifts_section.dart index b2bec50c..764da501 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/todays_shifts_section.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/todays_shifts_section.dart @@ -5,7 +5,7 @@ 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/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'; diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/tomorrows_shifts_section.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/tomorrows_shifts_section.dart index ee372f54..b372856d 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/tomorrows_shifts_section.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/home_page/tomorrows_shifts_section.dart @@ -2,7 +2,7 @@ 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_cubit.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'; diff --git a/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart b/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart index 74cc76c4..0b319174 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart @@ -4,7 +4,8 @@ import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:staff_home/src/data/repositories/home_repository_impl.dart'; import 'package:staff_home/src/domain/repositories/home_repository.dart'; -import 'package:staff_home/src/presentation/blocs/home_cubit.dart'; +import 'package:staff_home/src/presentation/blocs/benefits_overview/benefits_overview_cubit.dart'; +import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart'; import 'package:staff_home/src/presentation/pages/benefits_overview_page.dart'; import 'package:staff_home/src/presentation/pages/worker_home_page.dart'; @@ -31,13 +32,18 @@ class StaffHomeModule extends Module { ), ); - // Presentation layer - Cubit + // Presentation layer - Cubits i.addSingleton( () => HomeCubit( repository: i.get(), getProfileCompletion: i.get(), ), ); + + // Cubit for benefits overview page + i.addLazySingleton( + () => BenefitsOverviewCubit(repository: i.get()), + ); } @override