feat(shifts): refactor shift card implementation for today's and tomorrow's shifts
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// A reusable compact card for displaying shift information on the home page.
|
||||
///
|
||||
/// Accepts display-ready primitive fields so it works with any shift type
|
||||
/// (today shifts, tomorrow shifts, etc.).
|
||||
class HomeShiftCard extends StatelessWidget {
|
||||
/// Creates a [HomeShiftCard].
|
||||
const HomeShiftCard({
|
||||
super.key,
|
||||
required this.shiftId,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.location,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
this.hourlyRate,
|
||||
this.totalRate,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
/// Unique identifier of the shift.
|
||||
final String shiftId;
|
||||
|
||||
/// Primary display text (client name or role name).
|
||||
final String title;
|
||||
|
||||
/// Secondary display text (role name when title is client name).
|
||||
final String? subtitle;
|
||||
|
||||
/// Location address to display.
|
||||
final String location;
|
||||
|
||||
/// Shift start time.
|
||||
final DateTime startTime;
|
||||
|
||||
/// Shift end time.
|
||||
final DateTime endTime;
|
||||
|
||||
/// Hourly rate in dollars, null if not available.
|
||||
final double? hourlyRate;
|
||||
|
||||
/// Total rate in dollars, null if not available.
|
||||
final double? totalRate;
|
||||
|
||||
/// Callback when the card is tapped.
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Formats a [DateTime] as a lowercase 12-hour time string (e.g. "9:00am").
|
||||
String _formatTime(DateTime time) {
|
||||
return DateFormat('h:mma').format(time).toLowerCase();
|
||||
}
|
||||
|
||||
/// Computes the shift duration in whole hours.
|
||||
double _durationHours() {
|
||||
final int minutes = endTime.difference(startTime).inMinutes;
|
||||
double hours = minutes / 60;
|
||||
if (hours < 0) hours += 24;
|
||||
return hours.roundToDouble();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool hasRate = hourlyRate != null && hourlyRate! > 0;
|
||||
final double durationHours = _durationHours();
|
||||
final double estimatedTotal = (totalRate != null && totalRate! > 0)
|
||||
? totalRate!
|
||||
: (hourlyRate ?? 0) * durationHours;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
spacing: UiConstants.space3,
|
||||
children: [
|
||||
Container(
|
||||
width: UiConstants.space10,
|
||||
height: UiConstants.space10,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
UiConstants.radiusBase,
|
||||
),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Icon(
|
||||
UiIcons.building,
|
||||
size: UiConstants.space5,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: UiTypography.body1m.textPrimary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
if (subtitle != null)
|
||||
Text(
|
||||
subtitle!,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (hasRate) ...<Widget>[
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// Time and location row
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space1,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
UiIcons.clock,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
'${_formatTime(startTime)} - ${_formatTime(endTime)}',
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
UiIcons.mapPin,
|
||||
size: UiConstants.space3,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Expanded(
|
||||
child: Text(
|
||||
location,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,12 @@ import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/home_shift_card.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dart';
|
||||
|
||||
/// A widget that displays today's shifts section.
|
||||
@@ -45,7 +45,29 @@ class TodaysShiftsSection extends StatelessWidget {
|
||||
: Column(
|
||||
children: shifts
|
||||
.map(
|
||||
(TodayShift shift) => _TodayShiftCard(shift: shift),
|
||||
(TodayShift shift) => HomeShiftCard(
|
||||
shiftId: shift.shiftId,
|
||||
title: shift.roleName.isNotEmpty
|
||||
? shift.roleName
|
||||
: shift.clientName,
|
||||
subtitle: shift.clientName.isNotEmpty
|
||||
? shift.clientName
|
||||
: null,
|
||||
location:
|
||||
shift.locationAddress?.isNotEmpty == true
|
||||
? shift.locationAddress!
|
||||
: shift.location,
|
||||
startTime: shift.startTime,
|
||||
endTime: shift.endTime,
|
||||
hourlyRate: shift.hourlyRate > 0
|
||||
? shift.hourlyRate
|
||||
: null,
|
||||
totalRate: shift.totalRate > 0
|
||||
? shift.totalRate
|
||||
: null,
|
||||
onTap: () => Modular.to
|
||||
.toShiftDetailsById(shift.shiftId),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
@@ -55,143 +77,6 @@ class TodaysShiftsSection extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact card for a today's shift.
|
||||
class _TodayShiftCard extends StatelessWidget {
|
||||
const _TodayShiftCard({required this.shift});
|
||||
|
||||
/// The today-shift to display.
|
||||
final TodayShift shift;
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
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
|
||||
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(
|
||||
onTap: () => Modular.to.toShiftDetailsById(shift.shiftId),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: UiConstants.space12,
|
||||
height: UiConstants.space12,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Icon(
|
||||
UiIcons.building,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
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),
|
||||
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(
|
||||
displayLocation,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline shimmer skeleton for the shifts section loading state.
|
||||
class _ShiftsSectionSkeleton extends StatelessWidget {
|
||||
const _ShiftsSectionSkeleton();
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:staff_home/src/presentation/blocs/home/home_cubit.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/empty_state_widget.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/home_shift_card.dart';
|
||||
import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dart';
|
||||
|
||||
/// A widget that displays tomorrow's shifts section.
|
||||
@@ -35,8 +34,26 @@ class TomorrowsShiftsSection extends StatelessWidget {
|
||||
: Column(
|
||||
children: shifts
|
||||
.map(
|
||||
(AssignedShift shift) =>
|
||||
_TomorrowShiftCard(shift: shift),
|
||||
(AssignedShift shift) => HomeShiftCard(
|
||||
shiftId: shift.shiftId,
|
||||
title: shift.clientName.isNotEmpty
|
||||
? shift.clientName
|
||||
: shift.roleName,
|
||||
subtitle: shift.clientName.isNotEmpty
|
||||
? shift.roleName
|
||||
: null,
|
||||
location: shift.location,
|
||||
startTime: shift.startTime,
|
||||
endTime: shift.endTime,
|
||||
hourlyRate: shift.hourlyRate > 0
|
||||
? shift.hourlyRate
|
||||
: null,
|
||||
totalRate: shift.totalRate > 0
|
||||
? shift.totalRate
|
||||
: null,
|
||||
onTap: () => Modular.to
|
||||
.toShiftDetailsById(shift.shiftId),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
@@ -45,137 +62,3 @@ class TomorrowsShiftsSection extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact card for a tomorrow's shift.
|
||||
class _TomorrowShiftCard extends StatelessWidget {
|
||||
const _TomorrowShiftCard({required this.shift});
|
||||
|
||||
/// The assigned shift to display.
|
||||
final AssignedShift shift;
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
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
|
||||
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;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => Modular.to.toShiftDetailsById(shift.shiftId),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: UiConstants.space12,
|
||||
height: UiConstants.space12,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UiConstants.radiusBase),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Icon(
|
||||
UiIcons.building,
|
||||
color: UiColors.mutedForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
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),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user