From a0d5a18e6feae9e5713f08906e72bc866e820503 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Tue, 17 Mar 2026 13:08:32 -0400 Subject: [PATCH] Refactor shift history and my shifts tabs to use a unified ShiftCard widget - Introduced ShiftCard widget to standardize the display of shift information across different states (assigned, completed, cancelled, pending). - Removed redundant card implementations (_CompletedShiftCard, MyShiftCard, ShiftAssignmentCard) and replaced them with ShiftCard. - Updated localization for empty states and shift titles in HistoryShiftsTab and MyShiftsTab. - Enhanced MyShiftsTab to track submitted shifts locally and show appropriate actions based on shift status. - Added meta package dependency for improved type annotations. --- .../src/entities/shifts/completed_shift.dart | 8 +- .../shifts_repository_impl.dart | 4 +- .../src/presentation/widgets/shift_card.dart | 775 ++++++++++++++++++ .../widgets/tabs/history_shifts_tab.dart | 104 +-- .../widgets/tabs/my_shifts_tab.dart | 359 ++++---- .../features/staff/shifts/pubspec.yaml | 1 + 6 files changed, 945 insertions(+), 306 deletions(-) create mode 100644 apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card.dart diff --git a/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart b/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart index 01b6f005..3d3e47e2 100644 --- a/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart +++ b/apps/mobile/packages/domain/lib/src/entities/shifts/completed_shift.dart @@ -1,6 +1,5 @@ import 'package:equatable/equatable.dart'; - -import 'package:krow_domain/src/entities/enums/payment_status.dart'; +import 'package:krow_domain/krow_domain.dart'; /// A shift the staff member has completed. /// @@ -16,6 +15,7 @@ class CompletedShift extends Equatable { required this.date, required this.minutesWorked, required this.paymentStatus, + required this.status, }); /// Deserialises from the V2 API JSON response. @@ -28,6 +28,7 @@ class CompletedShift extends Equatable { date: DateTime.parse(json['date'] as String), minutesWorked: json['minutesWorked'] as int? ?? 0, paymentStatus: PaymentStatus.fromJson(json['paymentStatus'] as String?), + status: AssignmentStatus.completed, ); } @@ -52,6 +53,9 @@ class CompletedShift extends Equatable { /// Payment processing status. final PaymentStatus paymentStatus; + /// Assignment status (should always be `completed` for this class). + final AssignmentStatus status; + /// Serialises to JSON. Map toJson() { return { diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart index 8835d825..ee27ea03 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart @@ -96,10 +96,12 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface { final ApiResponse response = await _apiService.get(StaffEndpoints.shiftsCompleted); final List items = _extractItems(response.data); - return items + var x = items .map((dynamic json) => CompletedShift.fromJson(json as Map)) .toList(); + + return x; } @override diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card.dart new file mode 100644 index 00000000..63b83e93 --- /dev/null +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/shift_card.dart @@ -0,0 +1,775 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Variant that controls the visual treatment of the [ShiftCard]. +/// +/// Each variant maps to a different colour scheme for the status badge and +/// optional footer action area. +enum ShiftCardVariant { + /// Confirmed / accepted assignment. + confirmed, + + /// Pending assignment awaiting acceptance. + pending, + + /// Cancelled assignment. + cancelled, + + /// Completed shift (history). + completed, + + /// Worker is currently checked in. + checkedIn, + + /// A swap has been requested. + swapRequested, +} + +/// Immutable data model that feeds the [ShiftCard]. +/// +/// Acts as an adapter between the various shift entity types +/// (`AssignedShift`, `CompletedShift`, `CancelledShift`, `PendingAssignment`) +/// and the unified card presentation. +class ShiftCardData { + /// Creates a [ShiftCardData]. + const ShiftCardData({ + required this.shiftId, + required this.title, + required this.location, + required this.date, + required this.variant, + this.subtitle, + this.startTime, + this.endTime, + this.hourlyRateCents, + this.orderType, + this.minutesWorked, + this.cancellationReason, + this.paymentStatus, + }); + + /// Constructs [ShiftCardData] from an [AssignedShift]. + factory ShiftCardData.fromAssigned(AssignedShift shift) { + return ShiftCardData( + shiftId: shift.shiftId, + title: shift.roleName, + subtitle: shift.location, + location: shift.location, + date: shift.date, + startTime: shift.startTime, + endTime: shift.endTime, + hourlyRateCents: shift.hourlyRateCents, + orderType: shift.orderType, + variant: _variantFromAssignmentStatus(shift.status), + ); + } + + /// Constructs [ShiftCardData] from a [CompletedShift]. + factory ShiftCardData.fromCompleted(CompletedShift shift) { + return ShiftCardData( + shiftId: shift.shiftId, + title: shift.title, + location: shift.location, + date: shift.date, + minutesWorked: shift.minutesWorked, + paymentStatus: shift.paymentStatus, + variant: ShiftCardVariant.completed, + ); + } + + /// Constructs [ShiftCardData] from a [CancelledShift]. + factory ShiftCardData.fromCancelled(CancelledShift shift) { + return ShiftCardData( + shiftId: shift.shiftId, + title: shift.title, + location: shift.location, + date: shift.date, + cancellationReason: shift.cancellationReason, + variant: ShiftCardVariant.cancelled, + ); + } + + /// Constructs [ShiftCardData] from a [PendingAssignment]. + factory ShiftCardData.fromPending(PendingAssignment assignment) { + return ShiftCardData( + shiftId: assignment.shiftId, + title: assignment.roleName, + subtitle: assignment.title.isNotEmpty ? assignment.title : null, + location: assignment.location, + date: assignment.startTime, + startTime: assignment.startTime, + endTime: assignment.endTime, + variant: ShiftCardVariant.pending, + ); + } + + /// The shift row id. + final String shiftId; + + /// Primary display title (role name or shift title). + final String title; + + /// Optional secondary text (e.g. location under the role name). + final String? subtitle; + + /// Human-readable location label. + final String location; + + /// The date of the shift. + final DateTime date; + + /// Scheduled start time (null for completed/cancelled). + final DateTime? startTime; + + /// Scheduled end time (null for completed/cancelled). + final DateTime? endTime; + + /// Hourly pay rate in cents (null when not applicable). + final int? hourlyRateCents; + + /// Order type (null for completed/cancelled). + final OrderType? orderType; + + /// Minutes worked (only for completed shifts). + final int? minutesWorked; + + /// Cancellation reason (only for cancelled shifts). + final String? cancellationReason; + + /// Payment processing status (only for completed shifts). + final PaymentStatus? paymentStatus; + + /// Visual variant for the card. + final ShiftCardVariant variant; + + static ShiftCardVariant _variantFromAssignmentStatus( + AssignmentStatus status, + ) { + switch (status) { + case AssignmentStatus.accepted: + return ShiftCardVariant.confirmed; + case AssignmentStatus.checkedIn: + return ShiftCardVariant.checkedIn; + case AssignmentStatus.swapRequested: + return ShiftCardVariant.swapRequested; + case AssignmentStatus.completed: + return ShiftCardVariant.completed; + case AssignmentStatus.cancelled: + return ShiftCardVariant.cancelled; + case AssignmentStatus.assigned: + return ShiftCardVariant.pending; + case AssignmentStatus.checkedOut: + case AssignmentStatus.noShow: + case AssignmentStatus.unknown: + return ShiftCardVariant.confirmed; + } + } +} + +/// Unified card widget for displaying shift information across all shift types. +/// +/// Replaces `MyShiftCard`, `ShiftAssignmentCard`, and the inline +/// `_CompletedShiftCard` / `_buildCancelledCard` from the tabs. Accepts a +/// [ShiftCardData] data model that adapts the various domain entities into a +/// common display shape. +class ShiftCard extends StatelessWidget { + /// Creates a [ShiftCard]. + const ShiftCard({ + super.key, + required this.data, + this.onTap, + this.onSubmitForApproval, + this.showApprovalAction = false, + this.isSubmitted = false, + this.onAccept, + this.onDecline, + this.isAccepting = false, + }); + + /// The shift data to display. + final ShiftCardData data; + + /// Callback when the card is tapped (typically navigates to shift details). + final VoidCallback? onTap; + + /// Callback when the "Submit for Approval" button is pressed. + final VoidCallback? onSubmitForApproval; + + /// Whether to show the submit-for-approval footer. + final bool showApprovalAction; + + /// Whether the timesheet has already been submitted. + final bool isSubmitted; + + /// Callback when the accept action is pressed (pending assignments only). + final VoidCallback? onAccept; + + /// Callback when the decline action is pressed (pending assignments only). + final VoidCallback? onDecline; + + /// Whether the accept action is in progress. + final bool isAccepting; + + /// Whether the accept/decline footer should be shown. + bool get _showPendingActions => onAccept != null || onDecline != null; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + boxShadow: _showPendingActions + ? [ + BoxShadow( + color: UiColors.black.withValues(alpha: 0.05), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ] + : null, + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _StatusBadge( + variant: data.variant, + orderType: data.orderType, + ), + const SizedBox(height: UiConstants.space2), + _CardBody(data: data), + if (showApprovalAction) ...[ + const SizedBox(height: UiConstants.space4), + const Divider(height: 1, color: UiColors.border), + const SizedBox(height: UiConstants.space2), + _ApprovalFooter( + isSubmitted: isSubmitted, + onSubmit: onSubmitForApproval, + ), + ], + ], + ), + ), + if (_showPendingActions) + _PendingActionsFooter( + onAccept: onAccept, + onDecline: onDecline, + isAccepting: isAccepting, + ), + ], + ), + ), + ); + } +} + +/// Displays the coloured status dot/icon and label, plus an optional order-type +/// chip. +class _StatusBadge extends StatelessWidget { + const _StatusBadge({required this.variant, this.orderType}); + + final ShiftCardVariant variant; + final OrderType? orderType; + + @override + Widget build(BuildContext context) { + final _StatusStyle style = _resolveStyle(context); + + return Row( + children: [ + if (style.icon != null) + Padding( + padding: const EdgeInsets.only(right: UiConstants.space2), + child: Icon( + style.icon, + size: UiConstants.iconXs, + color: style.foreground, + ), + ) + else + Container( + width: 8, + height: 8, + margin: const EdgeInsets.only(right: UiConstants.space2), + decoration: BoxDecoration( + color: style.dot, + shape: BoxShape.circle, + ), + ), + Text( + style.label, + style: UiTypography.footnote2b.copyWith( + color: style.foreground, + letterSpacing: 0.5, + ), + ), + if (orderType != null) ...[ + const SizedBox(width: UiConstants.space2), + _OrderTypeChip(orderType: orderType!), + ], + ], + ); + } + + _StatusStyle _resolveStyle(BuildContext context) { + switch (variant) { + case ShiftCardVariant.confirmed: + return _StatusStyle( + label: context.t.staff_shifts.status.confirmed, + foreground: UiColors.textLink, + dot: UiColors.primary, + ); + case ShiftCardVariant.pending: + return _StatusStyle( + label: context.t.staff_shifts.status.act_now, + foreground: UiColors.destructive, + dot: UiColors.destructive, + ); + case ShiftCardVariant.cancelled: + return _StatusStyle( + label: context.t.staff_shifts.my_shifts_tab.card.cancelled, + foreground: UiColors.destructive, + dot: UiColors.destructive, + ); + case ShiftCardVariant.completed: + return _StatusStyle( + label: context.t.staff_shifts.status.completed, + foreground: UiColors.textSuccess, + dot: UiColors.iconSuccess, + ); + case ShiftCardVariant.checkedIn: + return _StatusStyle( + label: context.t.staff_shifts.my_shift_card.checked_in, + foreground: UiColors.textSuccess, + dot: UiColors.iconSuccess, + ); + case ShiftCardVariant.swapRequested: + return _StatusStyle( + label: context.t.staff_shifts.status.swap_requested, + foreground: UiColors.textWarning, + dot: UiColors.textWarning, + icon: UiIcons.swap, + ); + } + } +} + +/// Internal helper grouping status badge presentation values. +class _StatusStyle { + const _StatusStyle({ + required this.label, + required this.foreground, + required this.dot, + this.icon, + }); + + final String label; + final Color foreground; + final Color dot; + final IconData? icon; +} + +/// Small chip showing the order type (One Day / Multi-Day / Long Term). +class _OrderTypeChip extends StatelessWidget { + const _OrderTypeChip({required this.orderType}); + + final OrderType orderType; + + @override + Widget build(BuildContext context) { + final String label = _label(context); + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: UiColors.background, + borderRadius: UiConstants.radiusSm, + border: Border.all(color: UiColors.border), + ), + child: Text( + label, + style: UiTypography.footnote2m.copyWith(color: UiColors.textSecondary), + ), + ); + } + + String _label(BuildContext context) { + switch (orderType) { + case OrderType.permanent: + return context.t.staff_shifts.filter.long_term; + case OrderType.recurring: + return context.t.staff_shifts.filter.multi_day; + case OrderType.oneTime: + case OrderType.rapid: + case OrderType.unknown: + return context.t.staff_shifts.filter.one_day; + } + } +} + +/// The main body: icon, title/subtitle, metadata rows, and optional pay info. +class _CardBody extends StatelessWidget { + const _CardBody({required this.data}); + + final ShiftCardData data; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ShiftIcon(variant: data.variant), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _TitleRow(data: data), + if (data.subtitle != null) ...[ + Text( + data.subtitle!, + style: UiTypography.body3r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: UiConstants.space2), + _MetadataRows(data: data), + if (data.cancellationReason != null && + data.cancellationReason!.isNotEmpty) ...[ + const SizedBox(height: UiConstants.space1), + Text( + data.cancellationReason!, + style: UiTypography.footnote2r.textSecondary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ); + } +} + +/// The 44x44 icon box with a gradient background. +class _ShiftIcon extends StatelessWidget { + const _ShiftIcon({required this.variant}); + + final ShiftCardVariant variant; + + @override + Widget build(BuildContext context) { + final bool isCancelled = variant == ShiftCardVariant.cancelled; + + return Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: isCancelled + ? null + : LinearGradient( + colors: [ + UiColors.primary.withValues(alpha: 0.09), + UiColors.primary.withValues(alpha: 0.03), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + color: isCancelled + ? UiColors.primary.withValues(alpha: 0.05) + : null, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: isCancelled + ? null + : Border.all( + color: UiColors.primary.withValues(alpha: 0.09), + ), + ), + child: const Center( + child: Icon( + UiIcons.briefcase, + color: UiColors.primary, + size: UiConstants.iconMd, + ), + ), + ); + } +} + +/// Title row with optional pay summary on the right. +class _TitleRow extends StatelessWidget { + const _TitleRow({required this.data}); + + final ShiftCardData data; + + @override + Widget build(BuildContext context) { + final bool hasPay = data.hourlyRateCents != null && + data.startTime != null && + data.endTime != null; + + if (!hasPay) { + return Text( + data.title, + style: UiTypography.body2m.textPrimary, + overflow: TextOverflow.ellipsis, + ); + } + + final double hourlyRate = data.hourlyRateCents! / 100; + final int durationMinutes = + data.endTime!.difference(data.startTime!).inMinutes; + double durationHours = durationMinutes / 60; + if (durationHours < 0) durationHours += 24; + durationHours = durationHours.roundToDouble(); + final double estimatedTotal = hourlyRate * durationHours; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + data.title, + style: UiTypography.body2m.textPrimary, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: UiConstants.space2), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '\$${estimatedTotal.toStringAsFixed(0)}', + style: UiTypography.title1m.textPrimary, + ), + Text( + '\$${hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h', + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), + ], + ); + } +} + +/// Date, time, location, and worked-hours rows. +class _MetadataRows extends StatelessWidget { + const _MetadataRows({required this.data}); + + final ShiftCardData data; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Date and time row + Row( + children: [ + const Icon( + UiIcons.calendar, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + _formatDate(context, data.date), + style: UiTypography.footnote1r.textSecondary, + ), + if (data.startTime != null && data.endTime != null) ...[ + const SizedBox(width: UiConstants.space3), + const Icon( + UiIcons.clock, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + '${_formatTime(data.startTime!)} - ${_formatTime(data.endTime!)}', + style: UiTypography.footnote1r.textSecondary, + ), + ], + if (data.minutesWorked != null) ...[ + const SizedBox(width: UiConstants.space3), + const Icon( + UiIcons.clock, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Text( + _formatWorkedDuration(data.minutesWorked!), + style: UiTypography.footnote1r.textSecondary, + ), + ], + ], + ), + const SizedBox(height: UiConstants.space1), + // Location row + Row( + children: [ + const Icon( + UiIcons.mapPin, + size: UiConstants.iconXs, + color: UiColors.iconSecondary, + ), + const SizedBox(width: UiConstants.space1), + Expanded( + child: Text( + data.location, + style: UiTypography.footnote1r.textSecondary, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ); + } + + String _formatDate(BuildContext context, DateTime date) { + final DateTime now = DateTime.now(); + final DateTime today = DateTime(now.year, now.month, now.day); + final DateTime tomorrow = today.add(const Duration(days: 1)); + final DateTime d = DateTime(date.year, date.month, date.day); + if (d == today) return context.t.staff_shifts.my_shifts_tab.date.today; + if (d == tomorrow) { + return context.t.staff_shifts.my_shifts_tab.date.tomorrow; + } + return DateFormat('EEE, MMM d').format(date); + } + + String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt); + + String _formatWorkedDuration(int totalMinutes) { + final int hours = totalMinutes ~/ 60; + final int mins = totalMinutes % 60; + return mins > 0 ? '${hours}h ${mins}m' : '${hours}h'; + } +} + +/// Footer showing the submit-for-approval action for completed shifts. +class _ApprovalFooter extends StatelessWidget { + const _ApprovalFooter({ + required this.isSubmitted, + this.onSubmit, + }); + + final bool isSubmitted; + final VoidCallback? onSubmit; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + isSubmitted + ? context.t.staff_shifts.my_shift_card.submitted + : context.t.staff_shifts.my_shift_card.ready_to_submit, + style: UiTypography.footnote2b.copyWith( + color: isSubmitted ? UiColors.textSuccess : UiColors.textSecondary, + ), + ), + if (!isSubmitted) + UiButton.secondary( + text: context.t.staff_shifts.my_shift_card.submit_for_approval, + size: UiButtonSize.small, + onPressed: onSubmit, + ) + else + const Icon( + UiIcons.success, + color: UiColors.iconSuccess, + size: 20, + ), + ], + ); + } +} + +/// Coloured footer with Decline / Accept buttons for pending assignments. +class _PendingActionsFooter extends StatelessWidget { + const _PendingActionsFooter({ + this.onAccept, + this.onDecline, + this.isAccepting = false, + }); + + final VoidCallback? onAccept; + final VoidCallback? onDecline; + final bool isAccepting; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space2), + decoration: const BoxDecoration( + color: UiColors.secondary, + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(UiConstants.radiusBase), + bottomRight: Radius.circular(UiConstants.radiusBase), + ), + ), + child: Row( + children: [ + Expanded( + child: TextButton( + onPressed: onDecline, + style: TextButton.styleFrom( + foregroundColor: UiColors.destructive, + ), + child: Text( + context.t.staff_shifts.action.decline, + style: UiTypography.body2m.textError, + ), + ), + ), + const SizedBox(width: UiConstants.space2), + Expanded( + child: ElevatedButton( + onPressed: isAccepting ? null : onAccept, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.primary, + foregroundColor: UiColors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + UiConstants.radiusMdValue, + ), + ), + ), + child: isAccepting + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: UiColors.white, + ), + ) + : Text( + context.t.staff_shifts.action.confirm, + style: UiTypography.body2m.white, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart index 639cc291..0ea3b6a6 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/history_shifts_tab.dart @@ -1,11 +1,12 @@ +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:intl/intl.dart'; -import 'package:design_system/design_system.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_card.dart'; /// Tab displaying completed shift history. class HistoryShiftsTab extends StatelessWidget { @@ -18,10 +19,10 @@ class HistoryShiftsTab extends StatelessWidget { @override Widget build(BuildContext context) { if (historyShifts.isEmpty) { - return const EmptyStateView( + return EmptyStateView( icon: UiIcons.clock, - title: 'No shift history', - subtitle: 'Completed shifts appear here', + title: context.t.staff_shifts.list.no_shifts, + subtitle: context.t.staff_shifts.history_tab.subtitle, ); } @@ -33,9 +34,10 @@ class HistoryShiftsTab extends StatelessWidget { ...historyShifts.map( (CompletedShift shift) => Padding( padding: const EdgeInsets.only(bottom: UiConstants.space3), - child: GestureDetector( - onTap: () => Modular.to.toShiftDetailsById(shift.shiftId), - child: _CompletedShiftCard(shift: shift), + child: ShiftCard( + data: ShiftCardData.fromCompleted(shift), + onTap: () => + Modular.to.toShiftDetailsById(shift.shiftId), ), ), ), @@ -45,89 +47,3 @@ class HistoryShiftsTab extends StatelessWidget { ); } } - -/// Card displaying a completed shift summary. -class _CompletedShiftCard extends StatelessWidget { - const _CompletedShiftCard({required this.shift}); - - final CompletedShift shift; - - @override - Widget build(BuildContext context) { - final int hours = shift.minutesWorked ~/ 60; - final int mins = shift.minutesWorked % 60; - final String workedLabel = - mins > 0 ? '${hours}h ${mins}m' : '${hours}h'; - - return Container( - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), - ), - child: Padding( - padding: const EdgeInsets.all(UiConstants.space4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: UiColors.primary.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - child: const Center( - child: Icon(UiIcons.briefcase, - color: UiColors.primary, size: UiConstants.iconMd), - ), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(shift.title, - style: UiTypography.body2m.textPrimary, - overflow: TextOverflow.ellipsis), - const SizedBox(height: UiConstants.space1), - Row( - children: [ - const Icon(UiIcons.calendar, - size: UiConstants.iconXs, - color: UiColors.iconSecondary), - const SizedBox(width: UiConstants.space1), - Text(DateFormat('EEE, MMM d').format(shift.date), - style: UiTypography.footnote1r.textSecondary), - const SizedBox(width: UiConstants.space3), - const Icon(UiIcons.clock, - size: UiConstants.iconXs, - color: UiColors.iconSecondary), - const SizedBox(width: UiConstants.space1), - Text(workedLabel, - style: UiTypography.footnote1r.textSecondary), - ], - ), - const SizedBox(height: UiConstants.space1), - Row( - children: [ - const Icon(UiIcons.mapPin, - size: UiConstants.iconXs, - color: UiColors.iconSecondary), - const SizedBox(width: UiConstants.space1), - Expanded( - child: Text(shift.location, - style: UiTypography.footnote1r.textSecondary, - overflow: TextOverflow.ellipsis), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart index 3914292c..c4c52421 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/presentation/widgets/tabs/my_shifts_tab.dart @@ -1,14 +1,15 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:intl/intl.dart'; -import 'package:design_system/design_system.dart'; 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' show ReadContext; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:intl/intl.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; -import 'package:staff_shifts/src/presentation/widgets/my_shift_card.dart'; -import 'package:staff_shifts/src/presentation/widgets/shift_assignment_card.dart'; import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; +import 'package:staff_shifts/src/presentation/widgets/shift_card.dart'; /// Tab displaying the worker's assigned, pending, and cancelled shifts. class MyShiftsTab extends StatefulWidget { @@ -41,6 +42,9 @@ class _MyShiftsTabState extends State { DateTime _selectedDate = DateTime.now(); int _weekOffset = 0; + /// Tracks which completed-shift cards have been submitted locally. + final Set _submittedShiftIds = {}; + @override void initState() { super.initState(); @@ -64,19 +68,19 @@ class _MyShiftsTabState extends State { void _applyInitialDate(DateTime date) { _selectedDate = date; - final now = DateTime.now(); - int reactDayIndex = now.weekday == 7 ? 0 : now.weekday; - int daysSinceFriday = (reactDayIndex + 2) % 7; + final DateTime now = DateTime.now(); + final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday; + final int daysSinceFriday = (reactDayIndex + 2) % 7; // Base Friday - final baseStart = DateTime( + final DateTime baseStart = DateTime( now.year, now.month, now.day, ).subtract(Duration(days: daysSinceFriday)); - final target = DateTime(date.year, date.month, date.day); - final diff = target.difference(baseStart).inDays; + final DateTime target = DateTime(date.year, date.month, date.day); + final int diff = target.difference(baseStart).inDays; setState(() { _weekOffset = (diff / 7).floor(); @@ -87,19 +91,23 @@ class _MyShiftsTabState extends State { } List _getCalendarDays() { - final now = DateTime.now(); - int reactDayIndex = now.weekday == 7 ? 0 : now.weekday; - int daysSinceFriday = (reactDayIndex + 2) % 7; - final start = now + final DateTime now = DateTime.now(); + final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday; + final int daysSinceFriday = (reactDayIndex + 2) % 7; + final DateTime start = now .subtract(Duration(days: daysSinceFriday)) .add(Duration(days: _weekOffset * 7)); - final startDate = DateTime(start.year, start.month, start.day); - return List.generate(7, (index) => startDate.add(Duration(days: index))); + final DateTime startDate = + DateTime(start.year, start.month, start.day); + return List.generate( + 7, + (int index) => startDate.add(Duration(days: index)), + ); } void _loadShiftsForCurrentWeek() { final List calendarDays = _getCalendarDays(); - context.read().add( + ReadContext(context).read().add( LoadShiftsForRangeEvent( start: calendarDays.first, end: calendarDays.last, @@ -114,10 +122,12 @@ class _MyShiftsTabState extends State { void _confirmShift(String id) { showDialog( context: context, - builder: (ctx) => AlertDialog( - title: Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.title), - content: Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.message), - actions: [ + builder: (BuildContext ctx) => AlertDialog( + title: + Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.title), + content: + Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.message), + actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), child: Text(context.t.common.cancel), @@ -125,17 +135,19 @@ class _MyShiftsTabState extends State { TextButton( onPressed: () { Navigator.of(ctx).pop(); - context.read().add(AcceptShiftEvent(id)); + ReadContext(context).read().add(AcceptShiftEvent(id)); UiSnackbar.show( context, - message: context.t.staff_shifts.my_shifts_tab.confirm_dialog.success, + message: context + .t.staff_shifts.my_shifts_tab.confirm_dialog.success, type: UiSnackbarType.success, ); }, style: TextButton.styleFrom( foregroundColor: UiColors.success, ), - child: Text(context.t.staff_shifts.shift_details.accept_shift), + child: + Text(context.t.staff_shifts.shift_details.accept_shift), ), ], ), @@ -145,12 +157,13 @@ class _MyShiftsTabState extends State { void _declineShift(String id) { showDialog( context: context, - builder: (ctx) => AlertDialog( - title: Text(context.t.staff_shifts.my_shifts_tab.decline_dialog.title), + builder: (BuildContext ctx) => AlertDialog( + title: + Text(context.t.staff_shifts.my_shifts_tab.decline_dialog.title), content: Text( context.t.staff_shifts.my_shifts_tab.decline_dialog.message, ), - actions: [ + actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), child: Text(context.t.common.cancel), @@ -158,10 +171,11 @@ class _MyShiftsTabState extends State { TextButton( onPressed: () { Navigator.of(ctx).pop(); - context.read().add(DeclineShiftEvent(id)); + ReadContext(context).read().add(DeclineShiftEvent(id)); UiSnackbar.show( context, - message: context.t.staff_shifts.my_shifts_tab.decline_dialog.success, + message: context + .t.staff_shifts.my_shifts_tab.decline_dialog.success, type: UiSnackbarType.error, ); }, @@ -175,27 +189,17 @@ class _MyShiftsTabState extends State { ); } - String _formatDateFromDateTime(DateTime date) { - final DateTime now = DateTime.now(); - if (_isSameDay(date, now)) { - return context.t.staff_shifts.my_shifts_tab.date.today; - } - final DateTime tomorrow = now.add(const Duration(days: 1)); - if (_isSameDay(date, tomorrow)) { - return context.t.staff_shifts.my_shifts_tab.date.tomorrow; - } - return DateFormat('EEE, MMM d').format(date); - } - @override Widget build(BuildContext context) { - final calendarDays = _getCalendarDays(); - final weekStartDate = calendarDays.first; - final weekEndDate = calendarDays.last; + final List calendarDays = _getCalendarDays(); + final DateTime weekStartDate = calendarDays.first; + final DateTime weekEndDate = calendarDays.last; - final List visibleMyShifts = widget.myShifts.where( - (AssignedShift s) => _isSameDay(s.date, _selectedDate), - ).toList(); + final List visibleMyShifts = widget.myShifts + .where( + (AssignedShift s) => _isSameDay(s.date, _selectedDate), + ) + .toList(); final List visibleCancelledShifts = widget.cancelledShifts.where((CancelledShift s) { @@ -205,7 +209,7 @@ class _MyShiftsTabState extends State { }).toList(); return Column( - children: [ + children: [ // Calendar Selector Container( color: UiColors.white, @@ -214,12 +218,12 @@ class _MyShiftsTabState extends State { horizontal: UiConstants.space4, ), child: Column( - children: [ + children: [ Padding( padding: const EdgeInsets.only(bottom: UiConstants.space3), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ IconButton( icon: const Icon( UiIcons.chevronLeft, @@ -258,10 +262,8 @@ class _MyShiftsTabState extends State { // Days Grid Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: calendarDays.map((date) { - final isSelected = _isSameDay(date, _selectedDate); - // ignore: unused_local_variable - final dateStr = DateFormat('yyyy-MM-dd').format(date); + children: calendarDays.map((DateTime date) { + final bool isSelected = _isSameDay(date, _selectedDate); final bool hasShifts = widget.myShifts.any( (AssignedShift s) => _isSameDay(s.date, date), ); @@ -269,7 +271,7 @@ class _MyShiftsTabState extends State { return GestureDetector( onTap: () => setState(() => _selectedDate = date), child: Column( - children: [ + children: [ Container( width: 44, height: 60, @@ -277,7 +279,9 @@ class _MyShiftsTabState extends State { color: isSelected ? UiColors.primary : UiColors.white, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), border: Border.all( color: isSelected ? UiColors.primary @@ -287,7 +291,7 @@ class _MyShiftsTabState extends State { ), child: Column( mainAxisAlignment: MainAxisAlignment.center, - children: [ + children: [ Text( date.day.toString().padLeft(2, '0'), style: isSelected @@ -297,14 +301,21 @@ class _MyShiftsTabState extends State { Text( DateFormat('E').format(date), style: (isSelected - ? UiTypography.footnote2m.white - : UiTypography.footnote2m.textSecondary).copyWith( - color: isSelected ? UiColors.white.withValues(alpha: 0.8) : null, + ? UiTypography.footnote2m.white + : UiTypography + .footnote2m.textSecondary) + .copyWith( + color: isSelected + ? UiColors.white + .withValues(alpha: 0.8) + : null, ), ), if (hasShifts && !isSelected) Container( - margin: const EdgeInsets.only(top: UiConstants.space1), + margin: const EdgeInsets.only( + top: UiConstants.space1, + ), width: 4, height: 4, decoration: const BoxDecoration( @@ -327,40 +338,52 @@ class _MyShiftsTabState extends State { Expanded( child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + ), child: Column( - children: [ + children: [ const SizedBox(height: UiConstants.space5), - if (widget.pendingAssignments.isNotEmpty) ...[ + if (widget.pendingAssignments.isNotEmpty) ...[ _buildSectionHeader( - context.t.staff_shifts.my_shifts_tab.sections.awaiting, + context + .t.staff_shifts.my_shifts_tab.sections.awaiting, UiColors.textWarning, ), ...widget.pendingAssignments.map( (PendingAssignment assignment) => Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space4), - child: ShiftAssignmentCard( - assignment: assignment, - onConfirm: () => _confirmShift(assignment.shiftId), - onDecline: () => _declineShift(assignment.shiftId), - isConfirming: true, + padding: const EdgeInsets.only( + bottom: UiConstants.space4, + ), + child: ShiftCard( + data: ShiftCardData.fromPending(assignment), + onTap: () => Modular.to + .toShiftDetailsById(assignment.shiftId), + onAccept: () => + _confirmShift(assignment.shiftId), + onDecline: () => + _declineShift(assignment.shiftId), ), ), ), const SizedBox(height: UiConstants.space3), ], - if (visibleCancelledShifts.isNotEmpty) ...[ - _buildSectionHeader(context.t.staff_shifts.my_shifts_tab.sections.cancelled, UiColors.textSecondary), + if (visibleCancelledShifts.isNotEmpty) ...[ + _buildSectionHeader( + context + .t.staff_shifts.my_shifts_tab.sections.cancelled, + UiColors.textSecondary, + ), ...visibleCancelledShifts.map( (CancelledShift cs) => Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space4), - child: _buildCancelledCard( - title: cs.title, - location: cs.location, - date: DateFormat('EEE, MMM d').format(cs.date), - reason: cs.cancellationReason, - onTap: () {}, + padding: const EdgeInsets.only( + bottom: UiConstants.space4, + ), + child: ShiftCard( + data: ShiftCardData.fromCancelled(cs), + onTap: () => + Modular.to.toShiftDetailsById(cs.shiftId), ), ), ), @@ -368,23 +391,43 @@ class _MyShiftsTabState extends State { ], // Confirmed Shifts - if (visibleMyShifts.isNotEmpty) ...[ - _buildSectionHeader(context.t.staff_shifts.my_shifts_tab.sections.confirmed, UiColors.textSecondary), + if (visibleMyShifts.isNotEmpty) ...[ + _buildSectionHeader( + context + .t.staff_shifts.my_shifts_tab.sections.confirmed, + UiColors.textSecondary, + ), ...visibleMyShifts.map( - (AssignedShift shift) => Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space3), - child: MyShiftCard( - shift: shift, - onDecline: () => _declineShift(shift.shiftId), - onRequestSwap: () { - UiSnackbar.show( - context, - message: context.t.staff_shifts.my_shifts_tab.swap_coming_soon, - type: UiSnackbarType.message, - ); - }, - ), - ), + (AssignedShift shift) { + final bool isCompleted = + shift.status == AssignmentStatus.completed; + final bool isSubmitted = + _submittedShiftIds.contains(shift.shiftId); + + return Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space3, + ), + child: ShiftCard( + data: ShiftCardData.fromAssigned(shift), + onTap: () => Modular.to + .toShiftDetailsById(shift.shiftId), + showApprovalAction: isCompleted, + isSubmitted: isSubmitted, + onSubmitForApproval: () { + setState(() { + _submittedShiftIds.add(shift.shiftId); + }); + UiSnackbar.show( + context, + message: context.t.staff_shifts + .my_shift_card.timesheet_submitted, + type: UiSnackbarType.success, + ); + }, + ), + ); + }, ), ], @@ -393,8 +436,10 @@ class _MyShiftsTabState extends State { widget.cancelledShifts.isEmpty) EmptyStateView( icon: UiIcons.calendar, - title: context.t.staff_shifts.my_shifts_tab.empty.title, - subtitle: context.t.staff_shifts.my_shifts_tab.empty.subtitle, + title: + context.t.staff_shifts.my_shifts_tab.empty.title, + subtitle: context + .t.staff_shifts.my_shifts_tab.empty.subtitle, ), const SizedBox(height: UiConstants.space32), @@ -410,11 +455,14 @@ class _MyShiftsTabState extends State { return Padding( padding: const EdgeInsets.only(bottom: UiConstants.space4), child: Row( - children: [ + children: [ Container( width: 8, height: 8, - decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle), + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + ), ), const SizedBox(width: UiConstants.space2), Text( @@ -427,111 +475,4 @@ class _MyShiftsTabState extends State { ), ); } - - Widget _buildCancelledCard({ - required String title, - required String location, - required String date, - String? reason, - required VoidCallback onTap, - }) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(UiConstants.radiusBase + 4), - border: Border.all(color: UiColors.border), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 6, - height: 6, - decoration: const BoxDecoration( - color: UiColors.destructive, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 6), - Text( - context.t.staff_shifts.my_shifts_tab.card.cancelled, - style: UiTypography.footnote2b.textError, - ), - ], - ), - const SizedBox(height: UiConstants.space3), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: UiColors.primary.withValues(alpha: 0.05), - borderRadius: - BorderRadius.circular(UiConstants.radiusBase), - ), - child: const Center( - child: Icon( - UiIcons.briefcase, - color: UiColors.primary, - size: 20, - ), - ), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: UiTypography.body2b.textPrimary), - const SizedBox(height: UiConstants.space2), - Row( - children: [ - const Icon(UiIcons.calendar, - size: 12, color: UiColors.textSecondary), - const SizedBox(width: 4), - Text(date, - style: UiTypography.footnote1r.textSecondary), - ], - ), - const SizedBox(height: 4), - Row( - children: [ - const Icon(UiIcons.mapPin, - size: 12, color: UiColors.textSecondary), - const SizedBox(width: 4), - Expanded( - child: Text( - location, - style: UiTypography.footnote1r.textSecondary, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - if (reason != null && reason.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - reason, - style: UiTypography.footnote2r.textSecondary, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - ], - ), - ], - ), - ), - ); - } } diff --git a/apps/mobile/packages/features/staff/shifts/pubspec.yaml b/apps/mobile/packages/features/staff/shifts/pubspec.yaml index 3d50a8ad..a05c568e 100644 --- a/apps/mobile/packages/features/staff/shifts/pubspec.yaml +++ b/apps/mobile/packages/features/staff/shifts/pubspec.yaml @@ -28,6 +28,7 @@ dependencies: intl: ^0.20.2 url_launcher: ^6.3.1 bloc: ^8.1.4 + meta: ^1.17.0 dev_dependencies: flutter_test: