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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
// Role name + pay headline + chevron // Role name + pay headline
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: UiConstants.space1, spacing: UiConstants.space1,

View File

@@ -14,27 +14,23 @@ class ShiftCardBody extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
ShiftCardIcon(variant: data.variant), Row(
const SizedBox(width: UiConstants.space3), children: [
Expanded( ShiftCardIcon(variant: data.variant),
child: Column( ShiftCardTitleRow(data: data),
crossAxisAlignment: CrossAxisAlignment.start, ],
children: <Widget>[
ShiftCardTitleRow(data: data),
const SizedBox(height: UiConstants.space2),
ShiftCardMetadataRows(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 { class ShiftCardIcon extends StatelessWidget {
/// Creates a [ShiftCardIcon]. /// Creates a [ShiftCardIcon].
const ShiftCardIcon({super.key, required this.variant}); const ShiftCardIcon({super.key, required this.variant});
@@ -47,30 +43,19 @@ class ShiftCardIcon extends StatelessWidget {
final bool isCancelled = variant == ShiftCardVariant.cancelled; final bool isCancelled = variant == ShiftCardVariant.cancelled;
return Container( return Container(
width: 44, width: UiConstants.space10,
height: 44, height: UiConstants.space10,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: isCancelled color: isCancelled
? null ? UiColors.primary.withValues(alpha: 0.05)
: LinearGradient( : UiColors.tagInProgress,
colors: <Color>[ borderRadius: UiConstants.radiusLg,
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: const Center(
child: Icon( child: Icon(
UiIcons.briefcase, UiIcons.briefcase,
color: UiColors.primary, color: UiColors.primary,
size: UiConstants.iconMd, size: UiConstants.space5,
), ),
), ),
); );

View File

@@ -38,6 +38,7 @@ class ShiftCardData {
required this.date, required this.date,
required this.variant, required this.variant,
this.subtitle, this.subtitle,
this.clientName,
this.startTime, this.startTime,
this.endTime, this.endTime,
this.hourlyRateCents, this.hourlyRateCents,
@@ -57,9 +58,12 @@ class ShiftCardData {
subtitle: shift.location, subtitle: shift.location,
location: shift.location, location: shift.location,
date: shift.date, date: shift.date,
clientName: shift.clientName,
startTime: shift.startTime, startTime: shift.startTime,
endTime: shift.endTime, endTime: shift.endTime,
hourlyRateCents: shift.hourlyRateCents, hourlyRateCents: shift.hourlyRateCents,
hourlyRate: shift.hourlyRate,
totalRate: shift.totalRate,
orderType: shift.orderType, orderType: shift.orderType,
variant: _variantFromAssignmentStatus(shift.status), variant: _variantFromAssignmentStatus(shift.status),
); );
@@ -73,6 +77,7 @@ class ShiftCardData {
subtitle: shift.title.isNotEmpty ? shift.title : null, subtitle: shift.title.isNotEmpty ? shift.title : null,
location: shift.location, location: shift.location,
date: shift.date, date: shift.date,
clientName: shift.clientName,
startTime: shift.startTime, startTime: shift.startTime,
endTime: shift.endTime, endTime: shift.endTime,
hourlyRateCents: shift.hourlyRateCents, hourlyRateCents: shift.hourlyRateCents,
@@ -91,6 +96,7 @@ class ShiftCardData {
title: shift.title, title: shift.title,
location: shift.location, location: shift.location,
date: shift.date, date: shift.date,
clientName: shift.clientName,
cancellationReason: shift.cancellationReason, cancellationReason: shift.cancellationReason,
variant: ShiftCardVariant.cancelled, variant: ShiftCardVariant.cancelled,
); );
@@ -104,6 +110,7 @@ class ShiftCardData {
subtitle: assignment.title.isNotEmpty ? assignment.title : null, subtitle: assignment.title.isNotEmpty ? assignment.title : null,
location: assignment.location, location: assignment.location,
date: assignment.startTime, date: assignment.startTime,
clientName: assignment.clientName,
startTime: assignment.startTime, startTime: assignment.startTime,
endTime: assignment.endTime, endTime: assignment.endTime,
variant: ShiftCardVariant.pending, variant: ShiftCardVariant.pending,
@@ -119,6 +126,9 @@ class ShiftCardData {
/// Optional secondary text (e.g. location under the role name). /// Optional secondary text (e.g. location under the role name).
final String? subtitle; final String? subtitle;
/// Client/business name.
final String? clientName;
/// Human-readable location label. /// Human-readable location label.
final String location; final String location;

View File

@@ -4,7 +4,10 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart'; import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_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 { class ShiftCardMetadataRows extends StatelessWidget {
/// Creates a [ShiftCardMetadataRows]. /// Creates a [ShiftCardMetadataRows].
const ShiftCardMetadataRows({super.key, required this.data}); const ShiftCardMetadataRows({super.key, required this.data});
@@ -15,62 +18,71 @@ class ShiftCardMetadataRows extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
// Date and time row // Date row (with optional worked duration for completed shifts).
Row( Row(
children: <Widget>[ children: <Widget>[
const Icon( const Icon(
UiIcons.calendar, UiIcons.calendar,
size: UiConstants.iconXs, size: UiConstants.space3,
color: UiColors.iconSecondary, color: UiColors.mutedForeground,
), ),
const SizedBox(width: UiConstants.space1), const SizedBox(width: UiConstants.space1),
Text( Text(
_formatDate(context, data.date), _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>[ if (data.minutesWorked != null) ...<Widget>[
const SizedBox(width: UiConstants.space3), const SizedBox(width: UiConstants.space3),
const Icon( const Icon(
UiIcons.clock, UiIcons.clock,
size: UiConstants.iconXs, size: UiConstants.space3,
color: UiColors.iconSecondary, color: UiColors.mutedForeground,
), ),
const SizedBox(width: UiConstants.space1), const SizedBox(width: UiConstants.space1),
Text( Text(
_formatWorkedDuration(data.minutesWorked!), _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), const SizedBox(height: UiConstants.space1),
// Location row
Row( Row(
children: <Widget>[ children: <Widget>[
const Icon( const Icon(
UiIcons.mapPin, UiIcons.mapPin,
size: UiConstants.iconXs, size: UiConstants.space3,
color: UiColors.iconSecondary, color: UiColors.mutedForeground,
), ),
const SizedBox(width: UiConstants.space1), const SizedBox(width: UiConstants.space1),
Expanded( Expanded(
child: Text( child: Text(
data.location, data.location,
style: UiTypography.footnote1r.textSecondary, style: UiTypography.body3r.textSecondary,
overflow: TextOverflow.ellipsis, 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) { String _formatDate(BuildContext context, DateTime date) {
final DateTime now = DateTime.now(); final DateTime now = DateTime.now();
final DateTime today = DateTime(now.year, now.month, now.day); 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); 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) { String _formatWorkedDuration(int totalMinutes) {
final int hours = totalMinutes ~/ 60; final int hours = totalMinutes ~/ 60;
final int mins = totalMinutes % 60; final int mins = totalMinutes % 60;

View File

@@ -1,8 +1,10 @@
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_data.dart'; import 'package:staff_shifts/src/presentation/widgets/shift_card/shift_card_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 { class ShiftCardTitleRow extends StatelessWidget {
/// Creates a [ShiftCardTitleRow]. /// Creates a [ShiftCardTitleRow].
const ShiftCardTitleRow({super.key, required this.data}); const ShiftCardTitleRow({super.key, required this.data});
@@ -12,77 +14,78 @@ class ShiftCardTitleRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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 hasDirectRate = data.hourlyRate != null && data.hourlyRate! > 0;
final bool hasComputedRate = final bool hasComputedRate =
data.hourlyRateCents != null && data.hourlyRateCents != null &&
data.startTime != null && data.startTime != null &&
data.endTime != null; data.endTime != null;
final bool hasPay = hasDirectRate || hasComputedRate;
if (!hasDirectRate && !hasComputedRate) { // Compute pay values when available.
return Text( double hourlyRate = 0;
data.title, double estimatedTotal = 0;
style: UiTypography.body2m.textPrimary, double durationHours = 0;
overflow: TextOverflow.ellipsis,
); 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. return Column(
final double hourlyRate; crossAxisAlignment: CrossAxisAlignment.start,
final double estimatedTotal;
final double durationHours;
if (hasDirectRate && data.totalRate != null && data.totalRate! > 0) {
hourlyRate = data.hourlyRate!;
estimatedTotal = data.totalRate!;
durationHours = hourlyRate > 0 ? (estimatedTotal / hourlyRate) : 0;
} else {
hourlyRate = data.hourlyRateCents! / 100;
final int durationMinutes = data.endTime!
.difference(data.startTime!)
.inMinutes;
double hours = durationMinutes / 60;
if (hours < 0) hours += 24;
durationHours = hours.roundToDouble();
estimatedTotal = hourlyRate * durationHours;
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[ children: <Widget>[
Expanded( // Row 1: Title + Pay headline
child: Column( Row(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ spacing: UiConstants.space1,
Text( children: <Widget>[
Flexible(
child: Text(
data.title, data.title,
style: UiTypography.body2m.textPrimary, style: UiTypography.body1m.textPrimary,
overflow: TextOverflow.ellipsis, 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();
} }