Added My Schedule tab in attendance screen

Allow 1 Day, Whole Week and Date Range swapping
This commit is contained in:
2026-03-22 11:52:25 +08:00
parent ba155885c0
commit 049ab2c794
17 changed files with 2515 additions and 305 deletions
File diff suppressed because it is too large Load Diff
+46 -228
View File
@@ -14,6 +14,7 @@ import '../../providers/rotation_config_provider.dart';
import '../../providers/workforce_provider.dart';
import '../../providers/chat_provider.dart';
import '../../providers/ramadan_provider.dart';
import '../../providers/notifications_provider.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/responsive_body.dart';
@@ -49,6 +50,9 @@ class WorkforceScreen extends ConsumerWidget {
);
if (isWide) {
if (!isAdmin) {
return schedulePanel;
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -58,8 +62,8 @@ class WorkforceScreen extends ConsumerWidget {
flex: 2,
child: Column(
children: [
if (isAdmin) generatorPanel,
if (isAdmin) const SizedBox(height: 16),
generatorPanel,
const SizedBox(height: 16),
Expanded(child: swapsPanel),
],
),
@@ -68,16 +72,19 @@ class WorkforceScreen extends ConsumerWidget {
);
}
if (!isAdmin) {
return schedulePanel;
}
return DefaultTabController(
length: isAdmin ? 3 : 2,
length: 3,
child: Column(
children: [
const SizedBox(height: 8),
TabBar(
const TabBar(
tabs: [
const Tab(text: 'Schedule'),
const Tab(text: 'Swaps'),
if (isAdmin) const Tab(text: 'Generator'),
Tab(text: 'Schedule'),
Tab(text: 'Swaps'),
Tab(text: 'Generator'),
],
),
const SizedBox(height: 8),
@@ -86,7 +93,7 @@ class WorkforceScreen extends ConsumerWidget {
children: [
schedulePanel,
swapsPanel,
if (isAdmin) generatorPanel,
generatorPanel,
],
),
),
@@ -314,23 +321,6 @@ class _ScheduleTile extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentUserId = ref.watch(currentUserIdProvider);
// Use .select() so this tile only rebuilds when its own swap status changes,
// not every time any swap in the list is updated.
final hasRequestedSwap = ref.watch(
swapRequestsProvider.select(
(async) => (async.valueOrNull ?? const []).any(
(swap) =>
swap.requesterScheduleId == schedule.id &&
swap.requesterId == currentUserId &&
swap.status == 'pending',
),
),
);
final now = AppTime.now();
final isPast = schedule.startTime.isBefore(now);
final canRequestSwap = isMine && schedule.status != 'absent' && !isPast;
final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull;
ShiftTypeConfig? shiftTypeConfig;
@@ -442,16 +432,6 @@ class _ScheduleTile extends ConsumerWidget {
onPressed: () => _editSchedule(context, ref),
icon: const Icon(Icons.edit, size: 20),
),
if (canRequestSwap)
OutlinedButton.icon(
onPressed: hasRequestedSwap
? () => _openSwapsTab(context)
: () => _requestSwap(context, ref, schedule),
icon: const Icon(Icons.swap_horiz),
label: Text(
hasRequestedSwap ? 'Swap Requested' : 'Request swap',
),
),
],
),
],
@@ -701,200 +681,8 @@ class _ScheduleTile extends ConsumerWidget {
}
}
Future<void> _requestSwap(
BuildContext context,
WidgetRef ref,
DutySchedule schedule,
) async {
final profiles = ref.read(profilesProvider).valueOrNull ?? [];
final currentUserId = ref.read(currentUserIdProvider);
final staff = profiles
.where((profile) => profile.role == 'it_staff')
.where((profile) => profile.id != currentUserId)
.toList();
if (staff.isEmpty) {
_showMessage(
context,
'No IT staff available for swaps.',
type: SnackType.warning,
);
return;
}
String? selectedId = staff.first.id;
List<DutySchedule> recipientShifts = [];
String? selectedTargetShiftId;
final confirmed = await m3ShowDialog<bool>(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (context, setState) {
// initial load for the first recipient shown — only upcoming shifts
if (recipientShifts.isEmpty && selectedId != null) {
ref
.read(dutySchedulesForUserProvider(selectedId!).future)
.then((shifts) {
final now = AppTime.now();
final upcoming =
shifts.where((s) => !s.startTime.isBefore(now)).toList()
..sort((a, b) => a.startTime.compareTo(b.startTime));
setState(() {
recipientShifts = upcoming;
selectedTargetShiftId = upcoming.isNotEmpty
? upcoming.first.id
: null;
});
})
.catchError((_) {});
}
return AlertDialog(
shape: AppSurfaces.of(context).dialogShape,
title: const Text('Request swap'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<String>(
initialValue: selectedId,
items: [
for (final profile in staff)
DropdownMenuItem(
value: profile.id,
child: Text(
profile.fullName.isNotEmpty
? profile.fullName
: profile.id,
),
),
],
onChanged: (value) async {
if (value == null) return;
setState(() => selectedId = value);
// load recipient shifts (only show upcoming)
final shifts = await ref
.read(dutySchedulesForUserProvider(value).future)
.catchError((_) => <DutySchedule>[]);
final now = AppTime.now();
final upcoming =
shifts
.where((s) => !s.startTime.isBefore(now))
.toList()
..sort(
(a, b) => a.startTime.compareTo(b.startTime),
);
setState(() {
recipientShifts = upcoming;
selectedTargetShiftId = upcoming.isNotEmpty
? upcoming.first.id
: null;
});
},
decoration: const InputDecoration(labelText: 'Recipient'),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: selectedTargetShiftId,
items: [
for (final s in recipientShifts)
DropdownMenuItem(
value: s.id,
child: Text(
'${s.shiftType == 'am'
? 'AM Duty'
: s.shiftType == 'pm'
? 'PM Duty'
: s.shiftType} · ${AppTime.formatDate(s.startTime)} · ${AppTime.formatTime(s.startTime)}',
),
),
],
onChanged: (value) =>
setState(() => selectedTargetShiftId = value),
decoration: const InputDecoration(
labelText: 'Recipient shift',
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Send request'),
),
],
);
},
);
},
);
if (!context.mounted) {
return;
}
if (confirmed != true ||
selectedId == null ||
selectedTargetShiftId == null) {
return;
}
try {
await ref
.read(workforceControllerProvider)
.requestSwap(
requesterScheduleId: schedule.id,
targetScheduleId: selectedTargetShiftId!,
recipientId: selectedId!,
);
ref.invalidate(swapRequestsProvider);
if (!context.mounted) return;
_showMessage(context, 'Swap request sent.', type: SnackType.success);
} catch (error) {
if (!context.mounted) return;
_showMessage(
context,
'Swap request failed: $error',
type: SnackType.error,
);
}
}
void _showMessage(
BuildContext context,
String message, {
SnackType type = SnackType.warning,
}) {
switch (type) {
case SnackType.success:
showSuccessSnackBar(context, message);
break;
case SnackType.error:
showErrorSnackBar(context, message);
break;
case SnackType.info:
showInfoSnackBar(context, message);
break;
case SnackType.warning:
showWarningSnackBar(context, message);
break;
}
}
void _openSwapsTab(BuildContext context) {
final controller = DefaultTabController.maybeOf(context);
if (controller != null) {
controller.animateTo(1);
return;
}
_showMessage(
context,
'Swap request already sent. See Swaps panel.',
type: SnackType.info,
);
}
String _statusLabel(String status) {
switch (status) {
@@ -2347,6 +2135,7 @@ class _SwapRequestsPanel extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final swapsAsync = ref.watch(swapRequestsProvider);
final removedSwapIds = ref.watch(locallyRemovedSwapIdsProvider);
final schedulesAsync = ref.watch(dutySchedulesProvider);
final profilesAsync = ref.watch(profilesProvider);
final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull;
@@ -2362,7 +2151,12 @@ class _SwapRequestsPanel extends ConsumerWidget {
};
return swapsAsync.when(
data: (items) {
data: (allItems) {
// Immediately exclude locally acted-on swap IDs while waiting for the
// stream to catch up (avoids stale cards flashing back after invalidation).
final items = allItems
.where((s) => !removedSwapIds.contains(s.id))
.toList();
if (items.isEmpty) {
return const Center(child: Text('No swap requests.'));
}
@@ -2534,7 +2328,31 @@ class _SwapRequestsPanel extends ConsumerWidget {
await ref
.read(workforceControllerProvider)
.respondSwap(swapId: request.id, action: action);
ref.read(locallyRemovedSwapIdsProvider.notifier).update((s) => {...s, request.id});
ref.invalidate(swapRequestsProvider);
ref.invalidate(dutySchedulesProvider);
// Send push notifications with date context
final notificationsController = ref.read(notificationsControllerProvider);
final shiftDate = request.shiftStartTime != null
? AppTime.formatDate(request.shiftStartTime!)
: 'the shift';
if (action == 'accepted') {
await notificationsController.sendPush(
userIds: [request.requesterId, request.recipientId],
title: 'Swap approved',
body: 'An admin approved the swap for $shiftDate.',
data: {'type': 'swap_update', 'navigate_to': '/attendance'},
);
} else if (action == 'rejected') {
await notificationsController.sendPush(
userIds: [request.requesterId],
title: 'Swap rejected by admin',
body: 'An admin rejected your swap request for $shiftDate.',
data: {'type': 'swap_update', 'navigate_to': '/attendance'},
);
}
}
Future<void> _changeRecipient(