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