Refactor Shift Card Implementation

- Updated import paths for shift card components to use a centralized index file.
- Introduced a new ShiftCard widget that consolidates the functionality of previous card implementations.
- Created individual files for shift card components: ShiftCard, ShiftCardBody, ShiftCardApprovalFooter, ShiftCardPendingFooter, ShiftCardStatusBadge, ShiftCardMetadataRows, ShiftCardTitleRow, and ShiftCardData.
- Enhanced ShiftCard to handle various shift types (confirmed, pending, cancelled, completed, checked-in, swap requested) through a unified data model.
- Improved UI consistency and maintainability by separating concerns into distinct widgets.
This commit is contained in:
Achintha Isuru
2026-03-18 15:18:27 -04:00
parent e7b5cb33a4
commit b9d64bd53b
12 changed files with 868 additions and 777 deletions

View File

@@ -1,775 +0,0 @@
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

@@ -0,0 +1,8 @@
export 'shift_card.dart';
export 'shift_card_approval_footer.dart';
export 'shift_card_body.dart';
export 'shift_card_data.dart';
export 'shift_card_metadata_rows.dart';
export 'shift_card_pending_footer.dart';
export 'shift_card_status_badge.dart';
export 'shift_card_title_row.dart';

View File

@@ -0,0 +1,111 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_approval_footer.dart';
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_body.dart';
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart';
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_pending_footer.dart';
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_status_badge.dart';
/// 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, width: 0.5),
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>[
ShiftCardStatusBadge(
variant: data.variant,
orderType: data.orderType,
),
const SizedBox(height: UiConstants.space2),
ShiftCardBody(data: data),
if (showApprovalAction) ...<Widget>[
const SizedBox(height: UiConstants.space4),
const Divider(height: 1, color: UiColors.border),
const SizedBox(height: UiConstants.space2),
ShiftCardApprovalFooter(
isSubmitted: isSubmitted,
onSubmit: onSubmitForApproval,
),
],
],
),
),
if (_showPendingActions)
ShiftCardPendingActionsFooter(
onAccept: onAccept,
onDecline: onDecline,
isAccepting: isAccepting,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Footer showing the submit-for-approval action for completed shifts.
class ShiftCardApprovalFooter extends StatelessWidget {
/// Creates a [ShiftCardApprovalFooter].
const ShiftCardApprovalFooter({
super.key,
required this.isSubmitted,
this.onSubmit,
});
/// Whether the timesheet has already been submitted.
final bool isSubmitted;
/// Callback when the submit button is pressed.
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),
],
);
}
}

View File

@@ -0,0 +1,88 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart';
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_metadata_rows.dart';
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_title_row.dart';
/// The main body: icon, title/subtitle, metadata rows, and optional pay info.
class ShiftCardBody extends StatelessWidget {
/// Creates a [ShiftCardBody].
const ShiftCardBody({super.key, required this.data});
/// The shift data to display.
final ShiftCardData data;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
ShiftCardIcon(variant: data.variant),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
ShiftCardTitleRow(data: data),
const SizedBox(height: UiConstants.space2),
ShiftCardMetadataRows(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 ShiftCardIcon extends StatelessWidget {
/// Creates a [ShiftCardIcon].
const ShiftCardIcon({super.key, required this.variant});
/// The variant controlling the icon appearance.
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,
),
),
);
}
}

View File

@@ -0,0 +1,180 @@
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.hourlyRate,
this.totalRate,
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.clientName.isNotEmpty ? shift.clientName : shift.title,
subtitle: shift.title.isNotEmpty ? shift.title : null,
location: shift.location,
date: shift.date,
startTime: shift.startTime,
endTime: shift.endTime,
hourlyRateCents: shift.hourlyRateCents,
hourlyRate: shift.hourlyRate,
totalRate: shift.totalRate,
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;
/// Hourly pay rate in dollars (null when not applicable).
final double? hourlyRate;
/// Total pay in dollars (null when not applicable).
final double? totalRate;
/// 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;
}
}
}

View File

@@ -0,0 +1,102 @@
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:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart';
/// Date, time, location, and worked-hours rows.
class ShiftCardMetadataRows extends StatelessWidget {
/// Creates a [ShiftCardMetadataRows].
const ShiftCardMetadataRows({super.key, required this.data});
/// The shift data to display.
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';
}
}

View File

@@ -0,0 +1,82 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// Coloured footer with Decline / Accept buttons for pending assignments.
class ShiftCardPendingActionsFooter extends StatelessWidget {
/// Creates a [ShiftCardPendingActionsFooter].
const ShiftCardPendingActionsFooter({
super.key,
this.onAccept,
this.onDecline,
this.isAccepting = false,
});
/// Callback when the accept action is pressed.
final VoidCallback? onAccept;
/// Callback when the decline action is pressed.
final VoidCallback? onDecline;
/// Whether the accept action is in progress.
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

@@ -0,0 +1,163 @@
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_shifts/src/presentation/widgets/shift_card/shift_card_data.dart';
/// Displays the coloured status dot/icon and label, plus an optional order-type
/// chip.
class ShiftCardStatusBadge extends StatelessWidget {
/// Creates a [ShiftCardStatusBadge].
const ShiftCardStatusBadge({super.key, required this.variant, this.orderType});
/// The visual variant for colour resolution.
final ShiftCardVariant variant;
/// Optional order type shown as a trailing chip.
final OrderType? orderType;
@override
Widget build(BuildContext context) {
final ShiftCardStatusStyle 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),
ShiftCardOrderTypeChip(orderType: orderType!),
],
],
);
}
ShiftCardStatusStyle _resolveStyle(BuildContext context) {
switch (variant) {
case ShiftCardVariant.confirmed:
return ShiftCardStatusStyle(
label: context.t.staff_shifts.status.confirmed,
foreground: UiColors.textLink,
dot: UiColors.primary,
);
case ShiftCardVariant.pending:
return ShiftCardStatusStyle(
label: context.t.staff_shifts.status.act_now,
foreground: UiColors.destructive,
dot: UiColors.destructive,
);
case ShiftCardVariant.cancelled:
return ShiftCardStatusStyle(
label: context.t.staff_shifts.my_shifts_tab.card.cancelled,
foreground: UiColors.destructive,
dot: UiColors.destructive,
);
case ShiftCardVariant.completed:
return ShiftCardStatusStyle(
label: context.t.staff_shifts.status.completed,
foreground: UiColors.textSuccess,
dot: UiColors.iconSuccess,
);
case ShiftCardVariant.checkedIn:
return ShiftCardStatusStyle(
label: context.t.staff_shifts.my_shift_card.checked_in,
foreground: UiColors.textSuccess,
dot: UiColors.iconSuccess,
);
case ShiftCardVariant.swapRequested:
return ShiftCardStatusStyle(
label: context.t.staff_shifts.status.swap_requested,
foreground: UiColors.textWarning,
dot: UiColors.textWarning,
icon: UiIcons.swap,
);
}
}
}
/// Helper grouping status badge presentation values.
class ShiftCardStatusStyle {
/// Creates a [ShiftCardStatusStyle].
const ShiftCardStatusStyle({
required this.label,
required this.foreground,
required this.dot,
this.icon,
});
/// The human-readable status label.
final String label;
/// Foreground colour for the label and icon.
final Color foreground;
/// Dot colour when no icon is provided.
final Color dot;
/// Optional icon replacing the dot indicator.
final IconData? icon;
}
/// Small chip showing the order type (One Day / Multi-Day / Long Term).
class ShiftCardOrderTypeChip extends StatelessWidget {
/// Creates a [ShiftCardOrderTypeChip].
const ShiftCardOrderTypeChip({super.key, required this.orderType});
/// The order type to display.
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;
}
}
}

View File

@@ -0,0 +1,88 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart';
/// Title row with optional pay summary on the right.
class ShiftCardTitleRow extends StatelessWidget {
/// Creates a [ShiftCardTitleRow].
const ShiftCardTitleRow({super.key, required this.data});
/// The shift data to display.
final ShiftCardData data;
@override
Widget build(BuildContext context) {
final bool hasDirectRate = data.hourlyRate != null && data.hourlyRate! > 0;
final bool hasComputedRate =
data.hourlyRateCents != null &&
data.startTime != null &&
data.endTime != null;
if (!hasDirectRate && !hasComputedRate) {
return Text(
data.title,
style: UiTypography.body2m.textPrimary,
overflow: TextOverflow.ellipsis,
);
}
// Prefer pre-computed values from the API when available.
final double hourlyRate;
final double estimatedTotal;
final double durationHours;
if (hasDirectRate && data.totalRate != null && data.totalRate! > 0) {
hourlyRate = data.hourlyRate!;
estimatedTotal = data.totalRate!;
durationHours = hourlyRate > 0 ? (estimatedTotal / hourlyRate) : 0;
} else {
hourlyRate = data.hourlyRateCents! / 100;
final int durationMinutes = data.endTime!
.difference(data.startTime!)
.inMinutes;
double hours = durationMinutes / 60;
if (hours < 0) hours += 24;
durationHours = hours.roundToDouble();
estimatedTotal = hourlyRate * durationHours;
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data.title,
style: UiTypography.body2m.textPrimary,
overflow: TextOverflow.ellipsis,
),
if (data.subtitle != null) ...<Widget>[
Text(
data.subtitle!,
style: UiTypography.body3r.textSecondary,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
const SizedBox(width: UiConstants.space3),
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,
),
],
),
],
);
}
}

View File

@@ -8,7 +8,7 @@ import 'package:krow_domain/krow_domain.dart';
import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart';
import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart';
import 'package:staff_shifts/src/presentation/widgets/shift_card.dart'; import 'package:staff_shifts/src/presentation/widgets/shift_card/index.dart';
/// Tab displaying completed shift history. /// Tab displaying completed shift history.
class HistoryShiftsTab extends StatelessWidget { class HistoryShiftsTab extends StatelessWidget {

View File

@@ -10,7 +10,7 @@ import 'package:krow_domain/krow_domain.dart';
import 'package:staff_shifts/src/domain/utils/shift_date_utils.dart'; import 'package:staff_shifts/src/domain/utils/shift_date_utils.dart';
import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart'; import 'package:staff_shifts/src/presentation/blocs/shifts/shifts_bloc.dart';
import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart'; import 'package:staff_shifts/src/presentation/widgets/shared/empty_state_view.dart';
import 'package:staff_shifts/src/presentation/widgets/shift_card.dart'; import 'package:staff_shifts/src/presentation/widgets/shift_card/index.dart';
/// Tab displaying the worker's assigned, pending, and cancelled shifts. /// Tab displaying the worker's assigned, pending, and cancelled shifts.
class MyShiftsTab extends StatefulWidget { class MyShiftsTab extends StatefulWidget {