1418 lines
47 KiB
Dart
1418 lines
47 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:geolocator/geolocator.dart';
|
||
|
||
import '../../models/attendance_log.dart';
|
||
import '../../models/duty_schedule.dart';
|
||
import '../../models/pass_slip.dart';
|
||
import '../../models/profile.dart';
|
||
import '../../providers/attendance_provider.dart';
|
||
import '../../providers/notifications_provider.dart';
|
||
import '../../providers/pass_slip_provider.dart';
|
||
import '../../providers/profile_provider.dart';
|
||
import '../../providers/reports_provider.dart';
|
||
import '../../providers/whereabouts_provider.dart';
|
||
import '../../providers/workforce_provider.dart';
|
||
import '../../theme/m3_motion.dart';
|
||
import '../../utils/app_time.dart';
|
||
import '../../utils/snackbar.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 SingleTickerProviderStateMixin {
|
||
late TabController _tabController;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_tabController = TabController(length: 3, vsync: this);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_tabController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
|
||
return ResponsiveBody(
|
||
child: 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,
|
||
tabs: const [
|
||
Tab(text: 'Check In'),
|
||
Tab(text: 'Logbook'),
|
||
Tab(text: 'Pass Slip'),
|
||
],
|
||
),
|
||
Expanded(
|
||
child: TabBarView(
|
||
controller: _tabController,
|
||
children: const [_CheckInTab(), _LogbookTab(), _PassSlipTab()],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ────────────────────────────────────────────────
|
||
// 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;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final colors = theme.colorScheme;
|
||
final profile = ref.watch(currentProfileProvider).valueOrNull;
|
||
final schedulesAsync = ref.watch(dutySchedulesProvider);
|
||
final logsAsync = ref.watch(attendanceLogsProvider);
|
||
final allowTracking = profile?.allowTracking ?? false;
|
||
|
||
if (profile == null) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
|
||
final now = AppTime.now();
|
||
final today = DateTime(now.year, now.month, now.day);
|
||
|
||
// Find today's schedule for the current user
|
||
final schedules = schedulesAsync.valueOrNull ?? [];
|
||
final todaySchedule = schedules.where((s) {
|
||
final sDay = DateTime(
|
||
s.startTime.year,
|
||
s.startTime.month,
|
||
s.startTime.day,
|
||
);
|
||
return s.userId == profile.id && sDay == today;
|
||
}).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();
|
||
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Location tracking toggle
|
||
Card(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
Icons.location_on,
|
||
size: 20,
|
||
color: allowTracking ? colors.primary : colors.outline,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
'Location Tracking',
|
||
style: theme.textTheme.bodyMedium,
|
||
),
|
||
),
|
||
Switch(
|
||
value: allowTracking,
|
||
onChanged: (v) =>
|
||
ref.read(whereaboutsControllerProvider).setTracking(v),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// Today's schedule
|
||
Text(
|
||
"Today's Schedule",
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
|
||
if (todaySchedule.isEmpty)
|
||
Card(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Center(
|
||
child: Text(
|
||
'No schedule assigned for today.',
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
color: colors.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
)
|
||
else
|
||
...todaySchedule.map((schedule) {
|
||
final hasCheckedIn = schedule.checkInAt != null;
|
||
final isActive = activeLog.any(
|
||
(l) => l.dutyScheduleId == schedule.id,
|
||
);
|
||
final completedLog = logs
|
||
.where(
|
||
(l) => l.dutyScheduleId == schedule.id && l.isCheckedOut,
|
||
)
|
||
.toList();
|
||
final isCompleted = completedLog.isNotEmpty;
|
||
|
||
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,
|
||
isCompleted
|
||
? 'Completed'
|
||
: isActive
|
||
? 'On Duty'
|
||
: hasCheckedIn
|
||
? 'Checked In'
|
||
: 'Scheduled',
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'${AppTime.formatTime(schedule.startTime)} – ${AppTime.formatTime(schedule.endTime)}',
|
||
style: theme.textTheme.bodyMedium,
|
||
),
|
||
const SizedBox(height: 12),
|
||
if (!hasCheckedIn && !isCompleted)
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: FilledButton.icon(
|
||
onPressed: _loading
|
||
? null
|
||
: () => _handleCheckIn(schedule),
|
||
icon: _loading
|
||
? const SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
),
|
||
)
|
||
: const Icon(Icons.login),
|
||
label: const Text('Check In'),
|
||
),
|
||
)
|
||
else if (isActive)
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: FilledButton.tonalIcon(
|
||
onPressed: _loading
|
||
? null
|
||
: () => _handleCheckOut(
|
||
activeLog.firstWhere(
|
||
(l) => l.dutyScheduleId == schedule.id,
|
||
),
|
||
),
|
||
icon: _loading
|
||
? const SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
),
|
||
)
|
||
: const Icon(Icons.logout),
|
||
label: const Text('Check Out'),
|
||
),
|
||
)
|
||
else if (isCompleted)
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
Icons.check_circle,
|
||
size: 16,
|
||
color: colors.primary,
|
||
),
|
||
const SizedBox(width: 6),
|
||
Expanded(
|
||
child: Text(
|
||
'Checked out at ${AppTime.formatTime(completedLog.first.checkOutAt!)}',
|
||
style: theme.textTheme.bodySmall?.copyWith(
|
||
color: colors.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _handleCheckIn(DutySchedule schedule) async {
|
||
setState(() => _loading = true);
|
||
try {
|
||
final geoCfg = await ref.read(geofenceProvider.future);
|
||
final position = await Geolocator.getCurrentPosition(
|
||
locationSettings: const LocationSettings(
|
||
accuracy: LocationAccuracy.high,
|
||
),
|
||
);
|
||
// Client-side geofence check
|
||
if (geoCfg != null) {
|
||
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;
|
||
}
|
||
}
|
||
await ref
|
||
.read(attendanceControllerProvider)
|
||
.checkIn(
|
||
dutyScheduleId: schedule.id,
|
||
lat: position.latitude,
|
||
lng: position.longitude,
|
||
);
|
||
if (mounted) {
|
||
showSuccessSnackBar(context, 'Checked in successfully.');
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
showErrorSnackBar(context, 'Check-in failed: $e');
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _loading = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _handleCheckOut(AttendanceLog log) async {
|
||
setState(() => _loading = true);
|
||
try {
|
||
final position = await Geolocator.getCurrentPosition(
|
||
locationSettings: const LocationSettings(
|
||
accuracy: LocationAccuracy.high,
|
||
),
|
||
);
|
||
await ref
|
||
.read(attendanceControllerProvider)
|
||
.checkOut(
|
||
attendanceId: log.id,
|
||
lat: position.latitude,
|
||
lng: position.longitude,
|
||
);
|
||
if (mounted) {
|
||
showSuccessSnackBar(context, 'Checked out successfully.');
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
showErrorSnackBar(context, 'Check-out failed: $e');
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _loading = false);
|
||
}
|
||
}
|
||
|
||
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;
|
||
default:
|
||
bg = colors.surfaceContainerHighest;
|
||
fg = colors.onSurface;
|
||
}
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: bg,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Text(
|
||
label,
|
||
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: fg),
|
||
),
|
||
);
|
||
}
|
||
|
||
String _shiftLabel(String shiftType) {
|
||
switch (shiftType) {
|
||
case 'normal':
|
||
return 'Normal Shift';
|
||
case 'night':
|
||
return 'Night Shift';
|
||
case 'overtime':
|
||
return 'Overtime';
|
||
default:
|
||
return shiftType;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ────────────────────────────────────────────────
|
||
// 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 Map<String, Profile> profileById = {
|
||
for (final p in profilesAsync.valueOrNull ?? []) p.id: p,
|
||
};
|
||
|
||
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();
|
||
|
||
if (filtered.isEmpty) {
|
||
return Center(
|
||
child: Text(
|
||
'No attendance logs for this period.',
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
color: colors.onSurfaceVariant,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
if (constraints.maxWidth >= 700) {
|
||
return _buildDataTable(context, filtered, profileById);
|
||
}
|
||
return _buildLogList(context, filtered, profileById);
|
||
},
|
||
);
|
||
},
|
||
loading: () => const Center(child: CircularProgressIndicator()),
|
||
error: (e, _) => Center(child: Text('Failed to load logs: $e')),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
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<AttendanceLog> logs,
|
||
Map<String, Profile> profileById,
|
||
) {
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: DataTable(
|
||
columns: const [
|
||
DataColumn(label: Text('Staff')),
|
||
DataColumn(label: Text('Role')),
|
||
DataColumn(label: Text('Date')),
|
||
DataColumn(label: Text('Check In')),
|
||
DataColumn(label: Text('Check Out')),
|
||
DataColumn(label: Text('Duration')),
|
||
DataColumn(label: Text('Status')),
|
||
],
|
||
rows: logs.map((log) {
|
||
final p = profileById[log.userId];
|
||
final name = p?.fullName ?? log.userId;
|
||
final role = p?.role ?? '-';
|
||
final date = AppTime.formatDate(log.checkInAt);
|
||
final checkIn = AppTime.formatTime(log.checkInAt);
|
||
final checkOut = log.isCheckedOut
|
||
? AppTime.formatTime(log.checkOutAt!)
|
||
: '—';
|
||
final duration = log.isCheckedOut
|
||
? _formatDuration(log.checkOutAt!.difference(log.checkInAt))
|
||
: 'On duty';
|
||
final status = log.isCheckedOut ? 'Completed' : 'On duty';
|
||
|
||
return DataRow(
|
||
cells: [
|
||
DataCell(Text(name)),
|
||
DataCell(Text(_roleLabel(role))),
|
||
DataCell(Text(date)),
|
||
DataCell(Text(checkIn)),
|
||
DataCell(Text(checkOut)),
|
||
DataCell(Text(duration)),
|
||
DataCell(
|
||
Text(
|
||
status,
|
||
style: TextStyle(
|
||
color: log.isCheckedOut ? Colors.green : Colors.orange,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}).toList(),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildLogList(
|
||
BuildContext context,
|
||
List<AttendanceLog> logs,
|
||
Map<String, Profile> profileById,
|
||
) {
|
||
return ListView.builder(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
itemCount: logs.length,
|
||
itemBuilder: (context, index) {
|
||
final log = logs[index];
|
||
final p = profileById[log.userId];
|
||
final name = p?.fullName ?? log.userId;
|
||
final role = p?.role ?? '-';
|
||
|
||
return Card(
|
||
child: ListTile(
|
||
title: Text(name),
|
||
subtitle: Text(
|
||
'${_roleLabel(role)} · ${AppTime.formatDate(log.checkInAt)}\n'
|
||
'In: ${AppTime.formatTime(log.checkInAt)}'
|
||
'${log.isCheckedOut ? " · Out: ${AppTime.formatTime(log.checkOutAt!)}" : " · On duty"}',
|
||
),
|
||
isThreeLine: true,
|
||
trailing: log.isCheckedOut
|
||
? Text(
|
||
_formatDuration(log.checkOutAt!.difference(log.checkInAt)),
|
||
style: Theme.of(context).textTheme.bodySmall,
|
||
)
|
||
: Chip(
|
||
label: const Text('On duty'),
|
||
backgroundColor: Theme.of(
|
||
context,
|
||
).colorScheme.tertiaryContainer,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
String _formatDuration(Duration d) {
|
||
final hours = d.inHours;
|
||
final minutes = d.inMinutes.remainder(60);
|
||
return '${hours}h ${minutes}m';
|
||
}
|
||
|
||
String _roleLabel(String role) {
|
||
switch (role) {
|
||
case 'admin':
|
||
return 'Admin';
|
||
case 'dispatcher':
|
||
return 'Dispatcher';
|
||
case 'it_staff':
|
||
return 'IT Staff';
|
||
default:
|
||
return 'Standard';
|
||
}
|
||
}
|
||
}
|
||
|
||
// ────────────────────────────────────────────────
|
||
// 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!,
|
||
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> {
|
||
final _reasonController = TextEditingController();
|
||
bool _submitting = false;
|
||
|
||
@override
|
||
void dispose() {
|
||
_reasonController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@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 schedulesAsync = ref.watch(dutySchedulesProvider);
|
||
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());
|
||
}
|
||
|
||
// Find today's schedule for passing to request form
|
||
final now = AppTime.now();
|
||
final today = DateTime(now.year, now.month, now.day);
|
||
final schedules = schedulesAsync.valueOrNull ?? [];
|
||
final todaySchedule = schedules.where((s) {
|
||
final sDay = DateTime(
|
||
s.startTime.year,
|
||
s.startTime.month,
|
||
s.startTime.day,
|
||
);
|
||
return s.userId == profile.id && sDay == today;
|
||
}).toList();
|
||
|
||
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 (only for non-admin staff with a schedule today)
|
||
if (!isAdmin && activeSlip == null && todaySchedule.isNotEmpty) ...[
|
||
Text(
|
||
'Request Pass Slip',
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Card(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
TextField(
|
||
controller: _reasonController,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Reason',
|
||
hintText: 'Brief reason for pass slip',
|
||
),
|
||
maxLines: 2,
|
||
),
|
||
const SizedBox(height: 12),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: FilledButton.tonalIcon(
|
||
onPressed: _submitting
|
||
? null
|
||
: () => _requestSlip(todaySchedule.first.id),
|
||
icon: _submitting
|
||
? const SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
),
|
||
)
|
||
: const Icon(Icons.send),
|
||
label: const Text('Submit Request'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
],
|
||
|
||
// 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> _requestSlip(String scheduleId) 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: scheduleId, reason: reason);
|
||
_reasonController.clear();
|
||
|
||
// Notify all admin users via push notification
|
||
final profiles = ref.read(profilesProvider).valueOrNull ?? [];
|
||
final adminIds = profiles
|
||
.where((p) => p.role == 'admin')
|
||
.map((p) => p.id)
|
||
.toList();
|
||
final currentProfile = ref.read(currentProfileProvider).valueOrNull;
|
||
final actorName = currentProfile?.fullName ?? 'A staff member';
|
||
if (adminIds.isNotEmpty && currentProfile != null) {
|
||
ref
|
||
.read(notificationsControllerProvider)
|
||
.createNotification(
|
||
userIds: adminIds,
|
||
type: 'pass_slip_request',
|
||
actorId: currentProfile.id,
|
||
pushTitle: 'Pass Slip Request',
|
||
pushBody: '$actorName requested a pass slip: $reason',
|
||
);
|
||
}
|
||
|
||
if (mounted) {
|
||
showSuccessSnackBar(context, 'Pass slip request submitted.');
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
showErrorSnackBar(context, 'Failed: $e');
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _submitting = false);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|