tasq/lib/screens/attendance/attendance_screen.dart

3318 lines
111 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:geolocator/geolocator.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 '../../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 '../../theme/m3_motion.dart';
import '../../utils/app_time.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/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;
@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';
final canFileLeave =
profile.role == 'admin' ||
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';
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;
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;
if (profile == null) {
return const Center(child: CircularProgressIndicator());
}
final now = _currentTime;
final today = DateTime(now.year, now.month, now.day);
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.
// Exclude overtime schedules they only belong in the Logbook.
final schedules = schedulesAsync.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();
// 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();
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: allowTracking,
onChanged: (v) =>
ref.read(whereaboutsControllerProvider).setTracking(v),
),
],
),
),
),
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
? Icons.check_circle
: Icons.cancel,
size: 16,
color: _insideGeofence
? Colors.green
: colors.error,
),
const SizedBox(width: 6),
Text(
_insideGeofence
? 'Within geofence'
: 'Outside geofence',
style: theme.textTheme.bodySmall?.copyWith(
color: _insideGeofence
? Colors.green
: colors.error,
fontWeight: FontWeight.w500,
),
),
if (!_insideGeofence) ...[
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 (todaySchedule.isEmpty &&
activeLog.isEmpty &&
_overtimeLogId == null)
_buildOvertimeCard(context, theme, colors)
else if (todaySchedule.isEmpty &&
(activeLog.isNotEmpty || _overtimeLogId != null))
_buildActiveOvertimeCard(context, theme, colors, activeLog)
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 &&
!_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 &&
!_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 {
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)
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)
.checkIn(
dutyScheduleId: schedule.id,
lat: position.latitude,
lng: position.longitude,
);
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 {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
);
await ref
.read(attendanceControllerProvider)
.checkOut(
attendanceId: log.id,
lat: position.latitude,
lng: position.longitude,
);
if (mounted) {
setState(() {
if (scheduleId != null) {
_justCheckedIn.remove(scheduleId);
_checkInLogIds.remove(scheduleId);
}
_overtimeLogId = null;
});
showSuccessSnackBar(context, 'Checked out! Running verification...');
_performFaceVerification(log.id);
}
} 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 {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
);
await ref
.read(attendanceControllerProvider)
.checkOut(
attendanceId: logId,
lat: position.latitude,
lng: position.longitude,
);
if (mounted) {
setState(() {
if (scheduleId != null) {
_justCheckedIn.remove(scheduleId);
_checkInLogIds.remove(scheduleId);
}
_overtimeLogId = null;
});
showSuccessSnackBar(context, 'Checked out! Running verification...');
_performFaceVerification(logId);
}
} catch (e) {
if (mounted) {
showErrorSnackBar(context, 'Check-out failed: $e');
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
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) 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,
);
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 schedule — offers overtime check-in.
Widget _buildOvertimeCard(
BuildContext context,
ThemeData theme,
ColorScheme colors,
) {
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(
'No schedule assigned for today.',
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';
default:
return shiftType;
}
}
}
// ────────────────────────────────────────────────
// Unified logbook entry (real log or absent schedule)
// ────────────────────────────────────────────────
class _LogbookEntry {
_LogbookEntry({
required this.name,
required this.date,
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,
});
final String name;
final DateTime date;
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;
/// 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;
}
factory _LogbookEntry.fromLog(AttendanceLog log, Map<String, Profile> byId) {
final p = byId[log.userId];
return _LogbookEntry(
name: p?.fullName ?? log.userId,
date: log.checkInAt,
checkIn: AppTime.formatTime(log.checkInAt),
checkOut: log.isCheckedOut ? AppTime.formatTime(log.checkOutAt!) : '',
duration: log.isCheckedOut
? _fmtDur(log.checkOutAt!.difference(log.checkInAt))
: 'On duty',
status: log.isCheckedOut ? 'Completed' : 'On duty',
isAbsent: false,
verificationStatus: log.verificationStatus,
logId: log.id,
logUserId: log.userId,
);
}
factory _LogbookEntry.absent(DutySchedule s, Map<String, Profile> byId) {
final p = byId[s.userId];
return _LogbookEntry(
name: p?.fullName ?? s.userId,
date: s.startTime,
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';
}
}
// ────────────────────────────────────────────────
// Tab 2 Logbook
// ────────────────────────────────────────────────
class _LogbookTab extends ConsumerWidget {
const _LogbookTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
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 Map<String, Profile> profileById = {
for (final p in profilesAsync.valueOrNull ?? []) p.id: p,
};
final now = AppTime.now();
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'),
),
],
),
),
),
),
const SizedBox(height: 8),
Expanded(
child: logsAsync.when(
data: (logs) {
final filtered = logs.where((log) {
return !log.checkInAt.isBefore(range.start) &&
log.checkInAt.isBefore(range.end);
}).toList();
// Build absent entries from past schedules with no logs.
final allSchedules = schedulesAsync.valueOrNull ?? [];
final logScheduleIds = logs.map((l) => l.dutyScheduleId).toSet();
final absentSchedules = allSchedules.where((s) {
// Only include schedules whose shift has ended, within
// the selected date range, and with no matching logs.
// Exclude overtime schedules they only belong in the
// Logbook via their attendance_log entry.
return s.shiftType != 'overtime' &&
s.endTime.isBefore(now) &&
!s.startTime.isBefore(range.start) &&
s.startTime.isBefore(range.end) &&
!logScheduleIds.contains(s.id);
}).toList();
// Build combined entries: _LogbookEntry sealed type.
// Include leave entries within the date range.
final leaves = leavesAsync.valueOrNull ?? [];
final leaveEntries = leaves
.where((l) {
return l.status == 'approved' &&
!l.startTime.isBefore(range.start) &&
l.startTime.isBefore(range.end);
})
.map((l) {
final p = profileById[l.userId];
return _LogbookEntry(
name: p?.fullName ?? l.userId,
date: l.startTime,
checkIn: AppTime.formatTime(l.startTime),
checkOut: AppTime.formatTime(l.endTime),
duration: '',
status: 'On Leave',
isAbsent: false,
isLeave: true,
leaveType: l.leaveType,
);
});
final List<_LogbookEntry> entries = [
...filtered.map((l) => _LogbookEntry.fromLog(l, profileById)),
...absentSchedules.map(
(s) => _LogbookEntry.absent(s, profileById),
),
...leaveEntries,
];
// Sort by date descending.
entries.sort((a, b) => b.date.compareTo(a.date));
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 LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 700) {
return _buildDataTable(
context,
entries,
currentUserId: currentUserId,
onReverify: (logId) => _reverify(context, ref, logId),
);
}
return _buildLogList(
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')),
),
),
],
);
}
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 _buildDataTable(
BuildContext context,
List<_LogbookEntry> entries, {
required String currentUserId,
required void Function(String logId) onReverify,
}) {
// Group entries by date.
final grouped = _groupByDate(entries);
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: grouped.entries.map((group) {
return _DateGroupTile(
dateLabel: group.key,
entries: group.value,
useTable: true,
currentUserId: currentUserId,
onReverify: onReverify,
);
}).toList(),
),
);
}
Widget _buildLogList(
BuildContext context,
List<_LogbookEntry> entries, {
required String currentUserId,
required void Function(String logId) onReverify,
}) {
// Group entries by date.
final grouped = _groupByDate(entries);
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: grouped.entries.map((group) {
return _DateGroupTile(
dateLabel: group.key,
entries: group.value,
useTable: false,
currentUserId: currentUserId,
onReverify: onReverify,
);
}).toList(),
);
}
/// 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.entries,
required this.useTable,
required this.currentUserId,
required this.onReverify,
});
final String dateLabel;
final List<_LogbookEntry> entries;
final bool useTable;
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(
'${entries.length} ${entries.length == 1 ? 'entry' : 'entries'}',
style: textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant),
),
children: [if (useTable) _buildTable(context) else _buildList(context)],
),
);
}
Widget _buildTable(BuildContext context) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columns: const [
DataColumn(label: Text('Staff')),
DataColumn(label: Text('Check In')),
DataColumn(label: Text('Check Out')),
DataColumn(label: Text('Duration')),
DataColumn(label: Text('Status')),
DataColumn(label: Text('Verified')),
],
rows: entries.map((entry) {
final Color statusColor;
if (entry.isLeave) {
statusColor = Colors.teal;
} else if (entry.isAbsent) {
statusColor = Colors.red;
} else if (entry.status == 'On duty') {
statusColor = Colors.orange;
} else {
statusColor = Colors.green;
}
final statusText = entry.isLeave
? 'On Leave${entry.leaveType != null ? ' (${_leaveLabel(entry.leaveType!)})' : ''}'
: entry.status;
return DataRow(
cells: [
DataCell(Text(entry.name)),
DataCell(Text(entry.checkIn)),
DataCell(Text(entry.checkOut)),
DataCell(Text(entry.duration)),
DataCell(
Text(
statusText,
style: TextStyle(
color: statusColor,
fontWeight: (entry.isAbsent || entry.isLeave)
? FontWeight.w600
: null,
),
),
),
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
_verificationBadge(context, entry),
if (entry.canReverify(currentUserId)) ...[
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.refresh, size: 16),
tooltip: 'Re-verify',
onPressed: () => onReverify(entry.logId!),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 28,
minHeight: 28,
),
visualDensity: VisualDensity.compact,
),
],
],
),
),
],
);
}).toList(),
),
);
}
Widget _buildList(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Column(
children: entries.map((entry) {
return ListTile(
title: Row(
children: [
Expanded(child: Text(entry.name)),
_verificationBadge(context, entry),
if (entry.canReverify(currentUserId))
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}${entry.checkOut != "" ? " · Out: ${entry.checkOut}" : " · On duty"}',
),
trailing: entry.isLeave
? Chip(
label: const Text('On Leave'),
backgroundColor: Colors.teal.withValues(alpha: 0.15),
)
: entry.isAbsent
? Chip(
label: const Text('Absent'),
backgroundColor: colors.errorContainer,
)
: entry.status == 'On duty'
? Chip(
label: const Text('On duty'),
backgroundColor: colors.tertiaryContainer,
)
: Text(
entry.duration,
style: Theme.of(context).textTheme.bodySmall,
),
);
}).toList(),
);
}
/// 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';
}
return 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)),
],
),
),
);
}
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;
}
}
}
// ────────────────────────────────────────────────
// 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);
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);
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);
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;
}
final startDt = DateTime(
_startDate!.year,
_startDate!.month,
_startDate!.day,
_startTime!.hour,
_startTime!.minute,
);
final 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;
}
setState(() => _submitting = true);
try {
await ref
.read(leaveControllerProvider)
.fileLeave(
leaveType: _leaveType,
justification: _justificationController.text.trim(),
startTime: startDt,
endTime: endDt,
autoApprove: widget.isAdmin,
);
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'),
),
],
),
],
),
),
),
),
);
}
}