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.
This commit is contained in:
Achintha Isuru
2026-03-17 13:08:32 -04:00
parent 020c785b6f
commit a0d5a18e6f
6 changed files with 945 additions and 306 deletions

View File

@@ -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<String, dynamic> toJson() {
return <String, dynamic>{

View File

@@ -96,10 +96,12 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
final ApiResponse response =
await _apiService.get(StaffEndpoints.shiftsCompleted);
final List<dynamic> items = _extractItems(response.data);
return items
var x = items
.map((dynamic json) =>
CompletedShift.fromJson(json as Map<String, dynamic>))
.toList();
return x;
}
@override

View File

@@ -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>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.05),
blurRadius: 2,
offset: const Offset(0, 1),
),
]
: null,
),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(UiConstants.space4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_StatusBadge(
variant: data.variant,
orderType: data.orderType,
),
const SizedBox(height: UiConstants.space2),
_CardBody(data: data),
if (showApprovalAction) ...<Widget>[
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: <Widget>[
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) ...<Widget>[
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: <Widget>[
_ShiftIcon(variant: data.variant),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_TitleRow(data: data),
if (data.subtitle != null) ...<Widget>[
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) ...<Widget>[
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: <Color>[
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: <Widget>[
Expanded(
child: Text(
data.title,
style: UiTypography.body2m.textPrimary,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: UiConstants.space2),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
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: <Widget>[
// Date and time row
Row(
children: <Widget>[
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) ...<Widget>[
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) ...<Widget>[
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: <Widget>[
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: <Widget>[
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: <Widget>[
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,
),
),
),
],
),
);
}
}

View File

@@ -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: <Widget>[
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: <Widget>[
Text(shift.title,
style: UiTypography.body2m.textPrimary,
overflow: TextOverflow.ellipsis),
const SizedBox(height: UiConstants.space1),
Row(
children: <Widget>[
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: <Widget>[
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),
),
],
),
],
),
),
],
),
),
);
}
}

View File

@@ -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<MyShiftsTab> {
DateTime _selectedDate = DateTime.now();
int _weekOffset = 0;
/// Tracks which completed-shift cards have been submitted locally.
final Set<String> _submittedShiftIds = <String>{};
@override
void initState() {
super.initState();
@@ -64,19 +68,19 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
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<MyShiftsTab> {
}
List<DateTime> _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<DateTime>.generate(
7,
(int index) => startDate.add(Duration(days: index)),
);
}
void _loadShiftsForCurrentWeek() {
final List<DateTime> calendarDays = _getCalendarDays();
context.read<ShiftsBloc>().add(
ReadContext(context).read<ShiftsBloc>().add(
LoadShiftsForRangeEvent(
start: calendarDays.first,
end: calendarDays.last,
@@ -114,10 +122,12 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
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: <Widget>[
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(context.t.common.cancel),
@@ -125,17 +135,19 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
context.read<ShiftsBloc>().add(AcceptShiftEvent(id));
ReadContext(context).read<ShiftsBloc>().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<MyShiftsTab> {
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: <Widget>[
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(context.t.common.cancel),
@@ -158,10 +171,11 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
context.read<ShiftsBloc>().add(DeclineShiftEvent(id));
ReadContext(context).read<ShiftsBloc>().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<MyShiftsTab> {
);
}
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<DateTime> calendarDays = _getCalendarDays();
final DateTime weekStartDate = calendarDays.first;
final DateTime weekEndDate = calendarDays.last;
final List<AssignedShift> visibleMyShifts = widget.myShifts.where(
final List<AssignedShift> visibleMyShifts = widget.myShifts
.where(
(AssignedShift s) => _isSameDay(s.date, _selectedDate),
).toList();
)
.toList();
final List<CancelledShift> visibleCancelledShifts =
widget.cancelledShifts.where((CancelledShift s) {
@@ -205,7 +209,7 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
}).toList();
return Column(
children: [
children: <Widget>[
// Calendar Selector
Container(
color: UiColors.white,
@@ -214,12 +218,12 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
horizontal: UiConstants.space4,
),
child: Column(
children: [
children: <Widget>[
Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
children: <Widget>[
IconButton(
icon: const Icon(
UiIcons.chevronLeft,
@@ -258,10 +262,8 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
// 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<MyShiftsTab> {
return GestureDetector(
onTap: () => setState(() => _selectedDate = date),
child: Column(
children: [
children: <Widget>[
Container(
width: 44,
height: 60,
@@ -277,7 +279,9 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
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<MyShiftsTab> {
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
children: <Widget>[
Text(
date.day.toString().padLeft(2, '0'),
style: isSelected
@@ -298,13 +302,20 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
DateFormat('E').format(date),
style: (isSelected
? UiTypography.footnote2m.white
: UiTypography.footnote2m.textSecondary).copyWith(
color: isSelected ? UiColors.white.withValues(alpha: 0.8) : null,
: 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<MyShiftsTab> {
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
child: Column(
children: [
children: <Widget>[
const SizedBox(height: UiConstants.space5),
if (widget.pendingAssignments.isNotEmpty) ...[
if (widget.pendingAssignments.isNotEmpty) ...<Widget>[
_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) ...<Widget>[
_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<MyShiftsTab> {
],
// Confirmed Shifts
if (visibleMyShifts.isNotEmpty) ...[
_buildSectionHeader(context.t.staff_shifts.my_shifts_tab.sections.confirmed, UiColors.textSecondary),
if (visibleMyShifts.isNotEmpty) ...<Widget>[
_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: () {
(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_shifts_tab.swap_coming_soon,
type: UiSnackbarType.message,
message: context.t.staff_shifts
.my_shift_card.timesheet_submitted,
type: UiSnackbarType.success,
);
},
),
),
);
},
),
],
@@ -393,8 +436,10 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
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<MyShiftsTab> {
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space4),
child: Row(
children: [
children: <Widget>[
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<MyShiftsTab> {
),
);
}
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: <Widget>[
Row(
children: <Widget>[
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: <Widget>[
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: <Widget>[
Text(title, style: UiTypography.body2b.textPrimary),
const SizedBox(height: UiConstants.space2),
Row(
children: <Widget>[
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: <Widget>[
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,
),
],
],
),
),
],
),
],
),
),
);
}
}

View File

@@ -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: