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(
|
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user