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:
@@ -1,6 +1,5 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import 'package:krow_domain/src/entities/enums/payment_status.dart';
|
|
||||||
|
|
||||||
/// A shift the staff member has completed.
|
/// A shift the staff member has completed.
|
||||||
///
|
///
|
||||||
@@ -16,6 +15,7 @@ class CompletedShift extends Equatable {
|
|||||||
required this.date,
|
required this.date,
|
||||||
required this.minutesWorked,
|
required this.minutesWorked,
|
||||||
required this.paymentStatus,
|
required this.paymentStatus,
|
||||||
|
required this.status,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Deserialises from the V2 API JSON response.
|
/// Deserialises from the V2 API JSON response.
|
||||||
@@ -28,6 +28,7 @@ class CompletedShift extends Equatable {
|
|||||||
date: DateTime.parse(json['date'] as String),
|
date: DateTime.parse(json['date'] as String),
|
||||||
minutesWorked: json['minutesWorked'] as int? ?? 0,
|
minutesWorked: json['minutesWorked'] as int? ?? 0,
|
||||||
paymentStatus: PaymentStatus.fromJson(json['paymentStatus'] as String?),
|
paymentStatus: PaymentStatus.fromJson(json['paymentStatus'] as String?),
|
||||||
|
status: AssignmentStatus.completed,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +53,9 @@ class CompletedShift extends Equatable {
|
|||||||
/// Payment processing status.
|
/// Payment processing status.
|
||||||
final PaymentStatus paymentStatus;
|
final PaymentStatus paymentStatus;
|
||||||
|
|
||||||
|
/// Assignment status (should always be `completed` for this class).
|
||||||
|
final AssignmentStatus status;
|
||||||
|
|
||||||
/// Serialises to JSON.
|
/// Serialises to JSON.
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
|
|||||||
@@ -96,10 +96,12 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
|||||||
final ApiResponse response =
|
final ApiResponse response =
|
||||||
await _apiService.get(StaffEndpoints.shiftsCompleted);
|
await _apiService.get(StaffEndpoints.shiftsCompleted);
|
||||||
final List<dynamic> items = _extractItems(response.data);
|
final List<dynamic> items = _extractItems(response.data);
|
||||||
return items
|
var x = items
|
||||||
.map((dynamic json) =>
|
.map((dynamic json) =>
|
||||||
CompletedShift.fromJson(json as Map<String, dynamic>))
|
CompletedShift.fromJson(json as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
return x;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.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_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.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/shared/empty_state_view.dart';
|
||||||
|
import 'package:staff_shifts/src/presentation/widgets/shift_card.dart';
|
||||||
|
|
||||||
/// Tab displaying completed shift history.
|
/// Tab displaying completed shift history.
|
||||||
class HistoryShiftsTab extends StatelessWidget {
|
class HistoryShiftsTab extends StatelessWidget {
|
||||||
@@ -18,10 +19,10 @@ class HistoryShiftsTab extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (historyShifts.isEmpty) {
|
if (historyShifts.isEmpty) {
|
||||||
return const EmptyStateView(
|
return EmptyStateView(
|
||||||
icon: UiIcons.clock,
|
icon: UiIcons.clock,
|
||||||
title: 'No shift history',
|
title: context.t.staff_shifts.list.no_shifts,
|
||||||
subtitle: 'Completed shifts appear here',
|
subtitle: context.t.staff_shifts.history_tab.subtitle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,9 +34,10 @@ class HistoryShiftsTab extends StatelessWidget {
|
|||||||
...historyShifts.map(
|
...historyShifts.map(
|
||||||
(CompletedShift shift) => Padding(
|
(CompletedShift shift) => Padding(
|
||||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||||
child: GestureDetector(
|
child: ShiftCard(
|
||||||
onTap: () => Modular.to.toShiftDetailsById(shift.shiftId),
|
data: ShiftCardData.fromCompleted(shift),
|
||||||
child: _CompletedShiftCard(shift: 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: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: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/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/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.
|
/// Tab displaying the worker's assigned, pending, and cancelled shifts.
|
||||||
class MyShiftsTab extends StatefulWidget {
|
class MyShiftsTab extends StatefulWidget {
|
||||||
@@ -41,6 +42,9 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
DateTime _selectedDate = DateTime.now();
|
DateTime _selectedDate = DateTime.now();
|
||||||
int _weekOffset = 0;
|
int _weekOffset = 0;
|
||||||
|
|
||||||
|
/// Tracks which completed-shift cards have been submitted locally.
|
||||||
|
final Set<String> _submittedShiftIds = <String>{};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -64,19 +68,19 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
void _applyInitialDate(DateTime date) {
|
void _applyInitialDate(DateTime date) {
|
||||||
_selectedDate = date;
|
_selectedDate = date;
|
||||||
|
|
||||||
final now = DateTime.now();
|
final DateTime now = DateTime.now();
|
||||||
int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
|
final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
|
||||||
int daysSinceFriday = (reactDayIndex + 2) % 7;
|
final int daysSinceFriday = (reactDayIndex + 2) % 7;
|
||||||
|
|
||||||
// Base Friday
|
// Base Friday
|
||||||
final baseStart = DateTime(
|
final DateTime baseStart = DateTime(
|
||||||
now.year,
|
now.year,
|
||||||
now.month,
|
now.month,
|
||||||
now.day,
|
now.day,
|
||||||
).subtract(Duration(days: daysSinceFriday));
|
).subtract(Duration(days: daysSinceFriday));
|
||||||
|
|
||||||
final target = DateTime(date.year, date.month, date.day);
|
final DateTime target = DateTime(date.year, date.month, date.day);
|
||||||
final diff = target.difference(baseStart).inDays;
|
final int diff = target.difference(baseStart).inDays;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_weekOffset = (diff / 7).floor();
|
_weekOffset = (diff / 7).floor();
|
||||||
@@ -87,19 +91,23 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<DateTime> _getCalendarDays() {
|
List<DateTime> _getCalendarDays() {
|
||||||
final now = DateTime.now();
|
final DateTime now = DateTime.now();
|
||||||
int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
|
final int reactDayIndex = now.weekday == 7 ? 0 : now.weekday;
|
||||||
int daysSinceFriday = (reactDayIndex + 2) % 7;
|
final int daysSinceFriday = (reactDayIndex + 2) % 7;
|
||||||
final start = now
|
final DateTime start = now
|
||||||
.subtract(Duration(days: daysSinceFriday))
|
.subtract(Duration(days: daysSinceFriday))
|
||||||
.add(Duration(days: _weekOffset * 7));
|
.add(Duration(days: _weekOffset * 7));
|
||||||
final startDate = DateTime(start.year, start.month, start.day);
|
final DateTime startDate =
|
||||||
return List.generate(7, (index) => startDate.add(Duration(days: index)));
|
DateTime(start.year, start.month, start.day);
|
||||||
|
return List<DateTime>.generate(
|
||||||
|
7,
|
||||||
|
(int index) => startDate.add(Duration(days: index)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _loadShiftsForCurrentWeek() {
|
void _loadShiftsForCurrentWeek() {
|
||||||
final List<DateTime> calendarDays = _getCalendarDays();
|
final List<DateTime> calendarDays = _getCalendarDays();
|
||||||
context.read<ShiftsBloc>().add(
|
ReadContext(context).read<ShiftsBloc>().add(
|
||||||
LoadShiftsForRangeEvent(
|
LoadShiftsForRangeEvent(
|
||||||
start: calendarDays.first,
|
start: calendarDays.first,
|
||||||
end: calendarDays.last,
|
end: calendarDays.last,
|
||||||
@@ -114,10 +122,12 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
void _confirmShift(String id) {
|
void _confirmShift(String id) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (BuildContext ctx) => AlertDialog(
|
||||||
title: Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.title),
|
title:
|
||||||
content: Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.message),
|
Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.title),
|
||||||
actions: [
|
content:
|
||||||
|
Text(context.t.staff_shifts.my_shifts_tab.confirm_dialog.message),
|
||||||
|
actions: <Widget>[
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(ctx).pop(),
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
child: Text(context.t.common.cancel),
|
child: Text(context.t.common.cancel),
|
||||||
@@ -125,17 +135,19 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(ctx).pop();
|
Navigator.of(ctx).pop();
|
||||||
context.read<ShiftsBloc>().add(AcceptShiftEvent(id));
|
ReadContext(context).read<ShiftsBloc>().add(AcceptShiftEvent(id));
|
||||||
UiSnackbar.show(
|
UiSnackbar.show(
|
||||||
context,
|
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,
|
type: UiSnackbarType.success,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: UiColors.success,
|
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) {
|
void _declineShift(String id) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (BuildContext ctx) => AlertDialog(
|
||||||
title: Text(context.t.staff_shifts.my_shifts_tab.decline_dialog.title),
|
title:
|
||||||
|
Text(context.t.staff_shifts.my_shifts_tab.decline_dialog.title),
|
||||||
content: Text(
|
content: Text(
|
||||||
context.t.staff_shifts.my_shifts_tab.decline_dialog.message,
|
context.t.staff_shifts.my_shifts_tab.decline_dialog.message,
|
||||||
),
|
),
|
||||||
actions: [
|
actions: <Widget>[
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(ctx).pop(),
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
child: Text(context.t.common.cancel),
|
child: Text(context.t.common.cancel),
|
||||||
@@ -158,10 +171,11 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(ctx).pop();
|
Navigator.of(ctx).pop();
|
||||||
context.read<ShiftsBloc>().add(DeclineShiftEvent(id));
|
ReadContext(context).read<ShiftsBloc>().add(DeclineShiftEvent(id));
|
||||||
UiSnackbar.show(
|
UiSnackbar.show(
|
||||||
context,
|
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,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final calendarDays = _getCalendarDays();
|
final List<DateTime> calendarDays = _getCalendarDays();
|
||||||
final weekStartDate = calendarDays.first;
|
final DateTime weekStartDate = calendarDays.first;
|
||||||
final weekEndDate = calendarDays.last;
|
final DateTime weekEndDate = calendarDays.last;
|
||||||
|
|
||||||
final List<AssignedShift> visibleMyShifts = widget.myShifts.where(
|
final List<AssignedShift> visibleMyShifts = widget.myShifts
|
||||||
|
.where(
|
||||||
(AssignedShift s) => _isSameDay(s.date, _selectedDate),
|
(AssignedShift s) => _isSameDay(s.date, _selectedDate),
|
||||||
).toList();
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
final List<CancelledShift> visibleCancelledShifts =
|
final List<CancelledShift> visibleCancelledShifts =
|
||||||
widget.cancelledShifts.where((CancelledShift s) {
|
widget.cancelledShifts.where((CancelledShift s) {
|
||||||
@@ -205,7 +209,7 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: <Widget>[
|
||||||
// Calendar Selector
|
// Calendar Selector
|
||||||
Container(
|
Container(
|
||||||
color: UiColors.white,
|
color: UiColors.white,
|
||||||
@@ -214,12 +218,12 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
horizontal: UiConstants.space4,
|
horizontal: UiConstants.space4,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: <Widget>[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: <Widget>[
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
UiIcons.chevronLeft,
|
UiIcons.chevronLeft,
|
||||||
@@ -258,10 +262,8 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
// Days Grid
|
// Days Grid
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: calendarDays.map((date) {
|
children: calendarDays.map((DateTime date) {
|
||||||
final isSelected = _isSameDay(date, _selectedDate);
|
final bool isSelected = _isSameDay(date, _selectedDate);
|
||||||
// ignore: unused_local_variable
|
|
||||||
final dateStr = DateFormat('yyyy-MM-dd').format(date);
|
|
||||||
final bool hasShifts = widget.myShifts.any(
|
final bool hasShifts = widget.myShifts.any(
|
||||||
(AssignedShift s) => _isSameDay(s.date, date),
|
(AssignedShift s) => _isSameDay(s.date, date),
|
||||||
);
|
);
|
||||||
@@ -269,7 +271,7 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => setState(() => _selectedDate = date),
|
onTap: () => setState(() => _selectedDate = date),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: <Widget>[
|
||||||
Container(
|
Container(
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 60,
|
height: 60,
|
||||||
@@ -277,7 +279,9 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
color: isSelected
|
color: isSelected
|
||||||
? UiColors.primary
|
? UiColors.primary
|
||||||
: UiColors.white,
|
: UiColors.white,
|
||||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
borderRadius: BorderRadius.circular(
|
||||||
|
UiConstants.radiusBase,
|
||||||
|
),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? UiColors.primary
|
? UiColors.primary
|
||||||
@@ -287,7 +291,7 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
date.day.toString().padLeft(2, '0'),
|
date.day.toString().padLeft(2, '0'),
|
||||||
style: isSelected
|
style: isSelected
|
||||||
@@ -298,13 +302,20 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
DateFormat('E').format(date),
|
DateFormat('E').format(date),
|
||||||
style: (isSelected
|
style: (isSelected
|
||||||
? UiTypography.footnote2m.white
|
? UiTypography.footnote2m.white
|
||||||
: UiTypography.footnote2m.textSecondary).copyWith(
|
: UiTypography
|
||||||
color: isSelected ? UiColors.white.withValues(alpha: 0.8) : null,
|
.footnote2m.textSecondary)
|
||||||
|
.copyWith(
|
||||||
|
color: isSelected
|
||||||
|
? UiColors.white
|
||||||
|
.withValues(alpha: 0.8)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (hasShifts && !isSelected)
|
if (hasShifts && !isSelected)
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.only(top: UiConstants.space1),
|
margin: const EdgeInsets.only(
|
||||||
|
top: UiConstants.space1,
|
||||||
|
),
|
||||||
width: 4,
|
width: 4,
|
||||||
height: 4,
|
height: 4,
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
@@ -327,40 +338,52 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space5,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: <Widget>[
|
||||||
const SizedBox(height: UiConstants.space5),
|
const SizedBox(height: UiConstants.space5),
|
||||||
if (widget.pendingAssignments.isNotEmpty) ...[
|
if (widget.pendingAssignments.isNotEmpty) ...<Widget>[
|
||||||
_buildSectionHeader(
|
_buildSectionHeader(
|
||||||
context.t.staff_shifts.my_shifts_tab.sections.awaiting,
|
context
|
||||||
|
.t.staff_shifts.my_shifts_tab.sections.awaiting,
|
||||||
UiColors.textWarning,
|
UiColors.textWarning,
|
||||||
),
|
),
|
||||||
...widget.pendingAssignments.map(
|
...widget.pendingAssignments.map(
|
||||||
(PendingAssignment assignment) => Padding(
|
(PendingAssignment assignment) => Padding(
|
||||||
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
padding: const EdgeInsets.only(
|
||||||
child: ShiftAssignmentCard(
|
bottom: UiConstants.space4,
|
||||||
assignment: assignment,
|
),
|
||||||
onConfirm: () => _confirmShift(assignment.shiftId),
|
child: ShiftCard(
|
||||||
onDecline: () => _declineShift(assignment.shiftId),
|
data: ShiftCardData.fromPending(assignment),
|
||||||
isConfirming: true,
|
onTap: () => Modular.to
|
||||||
|
.toShiftDetailsById(assignment.shiftId),
|
||||||
|
onAccept: () =>
|
||||||
|
_confirmShift(assignment.shiftId),
|
||||||
|
onDecline: () =>
|
||||||
|
_declineShift(assignment.shiftId),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space3),
|
const SizedBox(height: UiConstants.space3),
|
||||||
],
|
],
|
||||||
|
|
||||||
if (visibleCancelledShifts.isNotEmpty) ...[
|
if (visibleCancelledShifts.isNotEmpty) ...<Widget>[
|
||||||
_buildSectionHeader(context.t.staff_shifts.my_shifts_tab.sections.cancelled, UiColors.textSecondary),
|
_buildSectionHeader(
|
||||||
|
context
|
||||||
|
.t.staff_shifts.my_shifts_tab.sections.cancelled,
|
||||||
|
UiColors.textSecondary,
|
||||||
|
),
|
||||||
...visibleCancelledShifts.map(
|
...visibleCancelledShifts.map(
|
||||||
(CancelledShift cs) => Padding(
|
(CancelledShift cs) => Padding(
|
||||||
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
padding: const EdgeInsets.only(
|
||||||
child: _buildCancelledCard(
|
bottom: UiConstants.space4,
|
||||||
title: cs.title,
|
),
|
||||||
location: cs.location,
|
child: ShiftCard(
|
||||||
date: DateFormat('EEE, MMM d').format(cs.date),
|
data: ShiftCardData.fromCancelled(cs),
|
||||||
reason: cs.cancellationReason,
|
onTap: () =>
|
||||||
onTap: () {},
|
Modular.to.toShiftDetailsById(cs.shiftId),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -368,23 +391,43 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
],
|
],
|
||||||
|
|
||||||
// Confirmed Shifts
|
// Confirmed Shifts
|
||||||
if (visibleMyShifts.isNotEmpty) ...[
|
if (visibleMyShifts.isNotEmpty) ...<Widget>[
|
||||||
_buildSectionHeader(context.t.staff_shifts.my_shifts_tab.sections.confirmed, UiColors.textSecondary),
|
_buildSectionHeader(
|
||||||
|
context
|
||||||
|
.t.staff_shifts.my_shifts_tab.sections.confirmed,
|
||||||
|
UiColors.textSecondary,
|
||||||
|
),
|
||||||
...visibleMyShifts.map(
|
...visibleMyShifts.map(
|
||||||
(AssignedShift shift) => Padding(
|
(AssignedShift shift) {
|
||||||
padding: const EdgeInsets.only(bottom: UiConstants.space3),
|
final bool isCompleted =
|
||||||
child: MyShiftCard(
|
shift.status == AssignmentStatus.completed;
|
||||||
shift: shift,
|
final bool isSubmitted =
|
||||||
onDecline: () => _declineShift(shift.shiftId),
|
_submittedShiftIds.contains(shift.shiftId);
|
||||||
onRequestSwap: () {
|
|
||||||
|
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(
|
UiSnackbar.show(
|
||||||
context,
|
context,
|
||||||
message: context.t.staff_shifts.my_shifts_tab.swap_coming_soon,
|
message: context.t.staff_shifts
|
||||||
type: UiSnackbarType.message,
|
.my_shift_card.timesheet_submitted,
|
||||||
|
type: UiSnackbarType.success,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -393,8 +436,10 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
widget.cancelledShifts.isEmpty)
|
widget.cancelledShifts.isEmpty)
|
||||||
EmptyStateView(
|
EmptyStateView(
|
||||||
icon: UiIcons.calendar,
|
icon: UiIcons.calendar,
|
||||||
title: context.t.staff_shifts.my_shifts_tab.empty.title,
|
title:
|
||||||
subtitle: context.t.staff_shifts.my_shifts_tab.empty.subtitle,
|
context.t.staff_shifts.my_shifts_tab.empty.title,
|
||||||
|
subtitle: context
|
||||||
|
.t.staff_shifts.my_shifts_tab.empty.subtitle,
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: UiConstants.space32),
|
const SizedBox(height: UiConstants.space32),
|
||||||
@@ -410,11 +455,14 @@ class _MyShiftsTabState extends State<MyShiftsTab> {
|
|||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: <Widget>[
|
||||||
Container(
|
Container(
|
||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
|
decoration: BoxDecoration(
|
||||||
|
color: dotColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space2),
|
const SizedBox(width: UiConstants.space2),
|
||||||
Text(
|
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
|
intl: ^0.20.2
|
||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
bloc: ^8.1.4
|
bloc: ^8.1.4
|
||||||
|
meta: ^1.17.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user