tasq/lib/screens/attendance/attendance_screen.dart

1445 lines
48 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

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

import '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 '../../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) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('You are outside the geofence area.')),
);
return;
}
}
await ref
.read(attendanceControllerProvider)
.checkIn(
dutyScheduleId: schedule.id,
lat: position.latitude,
lng: position.longitude,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Checked in successfully.')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('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) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Checked out successfully.')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('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) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('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) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Pass slip request submitted.')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('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) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Pass slip approved.')));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('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) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Pass slip rejected.')));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('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) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Pass slip completed.')));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed: $e')));
}
} finally {
if (mounted) setState(() => _submitting = false);
}
}
}