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:
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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/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.
|
||||
class HistoryShiftsTab extends StatelessWidget {
|
||||
|
||||
@@ -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/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/shift_card.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/index.dart';
|
||||
|
||||
/// Tab displaying the worker's assigned, pending, and cancelled shifts.
|
||||
class MyShiftsTab extends StatefulWidget {
|
||||
|
||||
Reference in New Issue
Block a user