feat: Refactor ShiftCard components to include client name and improve layout consistency

This commit is contained in:
Achintha Isuru
2026-03-19 21:13:35 -04:00
parent 591b5d7b88
commit 0ff2949c1e
5 changed files with 125 additions and 115 deletions

View File

@@ -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,

View File

@@ -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,
),
),
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
}