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,72 +165,205 @@ 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),
child: Column(
children: [ children: [
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
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 Text(
"VENDOR",
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary,
letterSpacing: 0.5,
), ),
const SizedBox(height: 24), ),
const SizedBox(height: 8),
// Stats Row
Row( Row(
children: [ children: [
Expanded( Container(
child: _buildStatCard( width: 24,
UiIcons.dollar, height: 24,
"\$${estimatedTotal.toStringAsFixed(0)}", child: displayShift.logoUrl != null
"Total", ? ClipRRect(
borderRadius: BorderRadius.circular(
6,
),
child: Image.network(
displayShift.logoUrl!,
fit: BoxFit.cover,
),
)
: const Center(
child: Icon(
UiIcons.briefcase,
color: UiColors.primary,
size: 20,
), ),
), ),
const SizedBox(width: 12), ),
Expanded( const SizedBox(width: 8),
child: _buildStatCard( Text(
UiIcons.dollar, displayShift.clientName,
"\$${displayShift.hourlyRate.toInt()}", style: UiTypography.headline5m.copyWith(
"Hourly Rate", color: UiColors.textPrimary,
), ),
), ),
const SizedBox(width: 12), ],
Expanded(
child: _buildStatCard(
UiIcons.clock,
"${duration.toInt()}",
"Hours",
),
), ),
], ],
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// In/Out Time // Date Section
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"SHIFT DATE",
style: TextStyle(
fontSize: 10,
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: 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( Row(
children: [ children: [
Expanded( Expanded(
child: _buildTimeBox( child: _buildTimeBox(
"CLOCK IN TIME", "START TIME",
displayShift.startTime, displayShift.startTime,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: _buildTimeBox( child: _buildTimeBox(
"CLOCK OUT TIME", "END TIME",
displayShift.endTime, displayShift.endTime,
), ),
), ),
@@ -250,8 +385,8 @@ class ShiftDetailsPage extends StatelessWidget {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
displayShift.location.isEmpty displayShift.location.isEmpty
@@ -261,49 +396,16 @@ class ShiftDetailsPage extends StatelessWidget {
color: UiColors.textPrimary, color: UiColors.textPrimary,
), ),
), ),
OutlinedButton.icon( Text(
onPressed: () { displayShift.location.isEmpty
ScaffoldMessenger.of(context).showSnackBar( ? "TBD"
SnackBar( : displayShift.locationAddress,
content: Text(displayShift!.locationAddress), style: UiTypography.title1m.copyWith(
duration: const Duration(seconds: 3), color: UiColors.textPrimary,
),
);
},
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(
child: Icon(
UiIcons.mapPin,
color: UiColors.iconSecondary,
size: 32,
),
),
// Placeholder for Map
),
], ],
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -335,20 +437,21 @@ class ShiftDetailsPage extends StatelessWidget {
), ),
), ),
], ],
],
),
),
),
const SizedBox(height: 20), const SizedBox(height: 20),
Row( Row(
children: [ children: [
Expanded( Expanded(
child: OutlinedButton( child: OutlinedButton(
onPressed: () => _declineShift(context, displayShift!.id), onPressed: () =>
_declineShift(context, displayShift!.id),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFEF4444), foregroundColor: const Color(0xFFEF4444),
side: const BorderSide(color: Color(0xFFEF4444)), side: const BorderSide(
padding: const EdgeInsets.symmetric(vertical: 16), color: Color(0xFFEF4444),
),
padding: const EdgeInsets.symmetric(
vertical: 16,
),
), ),
child: const Text("Decline"), child: const Text("Decline"),
), ),
@@ -356,21 +459,29 @@ class ShiftDetailsPage extends StatelessWidget {
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: ElevatedButton( child: ElevatedButton(
onPressed: () => _bookShift(context, displayShift!.id), onPressed: () =>
_bookShift(context, displayShift!.id),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF10B981), backgroundColor: const Color(0xFF10B981),
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(
vertical: 16,
),
), ),
child: const Text("Book Shift"), child: const Text("Book Shift"),
), ),
), ),
], ],
), ),
SizedBox(height: MediaQuery.of(context).padding.bottom + 10), 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),