diff --git a/lib/models/attendance_log.dart b/lib/models/attendance_log.dart index 3060be34..122bbeeb 100644 --- a/lib/models/attendance_log.dart +++ b/lib/models/attendance_log.dart @@ -5,6 +5,7 @@ class AttendanceLog { required this.id, required this.userId, required this.dutyScheduleId, + required this.shiftType, required this.checkInAt, required this.checkInLat, required this.checkInLng, @@ -19,6 +20,7 @@ class AttendanceLog { final String id; final String userId; final String dutyScheduleId; + final String shiftType; final DateTime checkInAt; final double checkInLat; final double checkInLng; @@ -39,6 +41,7 @@ class AttendanceLog { id: map['id'] as String, userId: map['user_id'] as String, dutyScheduleId: map['duty_schedule_id'] as String, + shiftType: map['shift_type'] as String? ?? 'normal', checkInAt: AppTime.parse(map['check_in_at'] as String), checkInLat: (map['check_in_lat'] as num).toDouble(), checkInLng: (map['check_in_lng'] as num).toDouble(), diff --git a/lib/screens/attendance/attendance_screen.dart b/lib/screens/attendance/attendance_screen.dart index 7a9ec421..0db51f5b 100644 --- a/lib/screens/attendance/attendance_screen.dart +++ b/lib/screens/attendance/attendance_screen.dart @@ -3,7 +3,10 @@ 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'; @@ -1280,6 +1283,8 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { return 'Night Shift'; case 'overtime': return 'Overtime'; + case 'on_call': + return 'On Call'; default: return shiftType; } @@ -1293,6 +1298,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> { class _LogbookEntry { _LogbookEntry({ required this.name, + required this.shift, required this.date, required this.checkIn, required this.checkOut, @@ -1304,9 +1310,16 @@ class _LogbookEntry { this.verificationStatus, this.logId, this.logUserId, + this.enrolledFaceUrl, + this.verificationFaceUrl, + this.checkInLat, + this.checkInLng, + this.checkOutLat, + this.checkOutLng, }); final String name; + final String shift; final DateTime date; final String checkIn; final String checkOut; @@ -1318,6 +1331,12 @@ class _LogbookEntry { final String? verificationStatus; final String? logId; final String? logUserId; + final String? enrolledFaceUrl; + final String? verificationFaceUrl; + 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) { @@ -1329,10 +1348,19 @@ class _LogbookEntry { return elapsed.inMinutes <= 10; } - factory _LogbookEntry.fromLog(AttendanceLog log, Map byId) { + factory _LogbookEntry.fromLog( + AttendanceLog log, + Map byId, [ + Map? 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!) : '—', @@ -1344,6 +1372,12 @@ class _LogbookEntry { verificationStatus: log.verificationStatus, logId: log.id, logUserId: log.userId, + enrolledFaceUrl: p?.facePhotoUrl, + verificationFaceUrl: log.verificationPhotoUrl, + checkInLat: log.checkInLat, + checkInLng: log.checkInLng, + checkOutLat: log.checkOutLat, + checkOutLng: log.checkOutLng, ); } @@ -1351,6 +1385,7 @@ class _LogbookEntry { final p = byId[s.userId]; return _LogbookEntry( name: p?.fullName ?? s.userId, + shift: _shiftLabelFromType(s.shiftType), date: s.startTime, checkIn: '—', checkOut: '—', @@ -1365,6 +1400,21 @@ class _LogbookEntry { 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; + } + } } // ──────────────────────────────────────────────── @@ -1439,16 +1489,38 @@ class _LogbookTab extends ConsumerWidget { // 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 = >{}; + 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 schedules – they only belong in the - // Logbook via their attendance_log entry. - return s.shiftType != 'overtime' && - s.endTime.isBefore(now) && - !s.startTime.isBefore(range.start) && - s.startTime.isBefore(range.end) && - !logScheduleIds.contains(s.id); + // 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. @@ -1464,6 +1536,7 @@ class _LogbookTab extends ConsumerWidget { 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), @@ -1475,8 +1548,14 @@ class _LogbookTab extends ConsumerWidget { ); }); + final Map scheduleById = { + for (final s in allSchedules) s.id: s, + }; + final List<_LogbookEntry> entries = [ - ...filtered.map((l) => _LogbookEntry.fromLog(l, profileById)), + ...filtered.map( + (l) => _LogbookEntry.fromLog(l, profileById, scheduleById), + ), ...absentSchedules.map( (s) => _LogbookEntry.absent(s, profileById), ), @@ -1672,6 +1751,7 @@ class _DateGroupTile extends StatelessWidget { 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')), @@ -1697,6 +1777,7 @@ class _DateGroupTile extends StatelessWidget { return DataRow( cells: [ DataCell(Text(entry.name)), + DataCell(Text(entry.shift)), DataCell(Text(entry.checkIn)), DataCell(Text(entry.checkOut)), DataCell(Text(entry.duration)), @@ -1772,7 +1853,7 @@ class _DateGroupTile extends StatelessWidget { ? 'On Leave${entry.leaveType != null ? ' — ${_leaveLabel(entry.leaveType!)}' : ''}' : entry.isAbsent ? 'Absent — no check-in recorded' - : 'In: ${entry.checkIn}${entry.checkOut != "—" ? " · Out: ${entry.checkOut}" : " · On duty"}', + : 'Shift: ${entry.shift} · In: ${entry.checkIn}${entry.checkOut != "—" ? " · Out: ${entry.checkOut}" : " · On duty"}', ), trailing: entry.isLeave ? Chip( @@ -1823,7 +1904,7 @@ class _DateGroupTile extends StatelessWidget { color = Colors.orange; tooltip = 'Pending'; } - return Tooltip( + final badge = Tooltip( message: tooltip, child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), @@ -1841,6 +1922,62 @@ class _DateGroupTile extends StatelessWidget { ), ), ); + + 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( + 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) { @@ -1859,6 +1996,424 @@ class _DateGroupTile extends StatelessWidget { } } +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; + + 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: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + 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), + ], + ), + ), + ], + ), + ); + } +} + +class _FaceVerificationTab extends StatelessWidget { + const _FaceVerificationTab({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: [ + _ImagePanel( + width: panelWidth, + title: 'Enrolled Face', + imageUrl: entry.enrolledFaceUrl, + bucket: 'face-enrollment', + emptyMessage: 'No enrolled face photo found.', + icon: Icons.person, + ), + const SizedBox(width: 12), + _ImagePanel( + width: panelWidth, + title: 'Attendance Verification Face', + imageUrl: entry.verificationFaceUrl, + bucket: 'attendance-verification', + emptyMessage: 'No attendance verification photo found.', + icon: Icons.verified_user, + ), + ], + ), + ); + }, + ); + } +} + +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 _resolvedUrlFuture; + + @override + void initState() { + super.initState(); + _resolvedUrlFuture = _resolveAccessibleUrl( + sourceUrl: widget.sourceUrl, + bucket: widget.bucket, + ); + } + + Future _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( + 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) // ────────────────────────────────────────────────