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/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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user