feat(shifts): enhance TodayShift and shift card components with client details and pay calculations
This commit is contained in:
@@ -17,6 +17,12 @@ class TodayShift extends Equatable {
|
|||||||
required this.startTime,
|
required this.startTime,
|
||||||
required this.endTime,
|
required this.endTime,
|
||||||
required this.attendanceStatus,
|
required this.attendanceStatus,
|
||||||
|
this.clientName = '',
|
||||||
|
this.hourlyRateCents = 0,
|
||||||
|
this.hourlyRate = 0.0,
|
||||||
|
this.totalRateCents = 0,
|
||||||
|
this.totalRate = 0.0,
|
||||||
|
this.locationAddress,
|
||||||
this.clockInAt,
|
this.clockInAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,6 +36,12 @@ class TodayShift extends Equatable {
|
|||||||
startTime: DateTime.parse(json['startTime'] as String),
|
startTime: DateTime.parse(json['startTime'] as String),
|
||||||
endTime: DateTime.parse(json['endTime'] as String),
|
endTime: DateTime.parse(json['endTime'] as String),
|
||||||
attendanceStatus: AttendanceStatusType.fromJson(json['attendanceStatus'] as String?),
|
attendanceStatus: AttendanceStatusType.fromJson(json['attendanceStatus'] as String?),
|
||||||
|
clientName: json['clientName'] as String? ?? '',
|
||||||
|
hourlyRateCents: json['hourlyRateCents'] as int? ?? 0,
|
||||||
|
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
totalRateCents: json['totalRateCents'] as int? ?? 0,
|
||||||
|
totalRate: (json['totalRate'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
locationAddress: json['locationAddress'] as String?,
|
||||||
clockInAt: json['clockInAt'] != null
|
clockInAt: json['clockInAt'] != null
|
||||||
? DateTime.parse(json['clockInAt'] as String)
|
? DateTime.parse(json['clockInAt'] as String)
|
||||||
: null,
|
: null,
|
||||||
@@ -48,6 +60,24 @@ class TodayShift extends Equatable {
|
|||||||
/// Human-readable location label (clock-point or shift location).
|
/// Human-readable location label (clock-point or shift location).
|
||||||
final String location;
|
final String location;
|
||||||
|
|
||||||
|
/// Name of the client / business for this shift.
|
||||||
|
final String clientName;
|
||||||
|
|
||||||
|
/// Pay rate in cents per hour.
|
||||||
|
final int hourlyRateCents;
|
||||||
|
|
||||||
|
/// Pay rate in dollars per hour.
|
||||||
|
final double hourlyRate;
|
||||||
|
|
||||||
|
/// Total pay for this shift in cents.
|
||||||
|
final int totalRateCents;
|
||||||
|
|
||||||
|
/// Total pay for this shift in dollars.
|
||||||
|
final double totalRate;
|
||||||
|
|
||||||
|
/// Full street address of the shift location, if available.
|
||||||
|
final String? locationAddress;
|
||||||
|
|
||||||
/// Scheduled start time.
|
/// Scheduled start time.
|
||||||
final DateTime startTime;
|
final DateTime startTime;
|
||||||
|
|
||||||
@@ -67,6 +97,12 @@ class TodayShift extends Equatable {
|
|||||||
'shiftId': shiftId,
|
'shiftId': shiftId,
|
||||||
'roleName': roleName,
|
'roleName': roleName,
|
||||||
'location': location,
|
'location': location,
|
||||||
|
'clientName': clientName,
|
||||||
|
'hourlyRateCents': hourlyRateCents,
|
||||||
|
'hourlyRate': hourlyRate,
|
||||||
|
'totalRateCents': totalRateCents,
|
||||||
|
'totalRate': totalRate,
|
||||||
|
'locationAddress': locationAddress,
|
||||||
'startTime': startTime.toIso8601String(),
|
'startTime': startTime.toIso8601String(),
|
||||||
'endTime': endTime.toIso8601String(),
|
'endTime': endTime.toIso8601String(),
|
||||||
'attendanceStatus': attendanceStatus.toJson(),
|
'attendanceStatus': attendanceStatus.toJson(),
|
||||||
@@ -80,6 +116,12 @@ class TodayShift extends Equatable {
|
|||||||
shiftId,
|
shiftId,
|
||||||
roleName,
|
roleName,
|
||||||
location,
|
location,
|
||||||
|
clientName,
|
||||||
|
hourlyRateCents,
|
||||||
|
hourlyRate,
|
||||||
|
totalRateCents,
|
||||||
|
totalRate,
|
||||||
|
locationAddress,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
attendanceStatus,
|
attendanceStatus,
|
||||||
|
|||||||
@@ -21,11 +21,20 @@ class RecommendedShiftCard extends StatelessWidget {
|
|||||||
return DateFormat('h:mma').format(time).toLowerCase();
|
return DateFormat('h:mma').format(time).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Computes the shift duration in whole hours.
|
||||||
|
double _durationHours() {
|
||||||
|
final int minutes = shift.endTime.difference(shift.startTime).inMinutes;
|
||||||
|
double hours = minutes / 60;
|
||||||
|
if (hours < 0) hours += 24;
|
||||||
|
return hours.roundToDouble();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final dynamic recI18n = t.staff.home.recommended_card;
|
final dynamic recI18n = t.staff.home.recommended_card;
|
||||||
final Size size = MediaQuery.sizeOf(context);
|
final Size size = MediaQuery.sizeOf(context);
|
||||||
final double hourlyRate = shift.hourlyRateCents / 100;
|
final double durationHours = _durationHours();
|
||||||
|
final double estimatedTotal = shift.hourlyRate * durationHours;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => Modular.to.toShiftDetailsById(shift.shiftId),
|
onTap: () => Modular.to.toShiftDetailsById(shift.shiftId),
|
||||||
@@ -75,8 +84,8 @@ class RecommendedShiftCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'\$${hourlyRate.toStringAsFixed(0)}/h',
|
'\$${estimatedTotal.toStringAsFixed(0)}',
|
||||||
style: UiTypography.headline4b,
|
style: UiTypography.title1m.textPrimary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -89,8 +98,8 @@ class RecommendedShiftCard extends StatelessWidget {
|
|||||||
style: UiTypography.body3r.textSecondary,
|
style: UiTypography.body3r.textSecondary,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'\$${hourlyRate.toStringAsFixed(0)}/hr',
|
'\$${shift.hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h',
|
||||||
style: UiTypography.body3r.textSecondary,
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -66,8 +66,31 @@ class _TodayShiftCard extends StatelessWidget {
|
|||||||
return DateFormat('h:mma').format(time).toLowerCase();
|
return DateFormat('h:mma').format(time).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Computes the shift duration in whole hours.
|
||||||
|
double _durationHours() {
|
||||||
|
final int minutes = shift.endTime.difference(shift.startTime).inMinutes;
|
||||||
|
double hours = minutes / 60;
|
||||||
|
if (hours < 0) hours += 24;
|
||||||
|
return hours.roundToDouble();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final bool hasRate = shift.hourlyRate > 0;
|
||||||
|
final String title = shift.clientName.isNotEmpty
|
||||||
|
? shift.clientName
|
||||||
|
: shift.roleName;
|
||||||
|
final String? subtitle = shift.clientName.isNotEmpty
|
||||||
|
? shift.roleName
|
||||||
|
: null;
|
||||||
|
final double durationHours = _durationHours();
|
||||||
|
final double estimatedTotal = shift.totalRate > 0
|
||||||
|
? shift.totalRate
|
||||||
|
: shift.hourlyRate * durationHours;
|
||||||
|
final String displayLocation = shift.locationAddress?.isNotEmpty == true
|
||||||
|
? shift.locationAddress!
|
||||||
|
: shift.location;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => Modular.to.toShiftDetailsById(shift.shiftId),
|
onTap: () => Modular.to.toShiftDetailsById(shift.shiftId),
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -99,15 +122,65 @@ class _TodayShiftCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Row(
|
||||||
shift.roleName,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
style: UiTypography.body1m.textPrimary,
|
children: <Widget>[
|
||||||
overflow: TextOverflow.ellipsis,
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: UiTypography.body1m.textPrimary,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hasRate)
|
||||||
|
Text(
|
||||||
|
'\$${estimatedTotal.toStringAsFixed(0)}',
|
||||||
|
style: UiTypography.title1m.textPrimary,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
if (subtitle != null)
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: UiTypography.body3r.textSecondary,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (hasRate) ...<Widget>[
|
||||||
|
const SizedBox(height: UiConstants.space1),
|
||||||
|
Text(
|
||||||
|
'\$${shift.hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h',
|
||||||
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: UiConstants.space1),
|
const SizedBox(height: UiConstants.space1),
|
||||||
Text(
|
Row(
|
||||||
'${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)} \u2022 ${shift.location}',
|
children: <Widget>[
|
||||||
style: UiTypography.body3r.textSecondary,
|
Icon(
|
||||||
|
UiIcons.clock,
|
||||||
|
size: UiConstants.space3,
|
||||||
|
color: UiColors.mutedForeground,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space1),
|
||||||
|
Text(
|
||||||
|
'${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}',
|
||||||
|
style: UiTypography.body3r.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space3),
|
||||||
|
Icon(
|
||||||
|
UiIcons.mapPin,
|
||||||
|
size: UiConstants.space3,
|
||||||
|
color: UiColors.mutedForeground,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space1),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
displayLocation,
|
||||||
|
style: UiTypography.body3r.textSecondary,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -57,9 +57,27 @@ class _TomorrowShiftCard extends StatelessWidget {
|
|||||||
return DateFormat('h:mma').format(time).toLowerCase();
|
return DateFormat('h:mma').format(time).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Computes the shift duration in whole hours.
|
||||||
|
double _durationHours() {
|
||||||
|
final int minutes = shift.endTime.difference(shift.startTime).inMinutes;
|
||||||
|
double hours = minutes / 60;
|
||||||
|
if (hours < 0) hours += 24;
|
||||||
|
return hours.roundToDouble();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final double hourlyRate = shift.hourlyRateCents / 100;
|
final bool hasRate = shift.hourlyRate > 0;
|
||||||
|
final String title = shift.clientName.isNotEmpty
|
||||||
|
? shift.clientName
|
||||||
|
: shift.roleName;
|
||||||
|
final String? subtitle = shift.clientName.isNotEmpty
|
||||||
|
? shift.roleName
|
||||||
|
: null;
|
||||||
|
final double durationHours = _durationHours();
|
||||||
|
final double estimatedTotal = shift.totalRate > 0
|
||||||
|
? shift.totalRate
|
||||||
|
: shift.hourlyRate * durationHours;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => Modular.to.toShiftDetailsById(shift.shiftId),
|
onTap: () => Modular.to.toShiftDetailsById(shift.shiftId),
|
||||||
@@ -97,31 +115,61 @@ class _TomorrowShiftCard extends StatelessWidget {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
shift.roleName,
|
title,
|
||||||
style: UiTypography.body1m.textPrimary,
|
style: UiTypography.body1m.textPrimary,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text.rich(
|
if (hasRate)
|
||||||
TextSpan(
|
Text(
|
||||||
text:
|
'\$${estimatedTotal.toStringAsFixed(0)}',
|
||||||
'\$${hourlyRate % 1 == 0 ? hourlyRate.toInt() : hourlyRate.toStringAsFixed(2)}',
|
style: UiTypography.title1m.textPrimary,
|
||||||
style: UiTypography.body1b.textPrimary,
|
),
|
||||||
children: <InlineSpan>[
|
],
|
||||||
TextSpan(
|
),
|
||||||
text: '/h',
|
if (subtitle != null)
|
||||||
style: UiTypography.body3r,
|
Text(
|
||||||
),
|
subtitle,
|
||||||
],
|
style: UiTypography.body3r.textSecondary,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (hasRate) ...<Widget>[
|
||||||
|
const SizedBox(height: UiConstants.space1),
|
||||||
|
Text(
|
||||||
|
'\$${shift.hourlyRate.toInt()}/hr \u00b7 ${durationHours.toInt()}h',
|
||||||
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: UiConstants.space1),
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Icon(
|
||||||
|
UiIcons.clock,
|
||||||
|
size: UiConstants.space3,
|
||||||
|
color: UiColors.mutedForeground,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space1),
|
||||||
|
Text(
|
||||||
|
'${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)}',
|
||||||
|
style: UiTypography.body3r.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space3),
|
||||||
|
Icon(
|
||||||
|
UiIcons.mapPin,
|
||||||
|
size: UiConstants.space3,
|
||||||
|
color: UiColors.mutedForeground,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space1),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
shift.location,
|
||||||
|
style: UiTypography.body3r.textSecondary,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space1),
|
|
||||||
Text(
|
|
||||||
'${_formatTime(shift.startTime)} - ${_formatTime(shift.endTime)} \u2022 ${shift.location}',
|
|
||||||
style: UiTypography.body3r.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user