feat(shifts): refactor shift card implementation for today's and tomorrow's shifts

This commit is contained in:
Achintha Isuru
2026-03-18 15:56:20 -04:00
parent 47508f54e4
commit 3f42014cb9
3 changed files with 238 additions and 277 deletions

View File

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

View File

@@ -3,12 +3,12 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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/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/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'; import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dart';
/// A widget that displays today's shifts section. /// A widget that displays today's shifts section.
@@ -45,7 +45,29 @@ class TodaysShiftsSection extends StatelessWidget {
: Column( : Column(
children: shifts children: shifts
.map( .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(), .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. /// Inline shimmer skeleton for the shifts section loading state.
class _ShiftsSectionSkeleton extends StatelessWidget { class _ShiftsSectionSkeleton extends StatelessWidget {
const _ShiftsSectionSkeleton(); const _ShiftsSectionSkeleton();

View File

@@ -1,14 +1,13 @@
import 'package:core_localization/core_localization.dart'; import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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/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/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'; import 'package:staff_home/src/presentation/widgets/home_page/section_layout.dart';
/// A widget that displays tomorrow's shifts section. /// A widget that displays tomorrow's shifts section.
@@ -35,8 +34,26 @@ class TomorrowsShiftsSection extends StatelessWidget {
: Column( : Column(
children: shifts children: shifts
.map( .map(
(AssignedShift shift) => (AssignedShift shift) => HomeShiftCard(
_TomorrowShiftCard(shift: shift), 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(), .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,
),
),
],
),
],
),
),
],
),
),
);
}
}