Added My Schedule tab in attendance screen
Allow 1 Day, Whole Week and Date Range swapping
This commit is contained in:
@@ -12,6 +12,7 @@ class DutySchedule {
|
||||
required this.checkInAt,
|
||||
required this.checkInLocation,
|
||||
required this.relieverIds,
|
||||
this.swapRequestId,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -24,6 +25,7 @@ class DutySchedule {
|
||||
final DateTime? checkInAt;
|
||||
final Object? checkInLocation;
|
||||
final List<String> relieverIds;
|
||||
final String? swapRequestId;
|
||||
|
||||
factory DutySchedule.fromMap(Map<String, dynamic> map) {
|
||||
final relieversRaw = map['reliever_ids'];
|
||||
@@ -47,6 +49,7 @@ class DutySchedule {
|
||||
: AppTime.parse(map['check_in_at'] as String),
|
||||
checkInLocation: map['check_in_location'],
|
||||
relieverIds: relievers,
|
||||
swapRequestId: map['swap_request_id'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ class PassSlip {
|
||||
this.approvedAt,
|
||||
this.slipStart,
|
||||
this.slipEnd,
|
||||
this.requestedStart,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -24,6 +25,7 @@ class PassSlip {
|
||||
final DateTime? approvedAt;
|
||||
final DateTime? slipStart;
|
||||
final DateTime? slipEnd;
|
||||
final DateTime? requestedStart;
|
||||
|
||||
/// Whether the slip is active (approved but not yet completed).
|
||||
bool get isActive => status == 'approved' && slipEnd == null;
|
||||
@@ -52,6 +54,9 @@ class PassSlip {
|
||||
slipEnd: map['slip_end'] == null
|
||||
? null
|
||||
: AppTime.parse(map['slip_end'] as String),
|
||||
requestedStart: map['requested_start'] == null
|
||||
? null
|
||||
: AppTime.parse(map['requested_start'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -34,6 +34,17 @@ import '../theme/m3_motion.dart';
|
||||
|
||||
import '../utils/navigation.dart';
|
||||
|
||||
String _defaultRouteForRole(String? role) {
|
||||
switch (role) {
|
||||
case 'it_staff':
|
||||
return '/tasks';
|
||||
case 'standard':
|
||||
return '/tickets';
|
||||
default:
|
||||
return '/dashboard';
|
||||
}
|
||||
}
|
||||
|
||||
final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
final notifier = RouterNotifier(ref);
|
||||
ref.onDispose(notifier.dispose);
|
||||
@@ -71,7 +82,19 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
return '/login';
|
||||
}
|
||||
if (isSignedIn && isAuthRoute) {
|
||||
return '/dashboard';
|
||||
// If role already loaded, redirect directly and clear any pending flag
|
||||
if (role != null) notifier._needsRoleRedirect = false;
|
||||
return _defaultRouteForRole(role);
|
||||
}
|
||||
// Deferred post-login redirect: profile loaded after the initial redirect
|
||||
// (which fell back to /dashboard because role was null). Only fires once
|
||||
// per sign-in and only when the user is still on /dashboard.
|
||||
if (isSignedIn &&
|
||||
notifier._needsRoleRedirect &&
|
||||
role != null &&
|
||||
state.matchedLocation == '/dashboard') {
|
||||
notifier._needsRoleRedirect = false;
|
||||
return _defaultRouteForRole(role);
|
||||
}
|
||||
if (isAdminRoute && !isAdmin) {
|
||||
return '/tickets';
|
||||
@@ -274,8 +297,9 @@ class RouterNotifier extends ChangeNotifier {
|
||||
? previous.value?.session
|
||||
: null;
|
||||
if (session != null && previousSession == null) {
|
||||
// User just signed in; enforce lock check
|
||||
// User just signed in; enforce lock check and flag for role-based redirect
|
||||
_enforceLockAsync();
|
||||
_needsRoleRedirect = true;
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
@@ -290,6 +314,10 @@ class RouterNotifier extends ChangeNotifier {
|
||||
late final ProviderSubscription _profileSub;
|
||||
bool _lockEnforcementInProgress = false;
|
||||
|
||||
/// Set on new sign-in. Consumed by the redirect function to send the user
|
||||
/// to their role-appropriate landing page once the profile has loaded.
|
||||
bool _needsRoleRedirect = false;
|
||||
|
||||
/// Safely enforce lock in the background, preventing concurrent calls
|
||||
void _enforceLockAsync() {
|
||||
// Prevent concurrent enforcement calls
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -61,6 +61,21 @@ class AppTime {
|
||||
/// Renders a [DateTime] in 12‑hour clock notation with AM/PM suffix.
|
||||
///
|
||||
/// Example: **08:30 PM**. Used primarily in workforce-related screens.
|
||||
/// Creates a [DateTime] in the app timezone (Asia/Manila) from date/time components.
|
||||
///
|
||||
/// Use this instead of [DateTime] constructor when building a Manila-aware
|
||||
/// timestamp from separate year/month/day/hour/minute values so that
|
||||
/// `.toUtc()` correctly accounts for the +08:00 offset.
|
||||
static DateTime fromComponents({
|
||||
required int year,
|
||||
required int month,
|
||||
required int day,
|
||||
int hour = 0,
|
||||
int minute = 0,
|
||||
}) {
|
||||
return tz.TZDateTime(tz.local, year, month, day, hour, minute);
|
||||
}
|
||||
|
||||
static String formatTime(DateTime value) {
|
||||
final rawHour = value.hour;
|
||||
final hour = (rawHour % 12 == 0 ? 12 : rawHour % 12).toString().padLeft(
|
||||
|
||||
@@ -104,20 +104,37 @@ class _PassSlipCountdownBannerState
|
||||
return widget.child;
|
||||
}
|
||||
|
||||
final isUrgent = !_exceeded && _remaining.inMinutes < 5;
|
||||
final bgColor = _exceeded || isUrgent
|
||||
? Theme.of(context).colorScheme.errorContainer
|
||||
: Theme.of(context).colorScheme.tertiaryContainer;
|
||||
final fgColor = _exceeded || isUrgent
|
||||
? Theme.of(context).colorScheme.onErrorContainer
|
||||
: Theme.of(context).colorScheme.onTertiaryContainer;
|
||||
final now = DateTime.now();
|
||||
final hasStarted = now.isAfter(activeSlip.slipStart!) ||
|
||||
now.isAtSameMomentAs(activeSlip.slipStart!);
|
||||
|
||||
final bool isUrgent;
|
||||
final Color bgColor;
|
||||
final Color fgColor;
|
||||
final String message;
|
||||
final IconData icon;
|
||||
|
||||
if (_exceeded) {
|
||||
isUrgent = true;
|
||||
bgColor = Theme.of(context).colorScheme.errorContainer;
|
||||
fgColor = Theme.of(context).colorScheme.onErrorContainer;
|
||||
message = 'Pass slip time EXCEEDED — Please return and complete it';
|
||||
icon = Icons.warning_amber_rounded;
|
||||
} else if (!hasStarted) {
|
||||
isUrgent = false;
|
||||
bgColor = Theme.of(context).colorScheme.primaryContainer;
|
||||
fgColor = Theme.of(context).colorScheme.onPrimaryContainer;
|
||||
final untilStart = activeSlip.slipStart!.difference(now);
|
||||
message = 'Pass slip starts in ${_formatDuration(untilStart)}';
|
||||
icon = Icons.schedule_rounded;
|
||||
} else {
|
||||
isUrgent = !_exceeded && _remaining.inMinutes < 5;
|
||||
bgColor = isUrgent
|
||||
? Theme.of(context).colorScheme.errorContainer
|
||||
: Theme.of(context).colorScheme.tertiaryContainer;
|
||||
fgColor = isUrgent
|
||||
? Theme.of(context).colorScheme.onErrorContainer
|
||||
: Theme.of(context).colorScheme.onTertiaryContainer;
|
||||
message = 'Pass slip expires in ${_formatDuration(_remaining)}';
|
||||
icon = Icons.directions_walk_rounded;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user