tasq/lib/screens/attendance/attendance_screen.dart

4587 lines
151 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../models/attendance_log.dart';
import '../../models/duty_schedule.dart';
import '../../models/leave_of_absence.dart';
import '../../models/pass_slip.dart';
import '../../models/profile.dart';
import '../../providers/attendance_provider.dart';
import '../../providers/debug_settings_provider.dart';
import '../../providers/leave_provider.dart';
import '../../screens/dashboard/dashboard_screen.dart';
import '../../providers/pass_slip_provider.dart';
import '../../providers/profile_provider.dart';
import '../../providers/reports_provider.dart';
import '../../providers/whereabouts_provider.dart';
import '../../providers/workforce_provider.dart';
import '../../providers/it_service_request_provider.dart';
import '../../models/it_service_request.dart';
import '../../theme/m3_motion.dart';
import '../../utils/app_time.dart';
import '../../utils/location_permission.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../widgets/face_verification_overlay.dart';
import '../../utils/snackbar.dart';
import '../../widgets/gemini_animated_text_field.dart';
import '../../widgets/gemini_button.dart';
import '../../widgets/multi_select_picker.dart';
import '../../widgets/responsive_body.dart';
class AttendanceScreen extends ConsumerStatefulWidget {
const AttendanceScreen({super.key});
@override
ConsumerState<AttendanceScreen> createState() => _AttendanceScreenState();
}
class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
with TickerProviderStateMixin {
late TabController _tabController;
bool _fabMenuOpen = false;
// (moved into _CheckInTabState) local tracking state for optimistic UI updates
// bool _trackingLocal = false;
// bool _trackingSaving = false;
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
_tabController.addListener(_onTabChanged);
}
void _onTabChanged() {
if (_fabMenuOpen) setState(() => _fabMenuOpen = false);
setState(() {}); // rebuild for FAB visibility
}
@override
void dispose() {
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final profile = ref.watch(currentProfileProvider).valueOrNull;
final showFab = _tabController.index >= 2; // Pass Slip or Leave tabs
return ResponsiveBody(
maxWidth: 1200,
child: Scaffold(
backgroundColor: Colors.transparent,
floatingActionButton: showFab && profile != null
? _buildFabMenu(context, theme, colors, profile)
: null,
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Row(
children: [
Expanded(
child: Text(
'Attendance',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
],
),
),
TabBar(
controller: _tabController,
isScrollable: true,
tabAlignment: TabAlignment.start,
tabs: const [
Tab(text: 'Check In'),
Tab(text: 'Logbook'),
Tab(text: 'Pass Slip'),
Tab(text: 'Leave'),
],
),
Expanded(
child: TabBarView(
controller: _tabController,
children: const [
_CheckInTab(),
_LogbookTab(),
_PassSlipTab(),
_LeaveTab(),
],
),
),
],
),
),
);
}
Widget _buildFabMenu(
BuildContext context,
ThemeData theme,
ColorScheme colors,
Profile profile,
) {
final isAdmin = profile.role == 'admin' || profile.role == 'programmer';
final canFileLeave =
profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
if (!_fabMenuOpen) {
return M3ExpandedFab(
heroTag: 'attendance_fab',
onPressed: () => setState(() => _fabMenuOpen = true),
icon: const Icon(Icons.add),
label: const Text('Actions'),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Leave option
if (canFileLeave) ...[
_FabMenuItem(
heroTag: 'fab_leave',
label: 'File Leave',
icon: Icons.event_busy,
color: colors.tertiaryContainer,
onColor: colors.onTertiaryContainer,
onTap: () {
setState(() => _fabMenuOpen = false);
_showLeaveDialog(context, isAdmin);
},
),
const SizedBox(height: 12),
],
// Pass Slip option
_FabMenuItem(
heroTag: 'fab_slip',
label: 'Request Slip',
icon: Icons.receipt_long,
color: colors.secondaryContainer,
onColor: colors.onSecondaryContainer,
onTap: () {
setState(() => _fabMenuOpen = false);
_showPassSlipDialog(context, profile);
},
),
const SizedBox(height: 12),
// Close button
FloatingActionButton(
heroTag: 'fab_close',
onPressed: () => setState(() => _fabMenuOpen = false),
child: const Icon(Icons.close),
),
],
);
}
void _showLeaveDialog(BuildContext context, bool isAdmin) {
m3ShowDialog(
context: context,
builder: (ctx) => _FileLeaveDialog(
isAdmin: isAdmin,
onSubmitted: () {
if (mounted) {
showSuccessSnackBar(
context,
isAdmin
? 'Leave filed and auto-approved.'
: 'Leave application submitted for approval.',
);
}
},
),
);
}
void _showPassSlipDialog(BuildContext context, Profile profile) {
final isAdmin = profile.role == 'admin' || profile.role == 'programmer';
if (isAdmin) {
showWarningSnackBar(context, 'Admins cannot file pass slips.');
return;
}
final activeSlip = ref.read(activePassSlipProvider);
if (activeSlip != null) {
showWarningSnackBar(context, 'You already have an active pass slip.');
return;
}
final now = AppTime.now();
final today = DateTime(now.year, now.month, now.day);
final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? [];
final todaySchedule = schedules.where((s) {
final sDay = DateTime(
s.startTime.year,
s.startTime.month,
s.startTime.day,
);
return s.userId == profile.id &&
sDay == today &&
s.shiftType != 'overtime';
}).toList();
if (todaySchedule.isEmpty) {
showWarningSnackBar(context, 'No schedule found for today.');
return;
}
m3ShowDialog(
context: context,
builder: (ctx) => _PassSlipDialog(
scheduleId: todaySchedule.first.id,
onSubmitted: () {
if (mounted) {
showSuccessSnackBar(context, 'Pass slip requested.');
}
},
),
);
}
}
// ────────────────────────────────────────────────
// Tab 1 Check In / Check Out
// ────────────────────────────────────────────────
class _CheckInTab extends ConsumerStatefulWidget {
const _CheckInTab();
@override
ConsumerState<_CheckInTab> createState() => _CheckInTabState();
}
class _CheckInTabState extends ConsumerState<_CheckInTab> {
bool _loading = false;
// local tracking state for optimistic UI updates (optimistic toggle in UI)
bool _trackingLocal = false;
bool _trackingSaving = false;
final _justCheckedIn = <String>{};
final _checkInLogIds = <String, String>{};
String? _overtimeLogId;
final _justificationController = TextEditingController();
bool _isGeminiProcessing = false;
// Animated clock
Timer? _clockTimer;
DateTime _currentTime = AppTime.now();
// Geofence state
bool _insideGeofence = false;
bool _checkingGeofence = true;
@override
void initState() {
super.initState();
_clockTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() => _currentTime = AppTime.now());
});
_checkGeofenceStatus();
}
@override
void dispose() {
_clockTimer?.cancel();
_justificationController.dispose();
super.dispose();
}
Future<void> _checkGeofenceStatus() async {
final debugBypass =
kDebugMode && ref.read(debugSettingsProvider).bypassGeofence;
if (debugBypass) {
if (mounted) {
setState(() {
_insideGeofence = true;
_checkingGeofence = false;
});
}
return;
}
try {
final geoCfg = await ref.read(geofenceProvider.future);
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
);
bool inside = true;
if (geoCfg != null) {
if (geoCfg.hasPolygon) {
inside = geoCfg.containsPolygon(
position.latitude,
position.longitude,
);
} else if (geoCfg.hasCircle) {
final dist = Geolocator.distanceBetween(
position.latitude,
position.longitude,
geoCfg.lat!,
geoCfg.lng!,
);
inside = dist <= (geoCfg.radiusMeters ?? 0);
}
}
if (mounted) {
setState(() {
_insideGeofence = inside;
_checkingGeofence = false;
});
}
} catch (_) {
if (mounted) {
setState(() {
_insideGeofence = false;
_checkingGeofence = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final profile = ref.watch(currentProfileProvider).valueOrNull;
final schedulesAsync = ref.watch(dutySchedulesProvider);
final logsAsync = ref.watch(attendanceLogsProvider);
final allowTracking = profile?.allowTracking ?? false;
// local state for optimistic switch update. We only trust `_trackingLocal`
// while a save is in flight after that the server-side value (`allowTracking`)
// is authoritative. This ensures the toggle correctly reflects the persisted
// setting when the app restarts.
final bool effectiveTracking = _trackingSaving
? _trackingLocal
: allowTracking;
if (profile == null) {
return const Center(child: CircularProgressIndicator());
}
final now = _currentTime;
final todayStart = DateTime(now.year, now.month, now.day);
final tomorrowStart = todayStart.add(const Duration(days: 1));
final noon = DateTime(now.year, now.month, now.day, 12, 0);
final onePM = DateTime(now.year, now.month, now.day, 13, 0);
// Find today's schedule for the current user.
// Includes schedules where the user is a reliever (not only the primary user).
// Exclude overtime schedules they only belong in the Logbook.
// We treat a schedule as "today" if it overlaps the local calendar day window.
final schedules = schedulesAsync.valueOrNull ?? [];
final todaySchedule = schedules.where((s) {
final isAssigned =
s.userId == profile.id || s.relieverIds.contains(profile.id);
final overlapsToday =
s.startTime.isBefore(tomorrowStart) && s.endTime.isAfter(todayStart);
return isAssigned && overlapsToday;
}).toList();
// Find active attendance log (checked in but not out)
final logs = logsAsync.valueOrNull ?? [];
final activeLog = logs
.where((l) => l.userId == profile.id && !l.isCheckedOut)
.toList();
final activeOvertimeLog = activeLog
.where((l) => (l.justification ?? '').trim().isNotEmpty)
.toList();
// IT Service Request override (allows check-in/out outside geofence)
final itRequests = ref.watch(itServiceRequestsProvider).valueOrNull ?? [];
final itAssignments =
ref.watch(itServiceRequestAssignmentsProvider).valueOrNull ?? [];
final assignedRequestIds = itAssignments
.where((a) => a.userId == profile.id)
.map((a) => a.requestId)
.toSet();
final hasGeofenceOverride = itRequests.any((r) {
return assignedRequestIds.contains(r.id) &&
r.outsidePremiseAllowed &&
(r.status == ItServiceRequestStatus.scheduled ||
r.status == ItServiceRequestStatus.inProgressDryRun ||
r.status == ItServiceRequestStatus.inProgress);
});
final hasScheduleToday = todaySchedule.isNotEmpty;
final latestScheduleEnd = hasScheduleToday
? todaySchedule
.map((s) => s.endTime)
.reduce((a, b) => a.isAfter(b) ? a : b)
: null;
final hasScheduleEnded =
latestScheduleEnd != null && now.isAfter(latestScheduleEnd);
// If the user has an approved IT Service Request override, treat it as a "schedule" for
// purposes of showing the normal check-in UI (even if the duty schedule list is empty).
// (Note: this override should not prevent the overtime check-in from being shown.)
// Show overtime check-in when the user has no schedule today, or their last
// scheduled shift has already ended.
final showOvertimeCard =
(activeOvertimeLog.isEmpty && _overtimeLogId == null) &&
activeLog.isEmpty &&
(!hasScheduleToday || hasScheduleEnded);
if (kDebugMode && showOvertimeCard) {
final assignedSchedules = schedules
.where(
(s) => s.userId == profile.id || s.relieverIds.contains(profile.id),
)
.toList();
final assignedTodaySchedules = todaySchedule;
debugPrint(
'Attendance: showOvertimeCard=true (profile=${profile.id}, hasScheduleToday=$hasScheduleToday, hasScheduleEnded=$hasScheduleEnded, schedules=${schedules.length}, assigned=${assignedSchedules.length}, assignedToday=${assignedTodaySchedules.length})',
);
if (assignedSchedules.isNotEmpty) {
for (final s in assignedSchedules.take(6)) {
debugPrint(
' assigned: ${s.id} start=${s.startTime.toIso8601String()} end=${s.endTime.toIso8601String()} user=${s.userId} relievers=${s.relieverIds} shiftType=${s.shiftType}',
);
}
}
if (assignedTodaySchedules.isNotEmpty) {
for (final s in assignedTodaySchedules) {
debugPrint(
' assignedToday: ${s.id} start=${s.startTime.toIso8601String()} end=${s.endTime.toIso8601String()} user=${s.userId} relievers=${s.relieverIds} shiftType=${s.shiftType}',
);
}
}
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Location tracking toggle
Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(
Icons.location_on,
size: 20,
color: allowTracking ? colors.primary : colors.outline,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Location Tracking',
style: theme.textTheme.bodyMedium,
),
),
Switch(
value: effectiveTracking,
onChanged: (v) async {
if (_trackingSaving) return; // ignore while pending
if (v) {
// first ensure foreground & background location perms
final grantedFg = await ensureLocationPermission();
if (!grantedFg) {
if (context.mounted) {
showWarningSnackBar(
context,
'Location permission is required.',
);
}
return;
}
final grantedBg =
await ensureBackgroundLocationPermission();
if (!grantedBg) {
// if permanently denied, open settings so the user can
// manually grant the permission; otherwise just warn.
if (await Permission
.locationAlways
.isPermanentlyDenied ||
await Permission.location.isPermanentlyDenied) {
openAppSettings();
}
if (context.mounted) {
showWarningSnackBar(
context,
'Background location permission is required.',
);
}
return;
}
}
// optimistically flip
setState(() {
_trackingLocal = v;
_trackingSaving = true;
});
try {
await ref
.read(whereaboutsControllerProvider)
.setTracking(v);
} catch (e) {
if (context.mounted) {
showWarningSnackBar(context, e.toString());
}
// revert to actual stored value
setState(() {
_trackingLocal = allowTracking;
});
} finally {
setState(() {
_trackingSaving = false;
});
}
},
),
],
),
),
),
const SizedBox(height: 8),
// Debug: Geofence bypass toggle (only in debug mode)
if (kDebugMode)
Card(
color: colors.errorContainer,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
children: [
Icon(
Icons.bug_report,
size: 20,
color: colors.onErrorContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'DEBUG: Bypass Geofence',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onErrorContainer,
fontWeight: FontWeight.w600,
),
),
),
Switch(
value: ref.watch(debugSettingsProvider).bypassGeofence,
onChanged: (v) => ref
.read(debugSettingsProvider.notifier)
.setGeofenceBypass(v),
),
],
),
),
),
const SizedBox(height: 16),
// ── Animated Clock ──
() {
// Calculate lateness color from schedule
Color timeColor = colors.onSurface;
if (todaySchedule.isNotEmpty) {
final scheduleStart = todaySchedule.first.startTime;
final diff = scheduleStart.difference(now);
if (diff.isNegative) {
timeColor = colors.error;
} else if (diff.inMinutes <= 5) {
timeColor = colors.error;
} else if (diff.inMinutes <= 15) {
timeColor = Colors.orange;
} else if (diff.inMinutes <= 30) {
timeColor = colors.tertiary;
}
}
return Center(
child: Column(
children: [
AnimatedDefaultTextStyle(
duration: M3Motion.standard,
curve: M3Motion.standard_,
style: theme.textTheme.displayMedium!.copyWith(
fontWeight: FontWeight.w300,
color: timeColor,
letterSpacing: 2,
),
child: Text(AppTime.formatTime(now)),
),
const SizedBox(height: 4),
Text(
AppTime.formatDate(now),
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 8),
AnimatedSwitcher(
duration: M3Motion.short,
child: _checkingGeofence
? Row(
mainAxisSize: MainAxisSize.min,
key: const ValueKey('checking'),
children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colors.outline,
),
),
const SizedBox(width: 8),
Text(
'Checking location...',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.outline,
),
),
],
)
: Row(
mainAxisSize: MainAxisSize.min,
key: ValueKey(_insideGeofence),
children: [
Icon(
_insideGeofence || hasGeofenceOverride
? Icons.check_circle
: Icons.cancel,
size: 16,
color: _insideGeofence || hasGeofenceOverride
? Colors.green
: colors.error,
),
const SizedBox(width: 6),
Text(
_insideGeofence
? 'Within geofence'
: hasGeofenceOverride
? 'Outside geofence (allowed by IT request)'
: 'Outside geofence',
style: theme.textTheme.bodySmall?.copyWith(
color: _insideGeofence || hasGeofenceOverride
? Colors.green
: colors.error,
fontWeight: FontWeight.w500,
),
),
if (!_insideGeofence && !hasGeofenceOverride) ...[
const SizedBox(width: 8),
TextButton(
onPressed: () {
setState(() => _checkingGeofence = true);
_checkGeofenceStatus();
},
child: const Text('Refresh'),
),
],
],
),
),
],
),
);
}(),
const SizedBox(height: 24),
// Today's schedule
Text(
"Today's Schedule",
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
if (activeOvertimeLog.isNotEmpty || _overtimeLogId != null)
_buildActiveOvertimeCard(context, theme, colors, activeOvertimeLog)
else if (showOvertimeCard)
_buildOvertimeCard(
context,
theme,
colors,
hasScheduleToday: hasScheduleToday,
)
else
...todaySchedule.map((schedule) {
// All logs for this schedule.
final scheduleLogs = logs
.where((l) => l.dutyScheduleId == schedule.id)
.toList();
final realActiveLog = scheduleLogs
.where((l) => !l.isCheckedOut)
.toList();
final completedLogs = scheduleLogs
.where((l) => l.isCheckedOut)
.toList();
final hasActiveLog = realActiveLog.isNotEmpty;
final isLocallyCheckedIn = _justCheckedIn.contains(schedule.id);
final showCheckOut = hasActiveLog || isLocallyCheckedIn;
final isShiftOver = !now.isBefore(schedule.endTime);
final isFullDay =
schedule.endTime.difference(schedule.startTime).inHours >= 6;
final isNoonBreakWindow =
isFullDay && !now.isBefore(noon) && now.isBefore(onePM);
// Determine status label.
String statusLabel;
if (showCheckOut) {
statusLabel = 'On Duty';
} else if (isShiftOver) {
statusLabel = scheduleLogs.isEmpty ? 'Absent' : 'Completed';
} else if (completedLogs.isNotEmpty && isNoonBreakWindow) {
statusLabel = 'Noon Break';
} else if (completedLogs.isNotEmpty) {
statusLabel = 'Early Out';
} else {
statusLabel = 'Scheduled';
}
final canCheckIn =
!showCheckOut &&
!isShiftOver &&
(_insideGeofence || hasGeofenceOverride) &&
!_checkingGeofence;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.schedule, size: 20, color: colors.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
_shiftLabel(schedule.shiftType),
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
_statusChip(context, statusLabel),
],
),
const SizedBox(height: 8),
Text(
'${AppTime.formatTime(schedule.startTime)} ${AppTime.formatTime(schedule.endTime)}',
style: theme.textTheme.bodyMedium,
),
// Session history — show each completed check-in/out pair.
if (completedLogs.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
'Sessions',
style: theme.textTheme.labelMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 4),
...completedLogs.map((log) {
final dur = log.checkOutAt!.difference(log.checkInAt);
final hours = dur.inHours;
final mins = dur.inMinutes.remainder(60);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Icon(
Icons.check_circle_outline,
size: 14,
color: colors.primary,
),
const SizedBox(width: 6),
Expanded(
child: Text(
'${AppTime.formatTime(log.checkInAt)} ${AppTime.formatTime(log.checkOutAt!)} (${hours}h ${mins}m)',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
],
),
);
}),
],
const SizedBox(height: 20),
// Action button — check-in or check-out (centered, enlarged).
if (canCheckIn)
Center(
child: SizedBox(
width: 220,
height: 56,
child: FilledButton.icon(
onPressed: _loading
? null
: () => _handleCheckIn(schedule),
icon: _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.login, size: 24),
label: Text(
'Check In',
style: theme.textTheme.titleMedium?.copyWith(
color: colors.onPrimary,
fontWeight: FontWeight.w600,
),
),
),
),
)
else if (!showCheckOut &&
!isShiftOver &&
!_insideGeofence &&
!hasGeofenceOverride &&
!_checkingGeofence)
Center(
child: SizedBox(
width: 220,
height: 56,
child: FilledButton.icon(
onPressed: null,
icon: const Icon(Icons.location_off, size: 24),
label: Text(
'Check In',
style: theme.textTheme.titleMedium,
),
),
),
)
else if (showCheckOut)
Center(
child: SizedBox(
width: 220,
height: 56,
child: FilledButton.tonalIcon(
onPressed: _loading
? null
: () {
if (realActiveLog.isNotEmpty) {
_handleCheckOut(
realActiveLog.first,
scheduleId: schedule.id,
);
} else {
final logId =
_checkInLogIds[schedule.id];
if (logId != null) {
_handleCheckOutById(
logId,
scheduleId: schedule.id,
);
}
}
},
icon: _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.logout, size: 24),
label: Text(
'Check Out',
style: theme.textTheme.titleMedium,
),
),
),
)
else if (statusLabel == 'Absent')
Row(
children: [
Icon(
Icons.cancel_outlined,
size: 16,
color: colors.error,
),
const SizedBox(width: 6),
Expanded(
child: Text(
'No check-in recorded for this shift.',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.error,
),
),
),
],
),
],
),
),
);
}),
],
),
);
}
Future<void> _handleCheckIn(DutySchedule schedule) async {
setState(() => _loading = true);
try {
// Ensure location permission before check-in
final locGranted = await ensureLocationPermission();
if (!locGranted) {
if (mounted) {
showWarningSnackBar(
context,
'Location permission is required for check-in.',
);
}
return;
}
final geoCfg = await ref.read(geofenceProvider.future);
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
);
// Client-side geofence check (can be bypassed in debug mode or by an approved IT request)
final debugBypass =
kDebugMode && ref.read(debugSettingsProvider).bypassGeofence;
// Allow outside check-in if the user has an approved IT Service Request
// with outsidePremiseAllowed = true.
final profile = ref.read(currentProfileProvider).valueOrNull;
final hasItOverride = () {
if (profile == null) return false;
final itRequests =
ref.watch(itServiceRequestsProvider).valueOrNull ?? [];
final itAssignments =
ref.watch(itServiceRequestAssignmentsProvider).valueOrNull ?? [];
final assignedRequestIds = itAssignments
.where((a) => a.userId == profile.id)
.map((a) => a.requestId)
.toSet();
return itRequests.any((r) {
return assignedRequestIds.contains(r.id) &&
r.outsidePremiseAllowed &&
(r.status == ItServiceRequestStatus.scheduled ||
r.status == ItServiceRequestStatus.inProgressDryRun ||
r.status == ItServiceRequestStatus.inProgress);
});
}();
if (geoCfg != null && !debugBypass && !hasItOverride) {
bool inside = false;
if (geoCfg.hasPolygon) {
inside = geoCfg.containsPolygon(
position.latitude,
position.longitude,
);
} else if (geoCfg.hasCircle) {
final dist = Geolocator.distanceBetween(
position.latitude,
position.longitude,
geoCfg.lat!,
geoCfg.lng!,
);
inside = dist <= (geoCfg.radiusMeters ?? 0);
}
if (!inside && mounted) {
showWarningSnackBar(context, 'You are outside the geofence area.');
return;
}
} else if ((debugBypass || hasItOverride) && mounted) {
showInfoSnackBar(
context,
hasItOverride
? 'Allowed by approved IT Service Request.'
: '⚠️ DEBUG: Geofence check bypassed',
);
}
final logId = await ref
.read(attendanceControllerProvider)
.checkIn(
dutyScheduleId: schedule.id,
lat: position.latitude,
lng: position.longitude,
);
// automatically enable tracking when user checks in
try {
await ref.read(whereaboutsControllerProvider).setTracking(true);
} catch (_) {}
_trackingLocal = true; // reflect new state immediately
// Update live position immediately on check-in
ref.read(whereaboutsControllerProvider).updatePositionNow();
if (mounted) {
setState(() {
_justCheckedIn.add(schedule.id);
if (logId != null) _checkInLogIds[schedule.id] = logId;
});
showSuccessSnackBar(context, 'Checked in! Running verification...');
if (logId != null) _performFaceVerification(logId);
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Check-in failed: $e');
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _handleCheckOut(AttendanceLog log, {String? scheduleId}) async {
setState(() => _loading = true);
try {
// Ensure location permission before check-out
final locGranted = await ensureLocationPermission();
if (!locGranted) {
if (mounted) {
showWarningSnackBar(
context,
'Location permission is required for check-out.',
);
}
return;
}
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
);
// Check if outside geofence — require justification if so
String? checkOutJustification;
final geoCfg = await ref.read(geofenceProvider.future);
final debugBypass =
kDebugMode && ref.read(debugSettingsProvider).bypassGeofence;
if (geoCfg != null && !debugBypass) {
bool inside = false;
if (geoCfg.hasPolygon) {
inside = geoCfg.containsPolygon(
position.latitude,
position.longitude,
);
} else if (geoCfg.hasCircle) {
final dist = Geolocator.distanceBetween(
position.latitude,
position.longitude,
geoCfg.lat!,
geoCfg.lng!,
);
inside = dist <= (geoCfg.radiusMeters ?? 0);
}
if (!inside && mounted) {
checkOutJustification = await _showCheckOutJustificationDialog(
context,
);
if (checkOutJustification == null) {
// User cancelled
return;
}
}
}
await ref
.read(attendanceControllerProvider)
.checkOut(
attendanceId: log.id,
lat: position.latitude,
lng: position.longitude,
justification: checkOutJustification,
);
// automatically disable tracking when user checks out
try {
await ref.read(whereaboutsControllerProvider).setTracking(false);
} catch (_) {}
_trackingLocal = false;
// Update live position immediately on check-out
ref.read(whereaboutsControllerProvider).updatePositionNow();
if (mounted) {
setState(() {
if (scheduleId != null) {
_justCheckedIn.remove(scheduleId);
_checkInLogIds.remove(scheduleId);
}
_overtimeLogId = null;
});
showSuccessSnackBar(context, 'Checked out! Running verification...');
_performFaceVerification(log.id, isCheckOut: true);
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Check-out failed: $e');
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _handleCheckOutById(String logId, {String? scheduleId}) async {
setState(() => _loading = true);
try {
// Ensure location permission before check-out
final locGranted = await ensureLocationPermission();
if (!locGranted) {
if (mounted) {
showWarningSnackBar(
context,
'Location permission is required for check-out.',
);
}
return;
}
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
);
// Check if outside geofence — require justification if so
String? checkOutJustification;
final geoCfg = await ref.read(geofenceProvider.future);
final debugBypass =
kDebugMode && ref.read(debugSettingsProvider).bypassGeofence;
if (geoCfg != null && !debugBypass) {
bool inside = false;
if (geoCfg.hasPolygon) {
inside = geoCfg.containsPolygon(
position.latitude,
position.longitude,
);
} else if (geoCfg.hasCircle) {
final dist = Geolocator.distanceBetween(
position.latitude,
position.longitude,
geoCfg.lat!,
geoCfg.lng!,
);
inside = dist <= (geoCfg.radiusMeters ?? 0);
}
if (!inside && mounted) {
checkOutJustification = await _showCheckOutJustificationDialog(
context,
);
if (checkOutJustification == null) {
// User cancelled
return;
}
}
}
await ref
.read(attendanceControllerProvider)
.checkOut(
attendanceId: logId,
lat: position.latitude,
lng: position.longitude,
justification: checkOutJustification,
);
// Update live position immediately on check-out
ref.read(whereaboutsControllerProvider).updatePositionNow();
if (mounted) {
setState(() {
if (scheduleId != null) {
_justCheckedIn.remove(scheduleId);
_checkInLogIds.remove(scheduleId);
}
_overtimeLogId = null;
});
showSuccessSnackBar(context, 'Checked out! Running verification...');
_performFaceVerification(logId, isCheckOut: true);
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Check-out failed: $e');
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
/// Shows a dialog asking for justification when checking out outside geofence.
/// Returns the justification text, or null if the user cancelled.
Future<String?> _showCheckOutJustificationDialog(BuildContext context) async {
final controller = TextEditingController();
final result = await m3ShowDialog<String>(
context: context,
builder: (ctx) {
final colors = Theme.of(ctx).colorScheme;
final textTheme = Theme.of(ctx).textTheme;
return AlertDialog(
icon: Icon(Icons.location_off, color: colors.error),
title: const Text('Outside Geofence'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'You are checking out outside the designated area. '
'Please provide a justification.',
style: textTheme.bodyMedium,
),
const SizedBox(height: 16),
TextField(
controller: controller,
maxLines: 3,
decoration: const InputDecoration(
labelText: 'Justification',
hintText:
'Explain why you are checking out outside the geofence...',
border: OutlineInputBorder(),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(null),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final text = controller.text.trim();
if (text.isEmpty) return;
Navigator.of(ctx).pop(text);
},
child: const Text('Submit & Check Out'),
),
],
);
},
);
controller.dispose();
return result;
}
Future<void> _handleOvertimeCheckIn() async {
final justification = _justificationController.text.trim();
if (justification.isEmpty) {
showWarningSnackBar(
context,
'Please provide a justification for overtime.',
);
return;
}
setState(() => _loading = true);
try {
final geoCfg = await ref.read(geofenceProvider.future);
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
);
final debugBypass =
kDebugMode && ref.read(debugSettingsProvider).bypassGeofence;
if (geoCfg != null && !debugBypass) {
bool inside = false;
if (geoCfg.hasPolygon) {
inside = geoCfg.containsPolygon(
position.latitude,
position.longitude,
);
} else if (geoCfg.hasCircle) {
final dist = Geolocator.distanceBetween(
position.latitude,
position.longitude,
geoCfg.lat!,
geoCfg.lng!,
);
inside = dist <= (geoCfg.radiusMeters ?? 0);
}
if (!inside && mounted) {
showWarningSnackBar(context, 'You are outside the geofence area.');
return;
}
} else if (debugBypass && mounted) {
showInfoSnackBar(context, '⚠️ DEBUG: Geofence check bypassed');
}
final logId = await ref
.read(attendanceControllerProvider)
.overtimeCheckIn(
lat: position.latitude,
lng: position.longitude,
justification: justification,
);
if (mounted) {
setState(() {
_overtimeLogId = logId;
_justificationController.clear();
});
showSuccessSnackBar(
context,
'Overtime check-in! Running verification...',
);
if (logId != null) _performFaceVerification(logId);
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Overtime check-in failed: $e');
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
/// Face verification after check-in/out: liveness detection on mobile,
/// camera/gallery on web. Uploads selfie and updates attendance log.
Future<void> _performFaceVerification(
String attendanceLogId, {
bool isCheckOut = false,
}) async {
final profile = ref.read(currentProfileProvider).valueOrNull;
if (profile == null || !profile.hasFaceEnrolled) {
try {
await ref
.read(attendanceControllerProvider)
.skipVerification(attendanceLogId);
} catch (_) {}
if (mounted) {
showInfoSnackBar(
context,
'Face not enrolled — verification skipped. Enroll in Profile.',
);
}
return;
}
try {
final result = await showFaceVerificationOverlay(
context: context,
ref: ref,
attendanceLogId: attendanceLogId,
isCheckOut: isCheckOut,
);
if (!mounted) return;
if (result == null || !result.verified) {
final score = result?.matchScore;
if (score != null) {
showWarningSnackBar(
context,
'Face did not match (${(score * 100).toStringAsFixed(0)}%). Flagged for review.',
);
} else {
showWarningSnackBar(
context,
'Verification skipped — flagged as unverified.',
);
}
} else {
final score = result.matchScore ?? 0;
showSuccessSnackBar(
context,
'Face verified (${(score * 100).toStringAsFixed(0)}% match).',
);
}
} catch (e) {
try {
await ref
.read(attendanceControllerProvider)
.skipVerification(attendanceLogId);
} catch (_) {}
if (mounted) {
showWarningSnackBar(context, 'Verification failed — flagged.');
}
}
}
/// Card shown when user has no active schedule (or their schedule has ended).
/// Offers overtime check-in.
Widget _buildOvertimeCard(
BuildContext context,
ThemeData theme,
ColorScheme colors, {
required bool hasScheduleToday,
}) {
final headerText = hasScheduleToday
? 'Your scheduled shift has ended.'
: 'No schedule assigned for today.';
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline, size: 20, color: colors.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
headerText,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 16),
Text(
'Overtime Check-in',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
'You can check in as overtime. A justification is required.',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: GeminiAnimatedTextField(
controller: _justificationController,
labelText: 'Justification for overtime',
maxLines: 4,
enabled: !_loading && _insideGeofence && !_checkingGeofence,
isProcessing: _isGeminiProcessing,
),
),
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: GeminiButton(
textController: _justificationController,
onTextUpdated: (text) {
setState(() {
_justificationController.text = text;
});
},
onProcessingStateChanged: (processing) {
setState(() => _isGeminiProcessing = processing);
},
tooltip: 'Translate/Enhance with AI',
promptBuilder: (_) =>
'Translate this sentence to clear professional English '
'if needed, and enhance grammar/clarity while preserving '
'the original meaning. Return ONLY the improved text, '
'with no explanations, no recommendations, and no extra context.',
),
),
],
),
const SizedBox(height: 12),
Center(
child: SizedBox(
width: 220,
height: 56,
child: FilledButton.icon(
onPressed:
(_loading ||
_isGeminiProcessing ||
!_insideGeofence ||
_checkingGeofence)
? null
: _handleOvertimeCheckIn,
icon: _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.more_time, size: 24),
label: Text(
'Overtime Check In',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
),
);
}
/// Card shown when user is actively in an overtime session.
Widget _buildActiveOvertimeCard(
BuildContext context,
ThemeData theme,
ColorScheme colors,
List<AttendanceLog> activeLog,
) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.more_time, size: 20, color: colors.tertiary),
const SizedBox(width: 8),
Expanded(
child: Text(
'Overtime',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
_statusChip(context, 'On Duty'),
],
),
const SizedBox(height: 8),
if (activeLog.isNotEmpty)
Text(
'Checked in at ${AppTime.formatTime(activeLog.first.checkInAt)}',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
onPressed: _loading
? null
: () {
if (activeLog.isNotEmpty) {
_handleCheckOut(activeLog.first);
} else if (_overtimeLogId != null) {
_handleCheckOutById(_overtimeLogId!);
}
},
icon: _loading
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.logout),
label: const Text('Check Out'),
),
),
],
),
),
);
}
Widget _statusChip(BuildContext context, String label) {
final colors = Theme.of(context).colorScheme;
Color bg;
Color fg;
switch (label) {
case 'Completed':
bg = colors.primaryContainer;
fg = colors.onPrimaryContainer;
case 'On Duty':
bg = colors.tertiaryContainer;
fg = colors.onTertiaryContainer;
case 'Checked In':
bg = colors.secondaryContainer;
fg = colors.onSecondaryContainer;
case 'Early Out':
bg = Colors.orange.shade100;
fg = Colors.orange.shade900;
case 'Noon Break':
bg = Colors.blue.shade100;
fg = Colors.blue.shade900;
case 'Absent':
bg = colors.errorContainer;
fg = colors.onErrorContainer;
default:
bg = colors.surfaceContainerHighest;
fg = colors.onSurface;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: fg),
),
);
}
String _shiftLabel(String shiftType) {
switch (shiftType) {
case 'normal':
return 'Normal Shift';
case 'night':
return 'Night Shift';
case 'overtime':
return 'Overtime';
case 'on_call':
return 'On Call';
default:
return shiftType;
}
}
}
// ────────────────────────────────────────────────
// Unified logbook entry (real log or absent schedule)
// ────────────────────────────────────────────────
class _LogbookEntry {
_LogbookEntry({
required this.name,
required this.userId,
required this.dutyScheduleId,
required this.shift,
required this.date,
required this.checkInAt,
required this.checkOutAt,
required this.checkIn,
required this.checkOut,
required this.duration,
required this.status,
required this.isAbsent,
this.isLeave = false,
this.leaveType,
this.verificationStatus,
this.logId,
this.logUserId,
this.enrolledFaceUrl,
this.checkInVerificationFaceUrl,
this.checkOutVerificationFaceUrl,
this.justification,
this.checkOutJustification,
this.checkInLat,
this.checkInLng,
this.checkOutLat,
this.checkOutLng,
});
final String name;
final String userId;
final String? dutyScheduleId;
final String shift;
final DateTime date;
final DateTime checkInAt;
final DateTime? checkOutAt;
final String checkIn;
final String checkOut;
final String duration;
final String status;
final bool isAbsent;
final bool isLeave;
final String? leaveType;
final String? verificationStatus;
final String? logId;
final String? logUserId;
final String? enrolledFaceUrl;
final String? checkInVerificationFaceUrl;
final String? checkOutVerificationFaceUrl;
final String? justification;
final String? checkOutJustification;
final double? checkInLat;
final double? checkInLng;
final double? checkOutLat;
final double? checkOutLng;
/// Whether this entry can be re-verified (within 10 min of check-in).
bool canReverify(String currentUserId) {
if (logId == null || logUserId != currentUserId) return false;
if (verificationStatus != 'unverified' && verificationStatus != 'skipped') {
return false;
}
final elapsed = AppTime.now().difference(date);
return elapsed.inMinutes <= 10;
}
// NOTE: logbook entry creation is handled in an async provider (so that
// filtering/sorting does not block the UI). Use `_computeLogbookEntries`.
factory _LogbookEntry.absent(DutySchedule s, Map<String, Object?> byId) {
final p = byId[s.userId];
final name = p is Profile ? p.fullName : (p as String?) ?? s.userId;
return _LogbookEntry(
name: name,
userId: s.userId,
dutyScheduleId: s.id,
shift: _shiftLabelFromType(s.shiftType),
date: s.startTime,
checkInAt: s.startTime,
checkOutAt: null,
checkIn: '',
checkOut: '',
duration: '',
status: 'Absent',
isAbsent: true,
);
}
static String _fmtDur(Duration d) {
final h = d.inHours;
final m = d.inMinutes.remainder(60);
return '${h}h ${m}m';
}
static String _shiftLabelFromType(String shiftType) {
switch (shiftType) {
case 'normal':
return 'Normal Shift';
case 'night':
return 'Night Shift';
case 'overtime':
return 'Overtime';
case 'on_call':
return 'On Call';
default:
return shiftType;
}
}
}
/// A shift grouping representing a set of attendance sessions for a single
/// scheduled shift (or leave/absence) in a single day.
class _ShiftGroup {
_ShiftGroup({
required this.groupKey,
required this.userId,
required this.dutyScheduleId,
required this.name,
required this.shiftLabel,
required this.sessions,
});
final String groupKey;
final String userId;
final String? dutyScheduleId;
final String name;
final String shiftLabel;
final List<_LogbookEntry> sessions;
DateTime get date => sessions.first.date;
String get checkIn =>
sessions.isEmpty ? '' : AppTime.formatTime(sessions.first.checkInAt);
String get checkOut {
final checkedOut = sessions.where((s) => s.checkOutAt != null).toList();
if (checkedOut.isEmpty) return '';
final last = checkedOut.reduce(
(a, b) => a.checkOutAt!.isAfter(b.checkOutAt!) ? a : b,
);
return last.checkOut;
}
String get duration {
if (sessions.isEmpty) return '';
final first = sessions.first.checkInAt;
final checkedOut = sessions.where((s) => s.checkOutAt != null).toList();
if (checkedOut.isEmpty) return 'On duty';
final last = checkedOut.reduce(
(a, b) => a.checkOutAt!.isAfter(b.checkOutAt!) ? a : b,
);
return _LogbookEntry._fmtDur(last.checkOutAt!.difference(first));
}
String get status {
if (sessions.any((s) => s.isLeave)) return 'On Leave';
if (sessions.any((s) => s.isAbsent)) return 'Absent';
if (sessions.any((s) => s.checkOutAt == null)) return 'On duty';
return 'Completed';
}
/// Whether any session in this group is still in progress.
bool get hasOngoingSession =>
sessions.any((s) => !s.isAbsent && !s.isLeave && s.checkOutAt == null);
}
// ────────────────────────────────────────────────
// Tab 2 Logbook
// ────────────────────────────────────────────────
class _LogbookTab extends ConsumerStatefulWidget {
const _LogbookTab();
@override
ConsumerState<_LogbookTab> createState() => _LogbookTabState();
}
class _LogbookTabState extends ConsumerState<_LogbookTab> {
AsyncValue<List<_LogbookEntry>> _entriesAsync = const AsyncValue.loading();
int? _lastSignature;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final range = ref.watch(attendanceDateRangeProvider);
final logsAsync = ref.watch(attendanceLogsProvider);
final profilesAsync = ref.watch(profilesProvider);
final schedulesAsync = ref.watch(dutySchedulesProvider);
final leavesAsync = ref.watch(leavesProvider);
final selectedUserIds = ref.watch(attendanceUserFilterProvider);
// Only show users with admin-like roles in the selector.
const allowedRoles = {'admin', 'dispatcher', 'programmer', 'it_staff'};
final allowedProfiles =
(profilesAsync.valueOrNull ?? [])
.where((p) => allowedRoles.contains(p.role))
.toList()
..sort((a, b) => a.fullName.compareTo(b.fullName));
return Column(
children: [
// Date filter card
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Icon(Icons.calendar_today, size: 18, color: colors.primary),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(range.label, style: theme.textTheme.labelLarge),
Text(
AppTime.formatDateRange(range.dateTimeRange),
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
),
FilledButton.tonalIcon(
onPressed: () => _showDateFilterDialog(context, ref),
icon: const Icon(Icons.tune, size: 18),
label: const Text('Change'),
),
],
),
),
),
),
// User filter card (multi-select)
if (allowedProfiles.isNotEmpty) ...[
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Card(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: MultiSelectPicker<Profile>(
label: 'Personnel',
items: allowedProfiles,
selectedIds: selectedUserIds,
getId: (p) => p.id,
getLabel: (p) => p.fullName,
onChanged: (selected) {
ref.read(attendanceUserFilterProvider.notifier).state =
selected;
},
),
),
),
),
],
const SizedBox(height: 8),
Expanded(
child: logsAsync.when(
data: (logs) {
_recomputeEntriesIfNeeded(
logs: logs,
range: range,
schedules: schedulesAsync.valueOrNull ?? [],
leaves: leavesAsync.valueOrNull ?? [],
profileList: profilesAsync.valueOrNull ?? [],
selectedUserIds: selectedUserIds,
);
return _entriesAsync.when(
data: (entries) {
if (entries.isEmpty) {
return Center(
child: Text(
'No attendance logs for this period.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
);
}
final currentUserId = ref.read(currentUserIdProvider) ?? '';
return _buildShiftGroupList(
context,
entries,
currentUserId: currentUserId,
onReverify: (logId) => _reverify(context, ref, logId),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Failed to load logs: $e')),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Failed to load logs: $e')),
),
),
],
);
}
void _recomputeEntriesIfNeeded({
required List<AttendanceLog> logs,
required ReportDateRange range,
required List<DutySchedule> schedules,
required List<LeaveOfAbsence> leaves,
required List<Profile> profileList,
required List<String> selectedUserIds,
}) {
final signature = Object.hashAll([
logs.length,
range.start.millisecondsSinceEpoch,
range.end.millisecondsSinceEpoch,
schedules.length,
leaves.length,
profileList.length,
selectedUserIds.length,
selectedUserIds.hashCode,
]);
if (signature == _lastSignature) return;
_lastSignature = signature;
setState(() {
_entriesAsync = const AsyncValue.loading();
});
Future(() {
final profileById = {for (final p in profileList) p.id: p};
return _computeEntries(
logs: logs,
range: range,
schedules: schedules,
leaves: leaves,
profileById: profileById,
selectedUserIds: selectedUserIds,
);
})
.then((entries) {
if (!mounted) return;
setState(() {
_entriesAsync = AsyncValue.data(entries);
});
})
.catchError((e, st) {
if (!mounted) return;
setState(() {
_entriesAsync = AsyncValue.error(e, st);
});
});
}
List<_LogbookEntry> _computeEntries({
required List<AttendanceLog> logs,
required ReportDateRange range,
required List<DutySchedule> schedules,
required List<LeaveOfAbsence> leaves,
required Map<String, Profile> profileById,
required List<String> selectedUserIds,
}) {
final filtered = logs.where((log) {
return !log.checkInAt.isBefore(range.start) &&
log.checkInAt.isBefore(range.end);
}).toList();
final logScheduleIds = filtered.map((l) => l.dutyScheduleId).toSet();
final overtimeDaysByUser = <String, Set<String>>{};
for (final log in filtered) {
if (log.shiftType == 'overtime') {
final d = log.checkInAt;
final key = '${d.year}-${d.month}-${d.day}';
overtimeDaysByUser.putIfAbsent(log.userId, () => {}).add(key);
}
}
final now = AppTime.now();
final absentSchedules = schedules.where((s) {
if (s.shiftType == 'overtime' || s.shiftType == 'on_call') return false;
if (logScheduleIds.contains(s.id)) return false;
if (!s.endTime.isBefore(now)) return false;
if (s.startTime.isBefore(range.start) ||
!s.startTime.isBefore(range.end)) {
return false;
}
final d = s.startTime;
final dayKey = '${d.year}-${d.month}-${d.day}';
if (overtimeDaysByUser[s.userId]?.contains(dayKey) ?? false) {
return false;
}
return true;
}).toList();
final leaveEntries = leaves
.where((l) {
return l.status == 'approved' &&
!l.startTime.isBefore(range.start) &&
l.startTime.isBefore(range.end);
})
.map((l) {
final profile = profileById[l.userId];
return _LogbookEntry(
name: profile?.fullName ?? l.userId,
userId: l.userId,
dutyScheduleId: null,
shift: '',
date: l.startTime,
checkInAt: l.startTime,
checkOutAt: l.endTime,
checkIn: AppTime.formatTime(l.startTime),
checkOut: AppTime.formatTime(l.endTime),
duration: '',
status: 'On Leave',
isAbsent: false,
isLeave: true,
leaveType: l.leaveType,
enrolledFaceUrl: profile?.facePhotoUrl,
);
});
final scheduleById = {for (final s in schedules) s.id: s};
final List<_LogbookEntry> entries = [
for (final l in filtered)
_LogbookEntry(
name: profileById[l.userId]?.fullName ?? l.userId,
userId: l.userId,
dutyScheduleId: l.dutyScheduleId,
shift: _LogbookEntry._shiftLabelFromType(
scheduleById[l.dutyScheduleId]?.shiftType ?? l.shiftType,
),
date: l.checkInAt,
checkInAt: l.checkInAt,
checkOutAt: l.checkOutAt,
checkIn: AppTime.formatTime(l.checkInAt),
checkOut: l.isCheckedOut ? AppTime.formatTime(l.checkOutAt!) : '',
duration: l.isCheckedOut
? _LogbookEntry._fmtDur(l.checkOutAt!.difference(l.checkInAt))
: 'On duty',
status: l.isCheckedOut ? 'Completed' : 'On duty',
isAbsent: false,
verificationStatus: l.verificationStatus,
logId: l.id,
logUserId: l.userId,
enrolledFaceUrl: profileById[l.userId]?.facePhotoUrl,
checkInVerificationFaceUrl: l.checkInVerificationPhotoUrl,
checkOutVerificationFaceUrl: l.checkOutVerificationPhotoUrl,
justification: l.justification,
checkOutJustification: l.checkOutJustification,
checkInLat: l.checkInLat,
checkInLng: l.checkInLng,
checkOutLat: l.checkOutLat,
checkOutLng: l.checkOutLng,
),
...absentSchedules.map((s) => _LogbookEntry.absent(s, profileById)),
...leaveEntries,
];
final filteredEntries = selectedUserIds.isEmpty
? entries
: entries.where((e) => selectedUserIds.contains(e.userId)).toList();
filteredEntries.sort((a, b) => b.date.compareTo(a.date));
return filteredEntries;
}
void _reverify(BuildContext context, WidgetRef ref, String logId) async {
final profile = ref.read(currentProfileProvider).valueOrNull;
if (profile == null || !profile.hasFaceEnrolled) {
showInfoSnackBar(
context,
'Face not enrolled \u2014 enroll in Profile first.',
);
return;
}
final result = await showFaceVerificationOverlay(
context: context,
ref: ref,
attendanceLogId: logId,
);
if (!context.mounted) return;
if (result != null && result.verified) {
showSuccessSnackBar(
context,
'Re-verification successful (${((result.matchScore ?? 0) * 100).toStringAsFixed(0)}% match).',
);
} else if (result != null) {
showWarningSnackBar(
context,
'Re-verification failed. Still flagged as unverified.',
);
}
}
void _showDateFilterDialog(BuildContext context, WidgetRef ref) {
m3ShowDialog(
context: context,
builder: (ctx) => _AttendanceDateFilterDialog(
current: ref.read(attendanceDateRangeProvider),
onApply: (newRange) {
ref.read(attendanceDateRangeProvider.notifier).state = newRange;
},
),
);
}
Widget _buildShiftGroupList(
BuildContext context,
List<_LogbookEntry> entries, {
required String currentUserId,
required void Function(String logId) onReverify,
}) {
final groupedByDate = _groupByDate(entries);
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: groupedByDate.entries.map((group) {
final shiftGroups = _groupByShift(group.value);
return _DateGroupTile(
dateLabel: group.key,
shiftGroups: shiftGroups,
currentUserId: currentUserId,
onReverify: onReverify,
);
}).toList(),
);
}
/// Group list of entries by shift (user + schedule + date).
static List<_ShiftGroup> _groupByShift(List<_LogbookEntry> entries) {
final groups = <String, _ShiftGroup>{};
for (final entry in entries) {
final dateKey = AppTime.formatDate(entry.date);
final key = entry.isLeave
? 'leave|${entry.userId}|$dateKey|${entry.leaveType ?? ''}'
: 'shift|${entry.userId}|${entry.dutyScheduleId ?? ''}|$dateKey';
final group = groups.putIfAbsent(
key,
() => _ShiftGroup(
groupKey: key,
userId: entry.userId,
dutyScheduleId: entry.dutyScheduleId,
name: entry.name,
shiftLabel: entry.shift,
sessions: [],
),
);
group.sessions.add(entry);
}
for (final g in groups.values) {
g.sessions.sort((a, b) => a.date.compareTo(b.date));
}
final list = groups.values.toList();
list.sort((a, b) => b.date.compareTo(a.date));
return list;
}
/// Group sorted entries by formatted date string (preserving order).
static Map<String, List<_LogbookEntry>> _groupByDate(
List<_LogbookEntry> entries,
) {
final map = <String, List<_LogbookEntry>>{};
for (final e in entries) {
final key = AppTime.formatDate(e.date);
map.putIfAbsent(key, () => []).add(e);
}
return map;
}
}
// ────────────────────────────────────────────────
// Collapsible date-group tile for Logbook
// ────────────────────────────────────────────────
class _DateGroupTile extends StatelessWidget {
const _DateGroupTile({
required this.dateLabel,
required this.shiftGroups,
required this.currentUserId,
required this.onReverify,
});
final String dateLabel;
final List<_ShiftGroup> shiftGroups;
final String currentUserId;
final void Function(String logId) onReverify;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Card(
margin: const EdgeInsets.symmetric(vertical: 6),
child: ExpansionTile(
initiallyExpanded: true,
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
title: Text(
dateLabel,
style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
subtitle: Text(
'${shiftGroups.length} ${shiftGroups.length == 1 ? 'shift' : 'shifts'}',
style: textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant),
),
children: shiftGroups.map((group) {
return _buildShiftGroupTile(context, group);
}).toList(),
),
);
}
Widget _buildShiftGroupTile(BuildContext context, _ShiftGroup group) {
// If the group is an absence or leave, render as a single row.
final first = group.sessions.first;
if (first.isAbsent || first.isLeave) {
return _buildSessionTile(context, first);
}
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final statusColor = group.status == 'Absent'
? Colors.red
: group.status == 'On duty'
? Colors.orange
: group.status == 'On Leave'
? Colors.teal
: Colors.green;
return Card(
margin: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
childrenPadding: const EdgeInsets.symmetric(horizontal: 16),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${group.name} · ${group.shiftLabel}',
style: textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
'In: ${group.checkIn} · Out: ${group.checkOut} · ${group.duration}',
style: textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
subtitle: Row(
children: [
Chip(
label: Text(group.status),
backgroundColor: statusColor.withValues(alpha: 0.15),
),
],
),
children: group.sessions.map((session) {
return _buildSessionTile(context, session);
}).toList(),
),
);
}
Widget _buildSessionTile(BuildContext context, _LogbookEntry entry) {
final colors = Theme.of(context).colorScheme;
final isReverifyable = entry.canReverify(currentUserId);
final isLeaveOrAbsent = entry.isLeave || entry.isAbsent;
return ListTile(
title: Row(
children: [
Expanded(child: Text(entry.name)),
if (!isLeaveOrAbsent) _verificationBadge(context, entry),
if (isReverifyable)
IconButton(
icon: Icon(
Icons.refresh,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
tooltip: 'Re-verify',
onPressed: () => onReverify(entry.logId!),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
visualDensity: VisualDensity.compact,
),
],
),
subtitle: Text(
entry.isLeave
? 'On Leave${entry.leaveType != null ? '${_leaveLabel(entry.leaveType!)}' : ''}'
: entry.isAbsent
? 'Absent — no check-in recorded'
: 'In: ${entry.checkIn} · Out: ${entry.checkOut} · ${entry.duration}${entry.status == 'On duty' ? ' · On duty' : ''}',
),
trailing: isLeaveOrAbsent
? Chip(
label: Text(entry.isLeave ? 'On Leave' : 'Absent'),
backgroundColor: entry.isLeave
? Colors.teal.withValues(alpha: 0.15)
: colors.errorContainer,
)
: entry.status == 'On duty'
? Chip(
label: const Text('On duty'),
backgroundColor: colors.tertiaryContainer,
)
: Text(entry.duration, style: Theme.of(context).textTheme.bodySmall),
);
}
/// Verification badge for logbook entries.
Widget _verificationBadge(BuildContext context, _LogbookEntry entry) {
if (entry.isAbsent || entry.isLeave || entry.verificationStatus == null) {
return const SizedBox.shrink();
}
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
IconData icon;
Color color;
String tooltip;
switch (entry.verificationStatus) {
case 'verified':
icon = Icons.verified;
color = Colors.green;
tooltip = 'Verified';
case 'unverified' || 'skipped':
icon = Icons.warning_amber_rounded;
color = colors.error;
tooltip = 'Unverified';
default:
icon = Icons.hourglass_bottom;
color = Colors.orange;
tooltip = 'Pending';
}
final badge = Tooltip(
message: tooltip,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 4),
Text(tooltip, style: textTheme.labelSmall?.copyWith(color: color)),
],
),
),
);
final canOpenDetails =
entry.logId != null &&
(entry.verificationStatus == 'verified' ||
entry.verificationStatus == 'unverified' ||
entry.verificationStatus == 'skipped');
if (!canOpenDetails) return badge;
return InkWell(
onTap: () => _showVerificationDetails(context, entry),
borderRadius: BorderRadius.circular(8),
child: badge,
);
}
void _showVerificationDetails(BuildContext context, _LogbookEntry entry) {
final isMobile = MediaQuery.sizeOf(context).width < 700;
if (isMobile) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
useSafeArea: true,
builder: (ctx) => DraggableScrollableSheet(
initialChildSize: 0.9,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (_, scrollController) => SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
child: _VerificationDetailsContent(
entry: entry,
fixedTabHeight: 440,
),
),
),
);
return;
}
m3ShowDialog(
context: context,
builder: (ctx) => Dialog(
child: SizedBox(
width: 980,
height: 680,
child: Padding(
padding: const EdgeInsets.all(16),
child: _VerificationDetailsContent(entry: entry),
),
),
),
);
}
static String _leaveLabel(String leaveType) {
switch (leaveType) {
case 'emergency_leave':
return 'Emergency';
case 'parental_leave':
return 'Parental';
case 'sick_leave':
return 'Sick';
case 'vacation_leave':
return 'Vacation';
default:
return leaveType;
}
}
}
class _VerificationDetailsContent extends StatelessWidget {
const _VerificationDetailsContent({required this.entry, this.fixedTabHeight});
final _LogbookEntry entry;
final double? fixedTabHeight;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
final hasJustification =
(entry.justification ?? '').trim().isNotEmpty ||
(entry.checkOutJustification ?? '').trim().isNotEmpty;
return DefaultTabController(
length: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${entry.name} · ${AppTime.formatDate(entry.date)}',
style: textTheme.titleMedium,
),
const SizedBox(height: 6),
Text(
'Shift: ${entry.shift}',
style: textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
// Justification section
if (hasJustification) ...[
const SizedBox(height: 12),
_JustificationSection(entry: entry),
],
const SizedBox(height: 12),
const TabBar(
tabs: [
Tab(text: 'Face Verification'),
Tab(text: 'Check In / Out Map'),
],
),
const SizedBox(height: 12),
if (fixedTabHeight != null)
SizedBox(
height: fixedTabHeight,
child: TabBarView(
children: [
_FaceVerificationTab(entry: entry),
_CheckInOutMapTab(entry: entry),
],
),
)
else
Expanded(
child: TabBarView(
children: [
_FaceVerificationTab(entry: entry),
_CheckInOutMapTab(entry: entry),
],
),
),
],
),
);
}
}
/// Displays justification notes for overtime check-in and/or off-site checkout.
class _JustificationSection extends StatelessWidget {
const _JustificationSection({required this.entry});
final _LogbookEntry entry;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final overtimeJustification = (entry.justification ?? '').trim();
final checkOutJustification = (entry.checkOutJustification ?? '').trim();
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.tertiaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.notes_rounded, size: 16, color: colors.tertiary),
const SizedBox(width: 6),
Text(
'Justification',
style: textTheme.labelMedium?.copyWith(
color: colors.tertiary,
fontWeight: FontWeight.w600,
),
),
],
),
if (overtimeJustification.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Overtime:',
style: textTheme.labelSmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(overtimeJustification, style: textTheme.bodySmall),
],
if (checkOutJustification.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Check-out (outside geofence):',
style: textTheme.labelSmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(checkOutJustification, style: textTheme.bodySmall),
],
],
),
);
}
}
class _FaceVerificationTab extends StatefulWidget {
const _FaceVerificationTab({required this.entry});
final _LogbookEntry entry;
@override
State<_FaceVerificationTab> createState() => _FaceVerificationTabState();
}
class _FaceVerificationTabState extends State<_FaceVerificationTab> {
late final PageController _pageController;
int _currentPage = 0;
static const _labels = ['Check-In Verification', 'Check-Out Verification'];
@override
void initState() {
super.initState();
_pageController = PageController();
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final entry = widget.entry;
return Column(
children: [
Expanded(
child: PageView(
controller: _pageController,
onPageChanged: (i) => setState(() => _currentPage = i),
children: [
// Page 1: Enrolled Face + Check-In Verification
_SideBySidePanel(
enrolledFaceUrl: entry.enrolledFaceUrl,
verificationUrl: entry.checkInVerificationFaceUrl,
verificationLabel: 'Check-In Verification',
verificationIcon: Icons.login_rounded,
emptyMessage: 'No check-in verification photo.',
),
// Page 2: Enrolled Face + Check-Out Verification
_SideBySidePanel(
enrolledFaceUrl: entry.enrolledFaceUrl,
verificationUrl: entry.checkOutVerificationFaceUrl,
verificationLabel: 'Check-Out Verification',
verificationIcon: Icons.logout_rounded,
emptyMessage: 'No check-out verification photo.',
),
],
),
),
const SizedBox(height: 12),
// Label
AnimatedSwitcher(
duration: M3Motion.micro,
child: Text(
_labels[_currentPage],
key: ValueKey(_currentPage),
style: textTheme.labelMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
const SizedBox(height: 8),
// Page indicator dots
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_labels.length, (i) {
final isActive = i == _currentPage;
return GestureDetector(
onTap: () => _pageController.animateToPage(
i,
duration: M3Motion.standard,
curve: M3Motion.standard_,
),
child: AnimatedContainer(
duration: M3Motion.short,
curve: M3Motion.standard_,
margin: const EdgeInsets.symmetric(horizontal: 4),
width: isActive ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: isActive
? colors.primary
: colors.onSurfaceVariant.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(4),
),
),
);
}),
),
const SizedBox(height: 4),
],
);
}
}
/// Side-by-side panel: enrolled face on the left, verification photo on the right.
class _SideBySidePanel extends StatelessWidget {
const _SideBySidePanel({
required this.enrolledFaceUrl,
required this.verificationUrl,
required this.verificationLabel,
required this.verificationIcon,
required this.emptyMessage,
});
final String? enrolledFaceUrl;
final String? verificationUrl;
final String verificationLabel;
final IconData verificationIcon;
final String emptyMessage;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final panelWidth = constraints.maxWidth >= 760
? (constraints.maxWidth - 20) / 2
: (constraints.maxWidth - 20) / 2;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
children: [
Expanded(
child: _ImagePanel(
width: panelWidth,
title: 'Enrolled Face',
imageUrl: enrolledFaceUrl,
bucket: 'face-enrollment',
emptyMessage: 'No enrolled face photo found.',
icon: Icons.person,
),
),
const SizedBox(width: 8),
Expanded(
child: _ImagePanel(
width: panelWidth,
title: verificationLabel,
imageUrl: verificationUrl,
bucket: 'attendance-verification',
emptyMessage: emptyMessage,
icon: verificationIcon,
),
),
],
),
);
},
);
}
}
class _CheckInOutMapTab extends StatelessWidget {
const _CheckInOutMapTab({required this.entry});
final _LogbookEntry entry;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final panelWidth = constraints.maxWidth >= 760
? (constraints.maxWidth - 12) / 2
: 300.0;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
SizedBox(
width: panelWidth,
child: _MapPanel(
title: 'Check In',
lat: entry.checkInLat,
lng: entry.checkInLng,
markerColor: Colors.green,
),
),
const SizedBox(width: 12),
SizedBox(
width: panelWidth,
child: _MapPanel(
title: 'Check Out',
lat: entry.checkOutLat,
lng: entry.checkOutLng,
markerColor: Colors.red,
),
),
],
),
);
},
);
}
}
class _ImagePanel extends StatelessWidget {
const _ImagePanel({
required this.width,
required this.title,
required this.imageUrl,
required this.bucket,
required this.emptyMessage,
required this.icon,
});
final double width;
final String title;
final String? imageUrl;
final String bucket;
final String emptyMessage;
final IconData icon;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final hasImage = imageUrl != null && imageUrl!.isNotEmpty;
return Card(
child: SizedBox(
width: width,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: colors.primary),
const SizedBox(width: 6),
Text(title, style: Theme.of(context).textTheme.titleSmall),
],
),
const SizedBox(height: 12),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: hasImage
? _SignedBucketImage(
sourceUrl: imageUrl!,
bucket: bucket,
emptyMessage: emptyMessage,
)
: _EmptyPanelState(message: emptyMessage),
),
),
],
),
),
),
);
}
}
class _SignedBucketImage extends StatefulWidget {
const _SignedBucketImage({
required this.sourceUrl,
required this.bucket,
required this.emptyMessage,
});
final String sourceUrl;
final String bucket;
final String emptyMessage;
@override
State<_SignedBucketImage> createState() => _SignedBucketImageState();
}
class _SignedBucketImageState extends State<_SignedBucketImage> {
late final Future<String?> _resolvedUrlFuture;
@override
void initState() {
super.initState();
_resolvedUrlFuture = _resolveAccessibleUrl(
sourceUrl: widget.sourceUrl,
bucket: widget.bucket,
);
}
Future<String?> _resolveAccessibleUrl({
required String sourceUrl,
required String bucket,
}) async {
final trimmed = sourceUrl.trim();
if (trimmed.isEmpty) return null;
// If already signed, keep it as-is.
if (trimmed.contains('/storage/v1/object/sign/')) {
return trimmed;
}
String? storagePath;
// Handle full storage URLs and extract the object path.
final bucketToken = '/$bucket/';
final bucketIndex = trimmed.indexOf(bucketToken);
if (bucketIndex >= 0) {
storagePath = trimmed.substring(bucketIndex + bucketToken.length);
final queryIndex = storagePath.indexOf('?');
if (queryIndex >= 0) {
storagePath = storagePath.substring(0, queryIndex);
}
}
// If DB already stores a direct object path, use it directly.
storagePath ??= trimmed.startsWith('http') ? null : trimmed;
if (storagePath == null || storagePath.isEmpty) {
return trimmed;
}
try {
return await Supabase.instance.client.storage
.from(bucket)
.createSignedUrl(storagePath, 3600);
} catch (_) {
// Fall back to original URL so public buckets still work.
return trimmed;
}
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return FutureBuilder<String?>(
future: _resolvedUrlFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
color: colors.surfaceContainerLow,
alignment: Alignment.center,
child: const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
final resolvedUrl = snapshot.data;
if (resolvedUrl == null || resolvedUrl.isEmpty) {
return _EmptyPanelState(message: widget.emptyMessage);
}
return Container(
color: colors.surfaceContainerLow,
alignment: Alignment.center,
child: Image.network(
resolvedUrl,
fit: BoxFit.contain,
errorBuilder: (context, error, stack) {
return _EmptyPanelState(message: widget.emptyMessage);
},
),
);
},
);
}
}
class _MapPanel extends StatelessWidget {
const _MapPanel({
required this.title,
required this.lat,
required this.lng,
required this.markerColor,
});
final String title;
final double? lat;
final double? lng;
final Color markerColor;
@override
Widget build(BuildContext context) {
final hasCoords = lat != null && lng != null;
final center = hasCoords
? LatLng(lat!, lng!)
: const LatLng(7.2046, 124.2460);
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
Text(
hasCoords
? '${lat!.toStringAsFixed(6)}, ${lng!.toStringAsFixed(6)}'
: 'No location captured.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 10),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: hasCoords
? FlutterMap(
options: MapOptions(
initialCenter: center,
initialZoom: 16,
interactionOptions: const InteractionOptions(
flags:
InteractiveFlag.pinchZoom |
InteractiveFlag.drag |
InteractiveFlag.doubleTapZoom,
),
),
children: [
TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.tasq.app',
),
MarkerLayer(
markers: [
Marker(
width: 48,
height: 48,
point: center,
child: Icon(
Icons.location_pin,
color: markerColor,
size: 36,
),
),
],
),
],
)
: const _EmptyPanelState(
message: 'Location is unavailable for this event.',
),
),
),
],
),
),
);
}
}
class _EmptyPanelState extends StatelessWidget {
const _EmptyPanelState({required this.message});
final String message;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Container(
color: colors.surfaceContainerLow,
alignment: Alignment.center,
padding: const EdgeInsets.all(16),
child: Text(
message,
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant),
),
);
}
}
// ────────────────────────────────────────────────
// Date filter dialog (reuses Metabase-style pattern)
// ────────────────────────────────────────────────
class _AttendanceDateFilterDialog extends StatefulWidget {
const _AttendanceDateFilterDialog({
required this.current,
required this.onApply,
});
final ReportDateRange current;
final ValueChanged<ReportDateRange> onApply;
@override
State<_AttendanceDateFilterDialog> createState() =>
_AttendanceDateFilterDialogState();
}
class _AttendanceDateFilterDialogState
extends State<_AttendanceDateFilterDialog>
with SingleTickerProviderStateMixin {
late TabController _tabCtrl;
int _relativeAmount = 7;
String _relativeUnit = 'days';
DateTime? _customStart;
DateTime? _customEnd;
static const _units = ['days', 'weeks', 'months', 'quarters', 'years'];
@override
void initState() {
super.initState();
_tabCtrl = TabController(length: 3, vsync: this);
_customStart = widget.current.start;
_customEnd = widget.current.end;
}
@override
void dispose() {
_tabCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final text = theme.textTheme;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440, maxHeight: 520),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
child: Row(
children: [
Icon(Icons.date_range, color: colors.primary),
const SizedBox(width: 8),
Text('Filter Date Range', style: text.titleMedium),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.pop(context),
),
],
),
),
TabBar(
controller: _tabCtrl,
labelStyle: text.labelMedium,
tabs: const [
Tab(text: 'Presets'),
Tab(text: 'Relative'),
Tab(text: 'Custom'),
],
),
Flexible(
child: TabBarView(
controller: _tabCtrl,
children: [
_buildPresetsTab(context),
_buildRelativeTab(context),
_buildCustomTab(context),
],
),
),
],
),
),
);
}
Widget _buildPresetsTab(BuildContext context) {
final now = AppTime.now();
final today = DateTime(now.year, now.month, now.day);
final presets = <_Preset>[
_Preset('Today', today, today.add(const Duration(days: 1))),
_Preset('Yesterday', today.subtract(const Duration(days: 1)), today),
_Preset(
'Last 7 Days',
today.subtract(const Duration(days: 7)),
today.add(const Duration(days: 1)),
),
_Preset(
'Last 30 Days',
today.subtract(const Duration(days: 30)),
today.add(const Duration(days: 1)),
),
_Preset(
'Last 90 Days',
today.subtract(const Duration(days: 90)),
today.add(const Duration(days: 1)),
),
_Preset(
'This Week',
today.subtract(Duration(days: today.weekday - 1)),
today.add(const Duration(days: 1)),
),
_Preset(
'This Month',
DateTime(now.year, now.month, 1),
DateTime(now.year, now.month + 1, 1),
),
_Preset(
'This Year',
DateTime(now.year, 1, 1),
DateTime(now.year + 1, 1, 1),
),
];
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: presets.map((p) {
final isSelected = widget.current.label == p.label;
return ChoiceChip(
label: Text(p.label),
selected: isSelected,
onSelected: (_) {
widget.onApply(
ReportDateRange(start: p.start, end: p.end, label: p.label),
);
Navigator.pop(context);
},
);
}).toList(),
),
);
}
Widget _buildRelativeTab(BuildContext context) {
final theme = Theme.of(context);
final text = theme.textTheme;
final preview = _computeRelativeRange(_relativeAmount, _relativeUnit);
final previewText = AppTime.formatDateRange(preview.dateTimeRange);
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Previous', style: text.labelLarge),
const SizedBox(height: 12),
Row(
children: [
SizedBox(
width: 80,
child: TextFormField(
initialValue: _relativeAmount.toString(),
keyboardType: TextInputType.number,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
onChanged: (v) => setState(() {
_relativeAmount = int.tryParse(v) ?? _relativeAmount;
}),
),
),
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<String>(
initialValue: _relativeUnit,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
items: _units
.map((u) => DropdownMenuItem(value: u, child: Text(u)))
.toList(),
onChanged: (v) {
if (v != null) setState(() => _relativeUnit = v);
},
),
),
const SizedBox(width: 12),
Text('ago', style: text.bodyLarge),
],
),
const SizedBox(height: 16),
Text(
previewText,
style: text.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: () {
widget.onApply(preview);
Navigator.pop(context);
},
child: const Text('Apply'),
),
),
],
),
);
}
ReportDateRange _computeRelativeRange(int amount, String unit) {
final now = AppTime.now();
final end = now;
DateTime start;
switch (unit) {
case 'days':
start = now.subtract(Duration(days: amount));
case 'weeks':
start = now.subtract(Duration(days: amount * 7));
case 'months':
start = DateTime(now.year, now.month - amount, now.day);
case 'quarters':
start = DateTime(now.year, now.month - (amount * 3), now.day);
case 'years':
start = DateTime(now.year - amount, now.month, now.day);
default:
start = now.subtract(Duration(days: amount));
}
return ReportDateRange(start: start, end: end, label: 'Last $amount $unit');
}
Widget _buildCustomTab(BuildContext context) {
final theme = Theme.of(context);
final text = theme.textTheme;
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Start Date', style: text.labelLarge),
const SizedBox(height: 8),
OutlinedButton.icon(
icon: const Icon(Icons.calendar_today, size: 16),
label: Text(
_customStart != null
? AppTime.formatDate(_customStart!)
: 'Select start date',
),
onPressed: () async {
final picked = await showDatePicker(
context: context,
initialDate: _customStart ?? AppTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2030),
);
if (picked != null) setState(() => _customStart = picked);
},
),
const SizedBox(height: 16),
Text('End Date', style: text.labelLarge),
const SizedBox(height: 8),
OutlinedButton.icon(
icon: const Icon(Icons.calendar_today, size: 16),
label: Text(
_customEnd != null
? AppTime.formatDate(_customEnd!)
: 'Select end date',
),
onPressed: () async {
final picked = await showDatePicker(
context: context,
initialDate: _customEnd ?? AppTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2030),
);
if (picked != null) setState(() => _customEnd = picked);
},
),
const SizedBox(height: 20),
Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: (_customStart != null && _customEnd != null)
? () {
widget.onApply(
ReportDateRange(
start: _customStart!,
end: _customEnd!.add(const Duration(days: 1)),
label: 'Custom',
),
);
Navigator.pop(context);
}
: null,
child: const Text('Apply'),
),
),
],
),
);
}
}
class _Preset {
const _Preset(this.label, this.start, this.end);
final String label;
final DateTime start;
final DateTime end;
}
// ────────────────────────────────────────────────
// Tab 3 Pass Slip
// ────────────────────────────────────────────────
class _PassSlipTab extends ConsumerStatefulWidget {
const _PassSlipTab();
@override
ConsumerState<_PassSlipTab> createState() => _PassSlipTabState();
}
class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
bool _submitting = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final profile = ref.watch(currentProfileProvider).valueOrNull;
final slipsAsync = ref.watch(passSlipsProvider);
final profilesAsync = ref.watch(profilesProvider);
final activeSlip = ref.watch(activePassSlipProvider);
final isAdmin = profile?.role == 'admin' || profile?.role == 'dispatcher';
final Map<String, Profile> profileById = {
for (final p in profilesAsync.valueOrNull ?? []) p.id: p,
};
if (profile == null) {
return const Center(child: CircularProgressIndicator());
}
return slipsAsync.when(
data: (slips) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Active slip banner
if (activeSlip != null) ...[
Card(
color: activeSlip.isExceeded
? colors.errorContainer
: colors.tertiaryContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
activeSlip.isExceeded
? Icons.warning
: Icons.directions_walk,
color: activeSlip.isExceeded
? colors.onErrorContainer
: colors.onTertiaryContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
activeSlip.isExceeded
? 'Pass Slip Exceeded (>1 hour)'
: 'Active Pass Slip',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: activeSlip.isExceeded
? colors.onErrorContainer
: colors.onTertiaryContainer,
),
),
),
],
),
const SizedBox(height: 8),
Text(
'Reason: ${activeSlip.reason}',
style: theme.textTheme.bodyMedium,
),
if (activeSlip.slipStart != null)
Text(
'Started: ${AppTime.formatTime(activeSlip.slipStart!)}',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _submitting
? null
: () => _completeSlip(activeSlip.id),
icon: const Icon(Icons.check),
label: const Text('Complete / Return'),
),
),
],
),
),
),
const SizedBox(height: 16),
],
// Request form removed — use FAB instead
// Pending slips for admin approval
if (isAdmin) ...[
Text(
'Pending Approvals',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
...slips
.where((s) => s.status == 'pending')
.map(
(slip) => _buildSlipCard(
context,
slip,
profileById,
showActions: true,
),
),
if (slips.where((s) => s.status == 'pending').isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'No pending pass slip requests.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
const SizedBox(height: 16),
],
// History
Text(
'Pass Slip History',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
...slips
.where((s) => s.status != 'pending' || !isAdmin)
.take(50)
.map(
(slip) => _buildSlipCard(
context,
slip,
profileById,
showActions: false,
),
),
if (slips.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(
'No pass slip records.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
),
],
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Failed to load pass slips: $e')),
);
}
Widget _buildSlipCard(
BuildContext context,
PassSlip slip,
Map<String, Profile> profileById, {
required bool showActions,
}) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final p = profileById[slip.userId];
final name = p?.fullName ?? slip.userId;
Color statusColor;
switch (slip.status) {
case 'approved':
statusColor = Colors.green;
case 'rejected':
statusColor = colors.error;
case 'completed':
statusColor = colors.primary;
default:
statusColor = Colors.orange;
}
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: Text(name, style: theme.textTheme.titleSmall)),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
slip.status.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
color: statusColor,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 4),
Text(slip.reason, style: theme.textTheme.bodyMedium),
Text(
'Requested: ${AppTime.formatDate(slip.requestedAt)} ${AppTime.formatTime(slip.requestedAt)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
if (slip.slipStart != null)
Text(
'Started: ${AppTime.formatTime(slip.slipStart!)}'
'${slip.slipEnd != null ? " · Ended: ${AppTime.formatTime(slip.slipEnd!)}" : ""}',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
if (showActions && slip.status == 'pending') ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _submitting ? null : () => _rejectSlip(slip.id),
child: Text(
'Reject',
style: TextStyle(color: colors.error),
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _submitting ? null : () => _approveSlip(slip.id),
child: const Text('Approve'),
),
],
),
],
],
),
),
);
}
Future<void> _approveSlip(String slipId) async {
setState(() => _submitting = true);
try {
await ref.read(passSlipControllerProvider).approveSlip(slipId);
if (mounted) {
showSuccessSnackBar(context, 'Pass slip approved.');
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Failed: $e');
}
} finally {
if (mounted) setState(() => _submitting = false);
}
}
Future<void> _rejectSlip(String slipId) async {
setState(() => _submitting = true);
try {
await ref.read(passSlipControllerProvider).rejectSlip(slipId);
if (mounted) {
showSuccessSnackBar(context, 'Pass slip rejected.');
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Failed: $e');
}
} finally {
if (mounted) setState(() => _submitting = false);
}
}
Future<void> _completeSlip(String slipId) async {
setState(() => _submitting = true);
try {
await ref.read(passSlipControllerProvider).completeSlip(slipId);
if (mounted) {
showSuccessSnackBar(context, 'Pass slip completed.');
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Failed: $e');
}
} finally {
if (mounted) setState(() => _submitting = false);
}
}
}
// ────────────────────────────────────────────────
// Tab 4 Leave of Absence
// ────────────────────────────────────────────────
class _LeaveTab extends ConsumerStatefulWidget {
const _LeaveTab();
@override
ConsumerState<_LeaveTab> createState() => _LeaveTabState();
}
class _LeaveTabState extends ConsumerState<_LeaveTab> {
bool _submitting = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final profile = ref.watch(currentProfileProvider).valueOrNull;
final leavesAsync = ref.watch(leavesProvider);
final profilesAsync = ref.watch(profilesProvider);
if (profile == null) {
return const Center(child: CircularProgressIndicator());
}
final isAdmin = profile.role == 'admin';
final profiles = profilesAsync.valueOrNull ?? [];
final profileById = {for (final p in profiles) p.id: p};
return leavesAsync.when(
data: (leaves) {
final myLeaves = leaves.where((l) => l.userId == profile.id).toList();
final pendingApprovals = isAdmin
? leaves
.where((l) => l.status == 'pending' && l.userId != profile.id)
.toList()
: <LeaveOfAbsence>[];
return ListView(
padding: const EdgeInsets.all(16),
children: [
// ── Pending Approvals (admin only) ──
if (isAdmin) ...[
Text(
'Pending Approvals',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
if (pendingApprovals.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'No pending leave requests.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
...pendingApprovals.map(
(leave) => _buildLeaveCard(
context,
leave,
profileById,
showApproval: true,
),
),
const SizedBox(height: 24),
],
// ── My Leave Applications ──
Text(
'My Leave Applications',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
if (myLeaves.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(
'You have no leave applications.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
),
...myLeaves
.take(50)
.map(
(leave) => _buildLeaveCard(
context,
leave,
profileById,
showApproval: false,
),
),
// ── All Leave History (admin only) ──
if (isAdmin) ...[
const SizedBox(height: 24),
Text(
'All Leave History',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
...leaves
.where((l) => l.status != 'pending' && l.userId != profile.id)
.take(50)
.map(
(leave) => _buildLeaveCard(
context,
leave,
profileById,
showApproval: false,
),
),
if (leaves
.where((l) => l.status != 'pending' && l.userId != profile.id)
.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'No leave history from other staff.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
],
const SizedBox(height: 80),
],
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Failed to load leaves: $e')),
);
}
Widget _buildLeaveCard(
BuildContext context,
LeaveOfAbsence leave,
Map<String, Profile> profileById, {
required bool showApproval,
}) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final p = profileById[leave.userId];
final name = p?.fullName ?? leave.userId;
Color statusColor;
switch (leave.status) {
case 'approved':
statusColor = Colors.teal;
case 'rejected':
statusColor = colors.error;
case 'cancelled':
statusColor = colors.onSurfaceVariant;
default:
statusColor = Colors.orange;
}
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: Text(name, style: theme.textTheme.titleSmall)),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
leave.status.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
color: statusColor,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 4),
Text(
leave.leaveTypeLabel,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(leave.justification, style: theme.textTheme.bodyMedium),
Text(
'${AppTime.formatDate(leave.startTime)} '
'${AppTime.formatTime(leave.startTime)} '
'${AppTime.formatTime(leave.endTime)}',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
// Approve / Reject for admins on pending leaves
if (showApproval && leave.status == 'pending') ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _submitting
? null
: () => _rejectLeave(leave.id),
child: Text(
'Reject',
style: TextStyle(color: colors.error),
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _submitting
? null
: () => _approveLeave(leave.id),
child: const Text('Approve'),
),
],
),
],
// Cancel future approved leaves:
// - user can cancel own
// - admin can cancel anyone
if (!showApproval && _canCancelFutureApproved(leave)) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: _submitting ? null : () => _cancelLeave(leave.id),
child: Text('Cancel', style: TextStyle(color: colors.error)),
),
),
],
],
),
),
);
}
bool _canCancelFutureApproved(LeaveOfAbsence leave) {
if (leave.status != 'approved' || !leave.startTime.isAfter(AppTime.now())) {
return false;
}
final profile = ref.read(currentProfileProvider).valueOrNull;
if (profile == null) return false;
final isAdmin = profile.role == 'admin';
return isAdmin || leave.userId == profile.id;
}
Future<void> _approveLeave(String leaveId) async {
setState(() => _submitting = true);
try {
await ref.read(leaveControllerProvider).approveLeave(leaveId);
ref.invalidate(leavesProvider);
ref.invalidate(dashboardMetricsProvider);
if (mounted) {
showSuccessSnackBar(context, 'Leave approved.');
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Failed: $e');
}
} finally {
if (mounted) setState(() => _submitting = false);
}
}
Future<void> _rejectLeave(String leaveId) async {
setState(() => _submitting = true);
try {
await ref.read(leaveControllerProvider).rejectLeave(leaveId);
ref.invalidate(leavesProvider);
ref.invalidate(dashboardMetricsProvider);
if (mounted) {
showSuccessSnackBar(context, 'Leave rejected.');
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Failed: $e');
}
} finally {
if (mounted) setState(() => _submitting = false);
}
}
Future<void> _cancelLeave(String leaveId) async {
setState(() => _submitting = true);
try {
await ref.read(leaveControllerProvider).cancelLeave(leaveId);
ref.invalidate(leavesProvider);
ref.invalidate(dashboardMetricsProvider);
if (mounted) {
showSuccessSnackBar(context, 'Leave cancelled.');
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Failed: $e');
}
} finally {
if (mounted) setState(() => _submitting = false);
}
}
}
// ─── FAB Menu Item ──────────────────────────────────────────────
class _FabMenuItem extends StatelessWidget {
const _FabMenuItem({
required this.heroTag,
required this.label,
required this.icon,
required this.color,
required this.onColor,
required this.onTap,
});
final String heroTag;
final String label;
final IconData icon;
final Color color;
final Color onColor;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Material(
color: color,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(
label,
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(color: onColor),
),
),
),
const SizedBox(width: 12),
FloatingActionButton.small(
heroTag: heroTag,
backgroundColor: color,
foregroundColor: onColor,
onPressed: onTap,
child: Icon(icon),
),
],
);
}
}
// ─── Pass Slip Dialog (with Gemini) ─────────────────────────────
class _PassSlipDialog extends ConsumerStatefulWidget {
const _PassSlipDialog({required this.scheduleId, required this.onSubmitted});
final String scheduleId;
final VoidCallback onSubmitted;
@override
ConsumerState<_PassSlipDialog> createState() => _PassSlipDialogState();
}
class _PassSlipDialogState extends ConsumerState<_PassSlipDialog> {
final _reasonController = TextEditingController();
bool _submitting = false;
bool _isGeminiProcessing = false;
@override
void dispose() {
_reasonController.dispose();
super.dispose();
}
Future<void> _submit() async {
final reason = _reasonController.text.trim();
if (reason.isEmpty) {
showWarningSnackBar(context, 'Please enter a reason.');
return;
}
setState(() => _submitting = true);
try {
await ref
.read(passSlipControllerProvider)
.requestSlip(dutyScheduleId: widget.scheduleId, reason: reason);
if (mounted) {
Navigator.of(context).pop();
widget.onSubmitted();
}
} catch (e) {
if (mounted) showErrorSnackBar(context, 'Failed: $e');
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Request Pass Slip', style: theme.textTheme.headlineSmall),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: GeminiAnimatedTextField(
controller: _reasonController,
labelText: 'Reason',
maxLines: 3,
enabled: !_submitting,
isProcessing: _isGeminiProcessing,
),
),
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: GeminiButton(
textController: _reasonController,
onTextUpdated: (text) {
setState(() => _reasonController.text = text);
},
onProcessingStateChanged: (processing) {
setState(() => _isGeminiProcessing = processing);
},
tooltip: 'Translate/Enhance with AI',
promptBuilder: (_) =>
'Translate this sentence to clear professional English '
'if needed, and enhance grammar/clarity while preserving '
'the original meaning. Return ONLY the improved text, '
'with no explanations, no recommendations, and no extra context.',
),
),
],
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _submitting
? null
: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Submit'),
),
],
),
],
),
),
),
);
}
}
// ─── File Leave Dialog ──────────────────────────────────────────
class _FileLeaveDialog extends ConsumerStatefulWidget {
const _FileLeaveDialog({required this.isAdmin, required this.onSubmitted});
final bool isAdmin;
final VoidCallback onSubmitted;
@override
ConsumerState<_FileLeaveDialog> createState() => _FileLeaveDialogState();
}
class _FileLeaveDialogState extends ConsumerState<_FileLeaveDialog> {
final _justificationController = TextEditingController();
bool _submitting = false;
bool _isGeminiProcessing = false;
String _leaveType = 'emergency_leave';
DateTime? _startDate;
TimeOfDay? _startTime;
TimeOfDay? _endTime;
static const _leaveTypes = {
'emergency_leave': 'Emergency Leave',
'parental_leave': 'Parental Leave',
'sick_leave': 'Sick Leave',
'vacation_leave': 'Vacation Leave',
};
@override
void dispose() {
_justificationController.dispose();
super.dispose();
}
void _autoFillShiftTimes(List<DutySchedule> schedules, String userId) {
if (_startDate == null) return;
final day = DateTime(_startDate!.year, _startDate!.month, _startDate!.day);
final match = schedules.where((s) {
final sDay = DateTime(
s.startTime.year,
s.startTime.month,
s.startTime.day,
);
return s.userId == userId && sDay == day;
}).toList();
if (match.isNotEmpty) {
setState(() {
_startTime = TimeOfDay.fromDateTime(match.first.startTime);
_endTime = TimeOfDay.fromDateTime(match.first.endTime);
});
}
}
Future<void> _pickDate() async {
final now = AppTime.now();
final today = DateTime(now.year, now.month, now.day);
final picked = await showDatePicker(
context: context,
initialDate: _startDate ?? today,
firstDate: today,
lastDate: today.add(const Duration(days: 365)),
);
if (picked != null) {
setState(() => _startDate = picked);
final profile = ref.read(currentProfileProvider).valueOrNull;
if (profile != null) {
final schedules = ref.read(dutySchedulesProvider).valueOrNull ?? [];
_autoFillShiftTimes(schedules, profile.id);
}
}
}
Future<void> _pickStartTime() async {
final picked = await showTimePicker(
context: context,
initialTime: _startTime ?? const TimeOfDay(hour: 8, minute: 0),
);
if (picked != null) setState(() => _startTime = picked);
}
Future<void> _pickEndTime() async {
final picked = await showTimePicker(
context: context,
initialTime: _endTime ?? const TimeOfDay(hour: 17, minute: 0),
);
if (picked != null) setState(() => _endTime = picked);
}
Future<void> _submit() async {
if (_startDate == null) {
showWarningSnackBar(context, 'Please select a date.');
return;
}
if (_startTime == null || _endTime == null) {
showWarningSnackBar(context, 'Please set start and end times.');
return;
}
if (_justificationController.text.trim().isEmpty) {
showWarningSnackBar(context, 'Please enter a justification.');
return;
}
var startDt = DateTime(
_startDate!.year,
_startDate!.month,
_startDate!.day,
_startTime!.hour,
_startTime!.minute,
);
var endDt = DateTime(
_startDate!.year,
_startDate!.month,
_startDate!.day,
_endTime!.hour,
_endTime!.minute,
);
if (!endDt.isAfter(startDt)) {
showWarningSnackBar(context, 'End time must be after start time.');
return;
}
// convert to app timezone to avoid device-local mismatches
startDt = AppTime.toAppTime(startDt);
endDt = AppTime.toAppTime(endDt);
setState(() => _submitting = true);
try {
await ref
.read(leaveControllerProvider)
.fileLeave(
leaveType: _leaveType,
justification: _justificationController.text.trim(),
startTime: startDt,
endTime: endDt,
autoApprove: widget.isAdmin,
);
// ensure UI and dashboard will refresh promptly even if realtime is
// delayed or temporarily disconnected
ref.invalidate(leavesProvider);
ref.invalidate(dashboardMetricsProvider);
if (mounted) {
Navigator.of(context).pop();
widget.onSubmitted();
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Failed to file leave: $e');
}
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Padding(
padding: const EdgeInsets.all(24),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'File Leave of Absence',
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 16),
// Leave type
DropdownButtonFormField<String>(
// ignore: deprecated_member_use
value: _leaveType,
decoration: const InputDecoration(labelText: 'Leave Type'),
items: _leaveTypes.entries
.map(
(e) => DropdownMenuItem(
value: e.key,
child: Text(e.value),
),
)
.toList(),
onChanged: (v) {
if (v != null) setState(() => _leaveType = v);
},
),
const SizedBox(height: 12),
// Date picker
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.calendar_today),
title: Text(
_startDate == null
? 'Select Date'
: AppTime.formatDate(_startDate!),
),
subtitle: const Text('Current or future dates only'),
onTap: _pickDate,
),
// Time range
Row(
children: [
Expanded(
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.access_time),
title: Text(
_startTime == null
? 'Start Time'
: _startTime!.format(context),
),
onTap: _pickStartTime,
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Icon(Icons.arrow_forward),
),
Expanded(
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.access_time),
title: Text(
_endTime == null
? 'End Time'
: _endTime!.format(context),
),
subtitle: const Text('From shift schedule'),
onTap: _pickEndTime,
),
),
],
),
const SizedBox(height: 12),
// Justification with AI
Row(
children: [
Expanded(
child: GeminiAnimatedTextField(
controller: _justificationController,
labelText: 'Justification',
maxLines: 3,
enabled: !_submitting,
isProcessing: _isGeminiProcessing,
),
),
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: GeminiButton(
textController: _justificationController,
onTextUpdated: (text) {
setState(() {
_justificationController.text = text;
});
},
onProcessingStateChanged: (processing) {
setState(() => _isGeminiProcessing = processing);
},
tooltip: 'Translate/Enhance with AI',
promptBuilder: (_) =>
'Translate this sentence to clear professional English '
'if needed, and enhance grammar/clarity while preserving '
'the original meaning. Return ONLY the improved text, '
'with no explanations, no recommendations, and no extra context.',
),
),
],
),
const SizedBox(height: 16),
if (widget.isAdmin)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'As admin, your leave will be auto-approved.',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.primary,
),
),
),
// Actions
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _submitting
? null
: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _submitting ? null : _submit,
icon: _submitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.event_busy),
label: const Text('File Leave'),
),
],
),
],
),
),
),
),
);
}
}