4446 lines
148 KiB
Dart
4446 lines
148 KiB
Dart
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/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.shift,
|
||
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,
|
||
this.enrolledFaceUrl,
|
||
this.checkInVerificationFaceUrl,
|
||
this.checkOutVerificationFaceUrl,
|
||
this.justification,
|
||
this.checkOutJustification,
|
||
this.checkInLat,
|
||
this.checkInLng,
|
||
this.checkOutLat,
|
||
this.checkOutLng,
|
||
});
|
||
|
||
final String name;
|
||
final String shift;
|
||
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;
|
||
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;
|
||
}
|
||
|
||
factory _LogbookEntry.fromLog(
|
||
AttendanceLog log,
|
||
Map<String, Profile> byId, [
|
||
Map<String, DutySchedule>? byScheduleId,
|
||
]) {
|
||
final p = byId[log.userId];
|
||
// duty_schedules.shift_type is the authoritative source; attendance_logs
|
||
// may not store it (no shift_type column in older DB schemas).
|
||
final shiftType =
|
||
byScheduleId?[log.dutyScheduleId]?.shiftType ?? log.shiftType;
|
||
return _LogbookEntry(
|
||
name: p?.fullName ?? log.userId,
|
||
shift: _shiftLabelFromType(shiftType),
|
||
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,
|
||
enrolledFaceUrl: p?.facePhotoUrl,
|
||
checkInVerificationFaceUrl: log.checkInVerificationPhotoUrl,
|
||
checkOutVerificationFaceUrl: log.checkOutVerificationPhotoUrl,
|
||
justification: log.justification,
|
||
checkOutJustification: log.checkOutJustification,
|
||
checkInLat: log.checkInLat,
|
||
checkInLng: log.checkInLng,
|
||
checkOutLat: log.checkOutLat,
|
||
checkOutLng: log.checkOutLng,
|
||
);
|
||
}
|
||
|
||
factory _LogbookEntry.absent(DutySchedule s, Map<String, Profile> byId) {
|
||
final p = byId[s.userId];
|
||
return _LogbookEntry(
|
||
name: p?.fullName ?? s.userId,
|
||
shift: _shiftLabelFromType(s.shiftType),
|
||
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';
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ────────────────────────────────────────────────
|
||
// 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();
|
||
// Build a lookup: userId → set of date-strings where overtime was rendered.
|
||
final overtimeDaysByUser = <String, Set<String>>{};
|
||
for (final log in logs) {
|
||
if (log.shiftType == 'overtime') {
|
||
final d = log.checkInAt;
|
||
final key = '${d.year}-${d.month}-${d.day}';
|
||
overtimeDaysByUser.putIfAbsent(log.userId, () => {}).add(key);
|
||
}
|
||
}
|
||
final absentSchedules = allSchedules.where((s) {
|
||
// Only include schedules whose shift has ended, within
|
||
// the selected date range, and with no matching logs.
|
||
// Exclude:
|
||
// • overtime – shown via attendance_log entries.
|
||
// • on_call – standby duty; no check-in obligation.
|
||
// • any day where the user rendered overtime.
|
||
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;
|
||
}
|
||
// If the user rendered overtime on this calendar day, don't
|
||
// mark the normal/night shift schedule as absent.
|
||
final d = s.startTime;
|
||
final dayKey = '${d.year}-${d.month}-${d.day}';
|
||
if (overtimeDaysByUser[s.userId]?.contains(dayKey) ?? false) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}).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,
|
||
shift: '—',
|
||
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 Map<String, DutySchedule> scheduleById = {
|
||
for (final s in allSchedules) s.id: s,
|
||
};
|
||
|
||
final List<_LogbookEntry> entries = [
|
||
...filtered.map(
|
||
(l) => _LogbookEntry.fromLog(l, profileById, scheduleById),
|
||
),
|
||
...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('Shift')),
|
||
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.shift)),
|
||
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'
|
||
: 'Shift: ${entry.shift} · 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';
|
||
}
|
||
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'),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|