Fixed Shift Labels in Logbook and made Verified/Unverified badge clickable

This commit is contained in:
Marc Rejohn Castillano 2026-03-08 08:45:30 +08:00
parent 88432551c8
commit d3da8901a4
2 changed files with 569 additions and 11 deletions

View File

@ -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(),

View File

@ -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<String, Profile> byId) {
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!) : '',
@ -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 = <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 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<String, DutySchedule> 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<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) {
@ -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<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)
//