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

View File

@@ -127,6 +127,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
status: _mapStatus(status),
description: shift.description,
durationDays: shift.durationDays,
requiredSlots: shift.requiredSlots,
filledSlots: shift.filledSlots,
));
}
}
@@ -182,6 +184,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
status: s.status?.stringValue.toLowerCase() ?? 'open',
description: s.description,
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;
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 endDt = _toDateTime(s.endTime);
final createdDt = _toDateTime(s.createdAt);
@@ -229,6 +247,8 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
status: s.status?.stringValue ?? 'OPEN',
description: s.description,
durationDays: s.durationDays,
requiredSlots: required,
filledSlots: filled,
);
} catch (e) {
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_event.dart';
import '../blocs/shift_details/shift_details_state.dart';
import '../styles/shifts_styles.dart';
import '../widgets/my_shift_card.dart';
class ShiftDetailsPage extends StatelessWidget {
final String shiftId;
final Shift? shift;
const ShiftDetailsPage({
super.key,
required this.shiftId,
this.shift,
});
const ShiftDetailsPage({super.key, required this.shiftId, this.shift});
String _formatTime(String time) {
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) {
if (shift.startTime.isEmpty || shift.endTime.isEmpty) {
return 0;
@@ -70,9 +74,7 @@ class ShiftDetailsPage extends StatelessWidget {
const SizedBox(height: 8),
Text(
value,
style: UiTypography.title1m.copyWith(
color: UiColors.textPrimary,
),
style: UiTypography.title1m.copyWith(color: UiColors.textPrimary),
),
Text(
label,
@@ -118,9 +120,9 @@ class ShiftDetailsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => Modular.get<ShiftDetailsBloc>()
..add(LoadShiftDetailsEvent(shiftId)),
return BlocProvider<ShiftDetailsBloc>(
create: (_) =>
Modular.get<ShiftDetailsBloc>()..add(LoadShiftDetailsEvent(shiftId)),
child: BlocListener<ShiftDetailsBloc, ShiftDetailsState>(
listener: (context, state) {
if (state is ShiftActionSuccess) {
@@ -163,72 +165,205 @@ class ShiftDetailsPage extends StatelessWidget {
final duration = _calculateDuration(displayShift);
final estimatedTotal = (displayShift.hourlyRate) * duration;
final openSlots =
(displayShift.requiredSlots ?? 0) -
(displayShift.filledSlots ?? 0);
return Scaffold(
backgroundColor: AppColors.krowBackground,
appBar: AppBar(
title: const Text("Shift Details"),
backgroundColor: Colors.white,
foregroundColor: AppColors.krowCharcoal,
elevation: 0.5,
appBar: UiAppBar(
title: displayShift.title,
showBackButton: true,
centerTitle: false,
),
body: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
body: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyShiftCard(
shift: displayShift,
// No direct actions on the card, handled by page buttons
// Vendor Section
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"VENDOR",
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary,
letterSpacing: 0.5,
),
const SizedBox(height: 24),
// Stats Row
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _buildStatCard(
UiIcons.dollar,
"\$${estimatedTotal.toStringAsFixed(0)}",
"Total",
Container(
width: 24,
height: 24,
child: displayShift.logoUrl != null
? 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(
child: _buildStatCard(
UiIcons.dollar,
"\$${displayShift.hourlyRate.toInt()}",
"Hourly Rate",
),
const SizedBox(width: 8),
Text(
displayShift.clientName,
style: UiTypography.headline5m.copyWith(
color: UiColors.textPrimary,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildStatCard(
UiIcons.clock,
"${duration.toInt()}",
"Hours",
),
],
),
],
),
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(
children: [
Expanded(
child: _buildTimeBox(
"CLOCK IN TIME",
"START TIME",
displayShift.startTime,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildTimeBox(
"CLOCK OUT TIME",
"END TIME",
displayShift.endTime,
),
),
@@ -250,8 +385,8 @@ class ShiftDetailsPage extends StatelessWidget {
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayShift.location.isEmpty
@@ -261,49 +396,16 @@ class ShiftDetailsPage extends StatelessWidget {
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),
Text(
displayShift.location.isEmpty
? "TBD"
: displayShift.locationAddress,
style: UiTypography.title1m.copyWith(
color: UiColors.textPrimary,
),
),
],
),
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),
@@ -335,20 +437,21 @@ class ShiftDetailsPage extends StatelessWidget {
),
),
],
],
),
),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => _declineShift(context, displayShift!.id),
onPressed: () =>
_declineShift(context, displayShift!.id),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFEF4444),
side: const BorderSide(color: Color(0xFFEF4444)),
padding: const EdgeInsets.symmetric(vertical: 16),
side: const BorderSide(
color: Color(0xFFEF4444),
),
padding: const EdgeInsets.symmetric(
vertical: 16,
),
),
child: const Text("Decline"),
),
@@ -356,21 +459,29 @@ class ShiftDetailsPage extends StatelessWidget {
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: () => _bookShift(context, displayShift!.id),
onPressed: () =>
_bookShift(context, displayShift!.id),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF10B981),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
padding: const EdgeInsets.symmetric(
vertical: 16,
),
),
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(
onPressed: () {
Navigator.of(ctx).pop();
BlocProvider.of<ShiftDetailsBloc>(context).add(BookShiftDetailsEvent(id));
BlocProvider.of<ShiftDetailsBloc>(
context,
).add(BookShiftDetailsEvent(id));
},
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF10B981),
@@ -410,7 +523,8 @@ class ShiftDetailsPage extends StatelessWidget {
builder: (ctx) => AlertDialog(
title: const Text('Decline Shift'),
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: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
@@ -419,7 +533,9 @@ class ShiftDetailsPage extends StatelessWidget {
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
BlocProvider.of<ShiftDetailsBloc>(context).add(DeclineShiftDetailsEvent(id));
BlocProvider.of<ShiftDetailsBloc>(
context,
).add(DeclineShiftDetailsEvent(id));
},
style: TextButton.styleFrom(
foregroundColor: const Color(0xFFEF4444),