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
+21 -2
View File
@@ -78,6 +78,7 @@ class PassSlipController {
Future<void> requestSlip({
required String dutyScheduleId,
required String reason,
DateTime? requestedStart,
}) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
@@ -87,6 +88,8 @@ class PassSlipController {
'reason': reason,
'status': 'pending',
'requested_at': DateTime.now().toUtc().toIso8601String(),
if (requestedStart != null)
'requested_start': requestedStart.toUtc().toIso8601String(),
};
final insertedRaw = await _client
@@ -174,13 +177,29 @@ class PassSlipController {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
// Determine slip start time based on requested_start
final nowUtc = DateTime.now().toUtc();
String slipStartIso = nowUtc.toIso8601String();
final row = await _client
.from('pass_slips')
.select('requested_start')
.eq('id', slipId)
.maybeSingle();
if (row != null && row['requested_start'] != null) {
final requestedStart = DateTime.parse(row['requested_start'] as String);
if (requestedStart.isAfter(nowUtc)) {
slipStartIso = requestedStart.toIso8601String();
}
}
await _client
.from('pass_slips')
.update({
'status': 'approved',
'approved_by': userId,
'approved_at': DateTime.now().toUtc().toIso8601String(),
'slip_start': DateTime.now().toUtc().toIso8601String(),
'approved_at': nowUtc.toIso8601String(),
'slip_start': slipStartIso,
})
.eq('id', slipId);
+5
View File
@@ -344,6 +344,11 @@ class StreamRecoveryWrapper<T> {
}
}
/// Immediately fetch fresh data via REST without restarting the realtime
/// subscription. Use this as a periodic safety net for missed realtime events
/// (e.g., when the table is not yet in the supabase_realtime publication).
Future<void> pollNow() async => _pollOnce();
/// Manually trigger a recovery attempt.
void retry() {
_recoveryAttempts = 0;
+47 -1
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
@@ -53,6 +55,20 @@ final dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) {
);
ref.onDispose(wrapper.dispose);
// Immediate poll so any changes that happened while this provider was
// not alive (e.g. a swap was accepted on another device) are reflected
// right away — before the 3-second periodic timer fires.
wrapper.pollNow();
// Periodic safety-net: keep polling every 3 s so that ownership changes
// (swap accepted → user_id updated on duty_schedules) are always picked
// up even if Supabase Realtime misses the event.
final dutyRefreshTimer = Timer.periodic(const Duration(seconds: 3), (_) {
wrapper.pollNow();
});
ref.onDispose(dutyRefreshTimer.cancel);
return wrapper.stream.map((result) => result.data);
});
@@ -127,6 +143,7 @@ final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) {
final data = await client
.from('swap_requests')
.select()
.inFilter('status', ['pending', 'admin_review'])
.order('created_at', ascending: false);
return data.map(SwapRequest.fromMap).toList();
},
@@ -136,13 +153,28 @@ final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) {
);
ref.onDispose(wrapper.dispose);
// Immediate poll: fetch fresh data right away so any status changes that
// happened while this provider was not alive are reflected instantly,
// before the 3-second periodic timer fires for the first time.
wrapper.pollNow();
// Periodic safety-net: keep polling every 3 s to catch any status changes
// that Supabase Realtime may have missed (e.g. when the swap_requests table
// is not yet in the supabase_realtime publication).
final refreshTimer = Timer.periodic(const Duration(seconds: 3), (_) {
wrapper.pollNow();
});
ref.onDispose(refreshTimer.cancel);
return wrapper.stream.map((result) {
// only return requests that are still actionable; once a swap has been
// accepted or rejected we no longer need to bubble it up to the UI for
// either party. admins still see "admin_review" rows so they can act on
// escalated cases.
return result.data.where((row) {
if (!(row.requesterId == profileId || row.recipientId == profileId)) {
// admins see all swaps; standard users only see swaps they're in
if (!isAdmin && !(row.requesterId == profileId || row.recipientId == profileId)) {
return false;
}
// only keep pending and admin_review statuses
@@ -151,6 +183,20 @@ final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) {
});
});
/// IDs of swap requests that were acted on locally (accepted, rejected, etc.).
/// Kept as a global provider so the set survives tab switches — widget state
/// is disposed when the user navigates away from My Schedule.
final locallyRemovedSwapIdsProvider = StateProvider<Set<String>>((ref) => {});
/// IDs of duty_schedules owned by the current user that were created by an accepted swap.
final swappedScheduleIdsProvider = Provider<Set<String>>((ref) {
final schedules = ref.watch(dutySchedulesProvider).valueOrNull ?? [];
return {
for (final s in schedules)
if (s.swapRequestId != null) s.id,
};
});
final workforceControllerProvider = Provider<WorkforceController>((ref) {
final client = ref.watch(supabaseClientProvider);
return WorkforceController(client);