feat: Add required and filled slots to Shift entity and update ShiftDetailsPage for capacity display

This commit is contained in:
Achintha Isuru
2026-01-31 21:48:42 -05:00
parent 144976de00
commit 820f475c51
3 changed files with 349 additions and 207 deletions

View File

@@ -24,6 +24,8 @@ class Shift extends Equatable {
final double? longitude; final double? longitude;
final String? status; final String? status;
final int? durationDays; // For multi-day shifts final int? durationDays; // For multi-day shifts
final int? requiredSlots;
final int? filledSlots;
const Shift({ const Shift({
required this.id, required this.id,
@@ -49,6 +51,8 @@ class Shift extends Equatable {
this.longitude, this.longitude,
this.status, this.status,
this.durationDays, this.durationDays,
this.requiredSlots,
this.filledSlots,
}); });
@override @override
@@ -76,6 +80,8 @@ class Shift extends Equatable {
longitude, longitude,
status, status,
durationDays, durationDays,
requiredSlots,
filledSlots,
]; ];
} }

View File

@@ -127,6 +127,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
status: _mapStatus(status), status: _mapStatus(status),
description: shift.description, description: shift.description,
durationDays: shift.durationDays, durationDays: shift.durationDays,
requiredSlots: shift.requiredSlots,
filledSlots: shift.filledSlots,
)); ));
} }
} }
@@ -182,6 +184,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
status: s.status?.stringValue.toLowerCase() ?? 'open', status: s.status?.stringValue.toLowerCase() ?? 'open',
description: s.description, description: s.description,
durationDays: s.durationDays, durationDays: s.durationDays,
requiredSlots: null, // Basic list doesn't fetch detailed role stats yet
filledSlots: null,
)); ));
} }
@@ -210,6 +214,20 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
final s = result.data.shift; final s = result.data.shift;
if (s == null) return null; if (s == null) return null;
int? required;
int? filled;
try {
final rolesRes = await _dataConnect.listShiftRolesByShiftId(shiftId: shiftId).execute();
if (rolesRes.data.shiftRoles.isNotEmpty) {
required = 0;
filled = 0;
for(var r in rolesRes.data.shiftRoles) {
required = (required ?? 0) + r.count;
filled = (filled ?? 0) + (r.assigned ?? 0);
}
}
} catch (_) {}
final startDt = _toDateTime(s.startTime); final startDt = _toDateTime(s.startTime);
final endDt = _toDateTime(s.endTime); final endDt = _toDateTime(s.endTime);
final createdDt = _toDateTime(s.createdAt); final createdDt = _toDateTime(s.createdAt);
@@ -229,6 +247,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
status: s.status?.stringValue ?? 'OPEN', status: s.status?.stringValue ?? 'OPEN',
description: s.description, description: s.description,
durationDays: s.durationDays, durationDays: s.durationDays,
requiredSlots: required,
filledSlots: filled,
); );
} catch (e) { } catch (e) {
return null; return null;

View File

@@ -7,18 +7,12 @@ import 'package:intl/intl.dart';
import '../blocs/shift_details/shift_details_bloc.dart'; import '../blocs/shift_details/shift_details_bloc.dart';
import '../blocs/shift_details/shift_details_event.dart'; import '../blocs/shift_details/shift_details_event.dart';
import '../blocs/shift_details/shift_details_state.dart'; import '../blocs/shift_details/shift_details_state.dart';
import '../styles/shifts_styles.dart';
import '../widgets/my_shift_card.dart';
class ShiftDetailsPage extends StatelessWidget { class ShiftDetailsPage extends StatelessWidget {
final String shiftId; final String shiftId;
final Shift? shift; final Shift? shift;
const ShiftDetailsPage({ const ShiftDetailsPage({super.key, required this.shiftId, this.shift});
super.key,
required this.shiftId,
this.shift,
});
String _formatTime(String time) { String _formatTime(String time) {
if (time.isEmpty) return ''; if (time.isEmpty) return '';
@@ -33,6 +27,16 @@ class ShiftDetailsPage extends StatelessWidget {
} }
} }
String _formatDate(String dateStr) {
if (dateStr.isEmpty) return '';
try {
final date = DateTime.parse(dateStr);
return DateFormat('EEEE, MMMM d, y').format(date);
} catch (e) {
return dateStr;
}
}
double _calculateDuration(Shift shift) { double _calculateDuration(Shift shift) {
if (shift.startTime.isEmpty || shift.endTime.isEmpty) { if (shift.startTime.isEmpty || shift.endTime.isEmpty) {
return 0; return 0;
@@ -70,9 +74,7 @@ class ShiftDetailsPage extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
value, value,
style: UiTypography.title1m.copyWith( style: UiTypography.title1m.copyWith(color: UiColors.textPrimary),
color: UiColors.textPrimary,
),
), ),
Text( Text(
label, label,
@@ -118,9 +120,9 @@ class ShiftDetailsPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( return BlocProvider<ShiftDetailsBloc>(
create: (_) => Modular.get<ShiftDetailsBloc>() create: (_) =>
..add(LoadShiftDetailsEvent(shiftId)), Modular.get<ShiftDetailsBloc>()..add(LoadShiftDetailsEvent(shiftId)),
child: BlocListener<ShiftDetailsBloc, ShiftDetailsState>( child: BlocListener<ShiftDetailsBloc, ShiftDetailsState>(
listener: (context, state) { listener: (context, state) {
if (state is ShiftActionSuccess) { if (state is ShiftActionSuccess) {
@@ -163,213 +165,322 @@ class ShiftDetailsPage extends StatelessWidget {
final duration = _calculateDuration(displayShift); final duration = _calculateDuration(displayShift);
final estimatedTotal = (displayShift.hourlyRate) * duration; final estimatedTotal = (displayShift.hourlyRate) * duration;
final openSlots =
(displayShift.requiredSlots ?? 0) -
(displayShift.filledSlots ?? 0);
return Scaffold( return Scaffold(
backgroundColor: AppColors.krowBackground, appBar: UiAppBar(
appBar: AppBar( title: displayShift.title,
title: const Text("Shift Details"), showBackButton: true,
backgroundColor: Colors.white, centerTitle: false,
foregroundColor: AppColors.krowCharcoal,
elevation: 0.5,
), ),
body: Padding( body: Column(
padding: const EdgeInsets.all(20.0), children: [
child: Column( Expanded(
children: [ child: SingleChildScrollView(
Expanded( padding: const EdgeInsets.all(20.0),
child: SingleChildScrollView( child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyShiftCard( // Vendor Section
shift: displayShift, Column(
// No direct actions on the card, handled by page buttons crossAxisAlignment: CrossAxisAlignment.start,
), children: [
const SizedBox(height: 24), const Text(
"VENDOR",
// Stats Row style: TextStyle(
Row( fontSize: 10,
children: [ fontWeight: FontWeight.bold,
Expanded( color: UiColors.textSecondary,
child: _buildStatCard( letterSpacing: 0.5,
UiIcons.dollar,
"\$${estimatedTotal.toStringAsFixed(0)}",
"Total",
),
), ),
const SizedBox(width: 12), ),
Expanded( const SizedBox(height: 8),
child: _buildStatCard( Row(
UiIcons.dollar, children: [
"\$${displayShift.hourlyRate.toInt()}", Container(
"Hourly Rate", width: 24,
), height: 24,
), child: displayShift.logoUrl != null
const SizedBox(width: 12), ? ClipRRect(
Expanded( borderRadius: BorderRadius.circular(
child: _buildStatCard( 6,
UiIcons.clock, ),
"${duration.toInt()}", child: Image.network(
"Hours", displayShift.logoUrl!,
), fit: BoxFit.cover,
), ),
], )
), : const Center(
const SizedBox(height: 24), child: Icon(
UiIcons.briefcase,
// In/Out Time color: UiColors.primary,
Row( size: 20,
children: [ ),
Expanded(
child: _buildTimeBox(
"CLOCK IN TIME",
displayShift.startTime,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildTimeBox(
"CLOCK OUT TIME",
displayShift.endTime,
),
),
],
),
const SizedBox(height: 24),
// Location
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"LOCATION",
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary,
letterSpacing: 0.5,
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
displayShift.location.isEmpty
? "TBD"
: displayShift.location,
style: UiTypography.title1m.copyWith(
color: UiColors.textPrimary,
),
),
OutlinedButton.icon(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(displayShift!.locationAddress),
duration: const Duration(seconds: 3),
), ),
);
},
icon: const Icon(UiIcons.navigation, size: 14),
label: const Text(
"Get direction",
style: TextStyle(fontSize: 12),
),
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.textPrimary,
side: const BorderSide(color: UiColors.border),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
minimumSize: const Size(0, 32),
),
),
],
),
const SizedBox(height: 12),
Container(
height: 128,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
), ),
child: const Center( const SizedBox(width: 8),
child: Icon( Text(
UiIcons.mapPin, displayShift.clientName,
color: UiColors.iconSecondary, style: UiTypography.headline5m.copyWith(
size: 32, color: UiColors.textPrimary,
), ),
), ),
// Placeholder for Map ],
), ),
], ],
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Additional Info // Date Section
if (displayShift.description != null) ...[ Column(
SizedBox( crossAxisAlignment: CrossAxisAlignment.start,
width: double.infinity, children: [
child: Column( const Text(
crossAxisAlignment: CrossAxisAlignment.start, "SHIFT DATE",
children: [ style: TextStyle(
const Text( fontSize: 10,
"ADDITIONAL INFO", fontWeight: FontWeight.bold,
style: TextStyle( color: UiColors.textSecondary,
fontSize: 10, letterSpacing: 0.5,
fontWeight: FontWeight.bold, ),
color: UiColors.textSecondary, ),
letterSpacing: 0.5, const SizedBox(height: 8),
), Row(
children: [
const Icon(
UiIcons.calendar,
size: 20,
color: UiColors.primary,
),
const SizedBox(width: 8),
Text(
_formatDate(displayShift.date),
style: UiTypography.headline5m.copyWith(
color: UiColors.textPrimary,
), ),
const SizedBox(height: 8), ),
Text( ],
displayShift.description!, ),
style: UiTypography.body2m.copyWith( ],
color: UiColors.textPrimary, ),
), const SizedBox(height: 24),
// Worker Capacity / Open Slots
if ((displayShift.requiredSlots ?? 0) > 0)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF0FDF4), // green-50
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFFBBF7D0),
), // green-200
),
child: Row(
children: [
const Icon(
Icons.people_alt_outlined,
size: 20,
color: Color(0xFF15803D),
), // green-700, using Material Icon as generic fallback
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
"$openSlots spots remaining",
style: UiTypography.body2b.copyWith(
color: const Color(0xFF15803D),
),
),
Text(
"${displayShift.filledSlots ?? 0} filled out of ${displayShift.requiredSlots}",
style: UiTypography.body3r.copyWith(
color: const Color(0xFF166534),
),
),
],
), ),
], ),
SizedBox(
width: 60,
child: LinearProgressIndicator(
value: (displayShift.requiredSlots! > 0)
? (displayShift.filledSlots ?? 0) /
displayShift.requiredSlots!
: 0,
backgroundColor: Colors.white,
color: const Color(0xFF15803D),
minHeight: 6,
borderRadius: BorderRadius.circular(3),
),
),
],
),
),
const SizedBox(height: 24),
// Stats Grid
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 3,
crossAxisSpacing: 12,
childAspectRatio: 0.85,
children: [
_buildStatCard(
UiIcons.dollar,
"\$${estimatedTotal.toStringAsFixed(0)}",
"Total Pay",
),
_buildStatCard(
UiIcons.dollar,
"\$${displayShift.hourlyRate.toInt()}",
"Per Hour",
),
_buildStatCard(
UiIcons.clock,
"${duration.toInt()}h",
"Duration",
),
],
),
const SizedBox(height: 24),
// Shift Timing
Row(
children: [
Expanded(
child: _buildTimeBox(
"START TIME",
displayShift.startTime,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildTimeBox(
"END TIME",
displayShift.endTime,
), ),
), ),
], ],
),
const SizedBox(height: 24),
// Location
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"LOCATION",
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary,
letterSpacing: 0.5,
),
),
const SizedBox(height: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayShift.location.isEmpty
? "TBD"
: displayShift.location,
style: UiTypography.title1m.copyWith(
color: UiColors.textPrimary,
),
),
Text(
displayShift.location.isEmpty
? "TBD"
: displayShift.locationAddress,
style: UiTypography.title1m.copyWith(
color: UiColors.textPrimary,
),
),
],
),
],
),
const SizedBox(height: 24),
// Additional Info
if (displayShift.description != null) ...[
SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"ADDITIONAL INFO",
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary,
letterSpacing: 0.5,
),
),
const SizedBox(height: 8),
Text(
displayShift.description!,
style: UiTypography.body2m.copyWith(
color: UiColors.textPrimary,
),
),
],
),
),
], ],
), const SizedBox(height: 20),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () =>
_declineShift(context, displayShift!.id),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFEF4444),
side: const BorderSide(
color: Color(0xFFEF4444),
),
padding: const EdgeInsets.symmetric(
vertical: 16,
),
),
child: const Text("Decline"),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: () =>
_bookShift(context, displayShift!.id),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF10B981),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
vertical: 16,
),
),
child: const Text("Book Shift"),
),
),
],
),
SizedBox(
height: MediaQuery.of(context).padding.bottom + 10,
),
],
), ),
), ),
const SizedBox(height: 20), ),
Row( ],
children: [
Expanded(
child: OutlinedButton(
onPressed: () => _declineShift(context, displayShift!.id),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFEF4444),
side: const BorderSide(color: Color(0xFFEF4444)),
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text("Decline"),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: () => _bookShift(context, displayShift!.id),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF10B981),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: const Text("Book Shift"),
),
),
],
),
SizedBox(height: MediaQuery.of(context).padding.bottom + 10),
],
),
), ),
); );
}, },
@@ -392,7 +503,9 @@ class ShiftDetailsPage extends StatelessWidget {
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(ctx).pop(); Navigator.of(ctx).pop();
BlocProvider.of<ShiftDetailsBloc>(context).add(BookShiftDetailsEvent(id)); BlocProvider.of<ShiftDetailsBloc>(
context,
).add(BookShiftDetailsEvent(id));
}, },
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: const Color(0xFF10B981), foregroundColor: const Color(0xFF10B981),
@@ -410,7 +523,8 @@ class ShiftDetailsPage extends StatelessWidget {
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Decline Shift'), title: const Text('Decline Shift'),
content: const Text( content: const Text(
'Are you sure you want to decline this shift? It will be hidden from your available jobs.'), 'Are you sure you want to decline this shift? It will be hidden from your available jobs.',
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(ctx).pop(), onPressed: () => Navigator.of(ctx).pop(),
@@ -419,7 +533,9 @@ class ShiftDetailsPage extends StatelessWidget {
TextButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(ctx).pop(); Navigator.of(ctx).pop();
BlocProvider.of<ShiftDetailsBloc>(context).add(DeclineShiftDetailsEvent(id)); BlocProvider.of<ShiftDetailsBloc>(
context,
).add(DeclineShiftDetailsEvent(id));
}, },
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: const Color(0xFFEF4444), foregroundColor: const Color(0xFFEF4444),