feat: Refactor ShiftCard components to include client name and improve layout consistency
This commit is contained in:
@@ -135,7 +135,7 @@ class AvailableOrderCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// Role name + pay headline + chevron
|
||||
// Role name + pay headline
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: UiConstants.space1,
|
||||
|
||||
@@ -14,27 +14,23 @@ class ShiftCardBody extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
ShiftCardIcon(variant: data.variant),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
ShiftCardTitleRow(data: data),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
ShiftCardMetadataRows(data: data),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
ShiftCardIcon(variant: data.variant),
|
||||
ShiftCardTitleRow(data: data),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
ShiftCardMetadataRows(data: data),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The 44x44 icon box with a gradient background.
|
||||
/// The icon box matching the AvailableOrderCard style.
|
||||
class ShiftCardIcon extends StatelessWidget {
|
||||
/// Creates a [ShiftCardIcon].
|
||||
const ShiftCardIcon({super.key, required this.variant});
|
||||
@@ -47,30 +43,19 @@ class ShiftCardIcon extends StatelessWidget {
|
||||
final bool isCancelled = variant == ShiftCardVariant.cancelled;
|
||||
|
||||
return Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
width: UiConstants.space10,
|
||||
height: UiConstants.space10,
|
||||
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)),
|
||||
color: isCancelled
|
||||
? UiColors.primary.withValues(alpha: 0.05)
|
||||
: UiColors.tagInProgress,
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
UiIcons.briefcase,
|
||||
color: UiColors.primary,
|
||||
size: UiConstants.iconMd,
|
||||
size: UiConstants.space5,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -38,6 +38,7 @@ class ShiftCardData {
|
||||
required this.date,
|
||||
required this.variant,
|
||||
this.subtitle,
|
||||
this.clientName,
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
this.hourlyRateCents,
|
||||
@@ -57,9 +58,12 @@ class ShiftCardData {
|
||||
subtitle: shift.location,
|
||||
location: shift.location,
|
||||
date: shift.date,
|
||||
clientName: shift.clientName,
|
||||
startTime: shift.startTime,
|
||||
endTime: shift.endTime,
|
||||
hourlyRateCents: shift.hourlyRateCents,
|
||||
hourlyRate: shift.hourlyRate,
|
||||
totalRate: shift.totalRate,
|
||||
orderType: shift.orderType,
|
||||
variant: _variantFromAssignmentStatus(shift.status),
|
||||
);
|
||||
@@ -73,6 +77,7 @@ class ShiftCardData {
|
||||
subtitle: shift.title.isNotEmpty ? shift.title : null,
|
||||
location: shift.location,
|
||||
date: shift.date,
|
||||
clientName: shift.clientName,
|
||||
startTime: shift.startTime,
|
||||
endTime: shift.endTime,
|
||||
hourlyRateCents: shift.hourlyRateCents,
|
||||
@@ -91,6 +96,7 @@ class ShiftCardData {
|
||||
title: shift.title,
|
||||
location: shift.location,
|
||||
date: shift.date,
|
||||
clientName: shift.clientName,
|
||||
cancellationReason: shift.cancellationReason,
|
||||
variant: ShiftCardVariant.cancelled,
|
||||
);
|
||||
@@ -104,6 +110,7 @@ class ShiftCardData {
|
||||
subtitle: assignment.title.isNotEmpty ? assignment.title : null,
|
||||
location: assignment.location,
|
||||
date: assignment.startTime,
|
||||
clientName: assignment.clientName,
|
||||
startTime: assignment.startTime,
|
||||
endTime: assignment.endTime,
|
||||
variant: ShiftCardVariant.pending,
|
||||
@@ -119,6 +126,9 @@ class ShiftCardData {
|
||||
/// Optional secondary text (e.g. location under the role name).
|
||||
final String? subtitle;
|
||||
|
||||
/// Client/business name.
|
||||
final String? clientName;
|
||||
|
||||
/// Human-readable location label.
|
||||
final String location;
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart';
|
||||
|
||||
/// Date, time, location, and worked-hours rows.
|
||||
/// Date, client name, location, and worked-hours metadata rows.
|
||||
///
|
||||
/// Follows the AvailableOrderCard element ordering:
|
||||
/// date -> client name -> location.
|
||||
class ShiftCardMetadataRows extends StatelessWidget {
|
||||
/// Creates a [ShiftCardMetadataRows].
|
||||
const ShiftCardMetadataRows({super.key, required this.data});
|
||||
@@ -15,62 +18,71 @@ class ShiftCardMetadataRows extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// Date and time row
|
||||
// Date row (with optional worked duration for completed shifts).
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.calendar,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
_formatDate(context, data.date),
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
style: UiTypography.body3r.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,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
_formatWorkedDuration(data.minutesWorked!),
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
// Client name row.
|
||||
if (data.clientName != null && data.clientName!.isNotEmpty) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.building,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Expanded(
|
||||
child: Text(
|
||||
data.clientName!,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
// Location row.
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
// Location row
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: UiConstants.iconXs,
|
||||
color: UiColors.iconSecondary,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Expanded(
|
||||
child: Text(
|
||||
data.location,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
@@ -80,6 +92,7 @@ class ShiftCardMetadataRows extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Formats [date] relative to today/tomorrow, or as "EEE, MMM d".
|
||||
String _formatDate(BuildContext context, DateTime date) {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime today = DateTime(now.year, now.month, now.day);
|
||||
@@ -92,8 +105,7 @@ class ShiftCardMetadataRows extends StatelessWidget {
|
||||
return DateFormat('EEE, MMM d').format(date);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dt) => DateFormat('h:mm a').format(dt);
|
||||
|
||||
/// Formats total minutes worked into a "Xh Ym" string.
|
||||
String _formatWorkedDuration(int totalMinutes) {
|
||||
final int hours = totalMinutes ~/ 60;
|
||||
final int mins = totalMinutes % 60;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart';
|
||||
|
||||
/// Title row with optional pay summary on the right.
|
||||
/// Title row showing role name + pay headline, with a time subtitle + pay detail
|
||||
/// row below. Matches the AvailableOrderCard layout.
|
||||
class ShiftCardTitleRow extends StatelessWidget {
|
||||
/// Creates a [ShiftCardTitleRow].
|
||||
const ShiftCardTitleRow({super.key, required this.data});
|
||||
@@ -12,77 +14,78 @@ class ShiftCardTitleRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Determine if we have enough data to show pay information.
|
||||
final bool hasDirectRate = data.hourlyRate != null && data.hourlyRate! > 0;
|
||||
final bool hasComputedRate =
|
||||
data.hourlyRateCents != null &&
|
||||
data.startTime != null &&
|
||||
data.endTime != null;
|
||||
final bool hasPay = hasDirectRate || hasComputedRate;
|
||||
|
||||
if (!hasDirectRate && !hasComputedRate) {
|
||||
return Text(
|
||||
data.title,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
// Compute pay values when available.
|
||||
double hourlyRate = 0;
|
||||
double estimatedTotal = 0;
|
||||
double durationHours = 0;
|
||||
|
||||
if (hasPay) {
|
||||
if (hasDirectRate && data.totalRate != null && data.totalRate! > 0) {
|
||||
hourlyRate = data.hourlyRate!;
|
||||
estimatedTotal = data.totalRate!;
|
||||
durationHours = hourlyRate > 0 ? (estimatedTotal / hourlyRate) : 0;
|
||||
} else if (hasComputedRate) {
|
||||
hourlyRate = data.hourlyRateCents! / 100;
|
||||
final int durationMinutes =
|
||||
data.endTime!.difference(data.startTime!).inMinutes;
|
||||
double hours = durationMinutes / 60;
|
||||
if (hours < 0) hours += 24;
|
||||
durationHours = hours.roundToDouble();
|
||||
estimatedTotal = hourlyRate * durationHours;
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer pre-computed values from the API when available.
|
||||
final double hourlyRate;
|
||||
final double estimatedTotal;
|
||||
final double durationHours;
|
||||
|
||||
if (hasDirectRate && data.totalRate != null && data.totalRate! > 0) {
|
||||
hourlyRate = data.hourlyRate!;
|
||||
estimatedTotal = data.totalRate!;
|
||||
durationHours = hourlyRate > 0 ? (estimatedTotal / hourlyRate) : 0;
|
||||
} else {
|
||||
hourlyRate = data.hourlyRateCents! / 100;
|
||||
final int durationMinutes = data.endTime!
|
||||
.difference(data.startTime!)
|
||||
.inMinutes;
|
||||
double hours = durationMinutes / 60;
|
||||
if (hours < 0) hours += 24;
|
||||
durationHours = hours.roundToDouble();
|
||||
estimatedTotal = hourlyRate * durationHours;
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
// Row 1: Title + Pay headline
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
Flexible(
|
||||
child: Text(
|
||||
data.title,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
style: UiTypography.body1m.textPrimary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (data.subtitle != null) ...<Widget>[
|
||||
Text(
|
||||
data.subtitle!,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'\$${estimatedTotal.toStringAsFixed(0)}',
|
||||
style: UiTypography.title1m.textPrimary,
|
||||
),
|
||||
Text(
|
||||
'\$${hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
if (hasPay)
|
||||
Text(
|
||||
'\$${estimatedTotal.toStringAsFixed(0)}',
|
||||
style: UiTypography.title1m.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
// Row 2: Time subtitle + pay detail
|
||||
if (data.startTime != null && data.endTime != null)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'${_formatTime(data.startTime!)} - ${_formatTime(data.endTime!)}',
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
if (hasPay)
|
||||
Text(
|
||||
'\$${hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Formats a [DateTime] to a compact time string like "3:30pm".
|
||||
String _formatTime(DateTime dt) => DateFormat('h:mma').format(dt).toLowerCase();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user