Enhanced Logbook layout

This commit is contained in:
Marc Rejohn Castillano 2026-03-18 10:56:31 +08:00
parent 84837c4bf2
commit 4eaf9444f0
2 changed files with 456 additions and 312 deletions

View File

@ -22,6 +22,9 @@ final attendanceDateRangeProvider = StateProvider<ReportDateRange>((ref) {
);
});
/// Filter for logbook users (multi-select). If empty, all users are shown.
final attendanceUserFilterProvider = StateProvider<List<String>>((ref) => []);
/// All visible attendance logs (own for standard, all for admin/dispatcher/it_staff).
final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
final client = ref.watch(supabaseClientProvider);

View File

@ -32,6 +32,7 @@ import '../../widgets/face_verification_overlay.dart';
import '../../utils/snackbar.dart';
import '../../widgets/gemini_animated_text_field.dart';
import '../../widgets/gemini_button.dart';
import '../../widgets/multi_select_picker.dart';
import '../../widgets/responsive_body.dart';
class AttendanceScreen extends ConsumerStatefulWidget {
@ -1652,8 +1653,12 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
class _LogbookEntry {
_LogbookEntry({
required this.name,
required this.userId,
required this.dutyScheduleId,
required this.shift,
required this.date,
required this.checkInAt,
required this.checkOutAt,
required this.checkIn,
required this.checkOut,
required this.duration,
@ -1676,8 +1681,12 @@ class _LogbookEntry {
});
final String name;
final String userId;
final String? dutyScheduleId;
final String shift;
final DateTime date;
final DateTime checkInAt;
final DateTime? checkOutAt;
final String checkIn;
final String checkOut;
final String duration;
@ -1708,48 +1717,20 @@ class _LogbookEntry {
return elapsed.inMinutes <= 10;
}
factory _LogbookEntry.fromLog(
AttendanceLog log,
Map<String, Profile> byId, [
Map<String, DutySchedule>? byScheduleId,
]) {
final p = byId[log.userId];
// duty_schedules.shift_type is the authoritative source; attendance_logs
// may not store it (no shift_type column in older DB schemas).
final shiftType =
byScheduleId?[log.dutyScheduleId]?.shiftType ?? log.shiftType;
return _LogbookEntry(
name: p?.fullName ?? log.userId,
shift: _shiftLabelFromType(shiftType),
date: log.checkInAt,
checkIn: AppTime.formatTime(log.checkInAt),
checkOut: log.isCheckedOut ? AppTime.formatTime(log.checkOutAt!) : '',
duration: log.isCheckedOut
? _fmtDur(log.checkOutAt!.difference(log.checkInAt))
: 'On duty',
status: log.isCheckedOut ? 'Completed' : 'On duty',
isAbsent: false,
verificationStatus: log.verificationStatus,
logId: log.id,
logUserId: log.userId,
enrolledFaceUrl: p?.facePhotoUrl,
checkInVerificationFaceUrl: log.checkInVerificationPhotoUrl,
checkOutVerificationFaceUrl: log.checkOutVerificationPhotoUrl,
justification: log.justification,
checkOutJustification: log.checkOutJustification,
checkInLat: log.checkInLat,
checkInLng: log.checkInLng,
checkOutLat: log.checkOutLat,
checkOutLng: log.checkOutLng,
);
}
// NOTE: logbook entry creation is handled in an async provider (so that
// filtering/sorting does not block the UI). Use `_computeLogbookEntries`.
factory _LogbookEntry.absent(DutySchedule s, Map<String, Profile> byId) {
factory _LogbookEntry.absent(DutySchedule s, Map<String, Object?> byId) {
final p = byId[s.userId];
final name = p is Profile ? p.fullName : (p as String?) ?? s.userId;
return _LogbookEntry(
name: p?.fullName ?? s.userId,
name: name,
userId: s.userId,
dutyScheduleId: s.id,
shift: _shiftLabelFromType(s.shiftType),
date: s.startTime,
checkInAt: s.startTime,
checkOutAt: null,
checkIn: '',
checkOut: '',
duration: '',
@ -1780,15 +1761,79 @@ class _LogbookEntry {
}
}
/// A shift grouping representing a set of attendance sessions for a single
/// scheduled shift (or leave/absence) in a single day.
class _ShiftGroup {
_ShiftGroup({
required this.groupKey,
required this.userId,
required this.dutyScheduleId,
required this.name,
required this.shiftLabel,
required this.sessions,
});
final String groupKey;
final String userId;
final String? dutyScheduleId;
final String name;
final String shiftLabel;
final List<_LogbookEntry> sessions;
DateTime get date => sessions.first.date;
String get checkIn =>
sessions.isEmpty ? '' : AppTime.formatTime(sessions.first.checkInAt);
String get checkOut {
final checkedOut = sessions.where((s) => s.checkOutAt != null).toList();
if (checkedOut.isEmpty) return '';
final last = checkedOut.reduce(
(a, b) => a.checkOutAt!.isAfter(b.checkOutAt!) ? a : b,
);
return last.checkOut;
}
String get duration {
if (sessions.isEmpty) return '';
final first = sessions.first.checkInAt;
final checkedOut = sessions.where((s) => s.checkOutAt != null).toList();
if (checkedOut.isEmpty) return 'On duty';
final last = checkedOut.reduce(
(a, b) => a.checkOutAt!.isAfter(b.checkOutAt!) ? a : b,
);
return _LogbookEntry._fmtDur(last.checkOutAt!.difference(first));
}
String get status {
if (sessions.any((s) => s.isLeave)) return 'On Leave';
if (sessions.any((s) => s.isAbsent)) return 'Absent';
if (sessions.any((s) => s.checkOutAt == null)) return 'On duty';
return 'Completed';
}
/// Whether any session in this group is still in progress.
bool get hasOngoingSession =>
sessions.any((s) => !s.isAbsent && !s.isLeave && s.checkOutAt == null);
}
//
// Tab 2 Logbook
//
class _LogbookTab extends ConsumerWidget {
class _LogbookTab extends ConsumerStatefulWidget {
const _LogbookTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<_LogbookTab> createState() => _LogbookTabState();
}
class _LogbookTabState extends ConsumerState<_LogbookTab> {
AsyncValue<List<_LogbookEntry>> _entriesAsync = const AsyncValue.loading();
int? _lastSignature;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final range = ref.watch(attendanceDateRangeProvider);
@ -1797,11 +1842,15 @@ class _LogbookTab extends ConsumerWidget {
final schedulesAsync = ref.watch(dutySchedulesProvider);
final leavesAsync = ref.watch(leavesProvider);
final Map<String, Profile> profileById = {
for (final p in profilesAsync.valueOrNull ?? []) p.id: p,
};
final selectedUserIds = ref.watch(attendanceUserFilterProvider);
final now = AppTime.now();
// Only show users with admin-like roles in the selector.
const allowedRoles = {'admin', 'dispatcher', 'programmer', 'it_staff'};
final allowedProfiles =
(profilesAsync.valueOrNull ?? [])
.where((p) => allowedRoles.contains(p.role))
.toList()
..sort((a, b) => a.fullName.compareTo(b.fullName));
return Column(
children: [
@ -1840,124 +1889,71 @@ class _LogbookTab extends ConsumerWidget {
),
),
),
// User filter card (multi-select)
if (allowedProfiles.isNotEmpty) ...[
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Card(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: MultiSelectPicker<Profile>(
label: 'Personnel',
items: allowedProfiles,
selectedIds: selectedUserIds,
getId: (p) => p.id,
getLabel: (p) => p.fullName,
onChanged: (selected) {
ref.read(attendanceUserFilterProvider.notifier).state =
selected;
},
),
),
),
),
],
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();
_recomputeEntriesIfNeeded(
logs: logs,
range: range,
schedules: schedulesAsync.valueOrNull ?? [],
leaves: leavesAsync.valueOrNull ?? [],
profileList: profilesAsync.valueOrNull ?? [],
selectedUserIds: selectedUserIds,
);
// Build absent entries from past schedules with no logs.
final allSchedules = schedulesAsync.valueOrNull ?? [];
final logScheduleIds = logs.map((l) => l.dutyScheduleId).toSet();
// Build a lookup: userId set of date-strings where overtime was rendered.
final overtimeDaysByUser = <String, Set<String>>{};
for (final log in logs) {
if (log.shiftType == 'overtime') {
final d = log.checkInAt;
final key = '${d.year}-${d.month}-${d.day}';
overtimeDaysByUser.putIfAbsent(log.userId, () => {}).add(key);
}
}
final absentSchedules = allSchedules.where((s) {
// Only include schedules whose shift has ended, within
// the selected date range, and with no matching logs.
// Exclude:
// overtime shown via attendance_log entries.
// on_call standby duty; no check-in obligation.
// any day where the user rendered overtime.
if (s.shiftType == 'overtime' || s.shiftType == 'on_call') {
return false;
}
if (logScheduleIds.contains(s.id)) return false;
if (!s.endTime.isBefore(now)) return false;
if (s.startTime.isBefore(range.start) ||
!s.startTime.isBefore(range.end)) {
return false;
}
// If the user rendered overtime on this calendar day, don't
// mark the normal/night shift schedule as absent.
final d = s.startTime;
final dayKey = '${d.year}-${d.month}-${d.day}';
if (overtimeDaysByUser[s.userId]?.contains(dayKey) ?? false) {
return false;
}
return true;
}).toList();
// Build combined entries: _LogbookEntry sealed type.
// Include leave entries within the date range.
final leaves = leavesAsync.valueOrNull ?? [];
final leaveEntries = leaves
.where((l) {
return l.status == 'approved' &&
!l.startTime.isBefore(range.start) &&
l.startTime.isBefore(range.end);
})
.map((l) {
final p = profileById[l.userId];
return _LogbookEntry(
name: p?.fullName ?? l.userId,
shift: '',
date: l.startTime,
checkIn: AppTime.formatTime(l.startTime),
checkOut: AppTime.formatTime(l.endTime),
duration: '',
status: 'On Leave',
isAbsent: false,
isLeave: true,
leaveType: l.leaveType,
);
});
final Map<String, DutySchedule> scheduleById = {
for (final s in allSchedules) s.id: s,
};
final List<_LogbookEntry> entries = [
...filtered.map(
(l) => _LogbookEntry.fromLog(l, profileById, scheduleById),
),
...absentSchedules.map(
(s) => _LogbookEntry.absent(s, profileById),
),
...leaveEntries,
];
// Sort by date descending.
entries.sort((a, b) => b.date.compareTo(a.date));
if (entries.isEmpty) {
return Center(
child: Text(
'No attendance logs for this period.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
);
}
final currentUserId = ref.read(currentUserIdProvider) ?? '';
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 700) {
return _buildDataTable(
context,
entries,
currentUserId: currentUserId,
onReverify: (logId) => _reverify(context, ref, logId),
return _entriesAsync.when(
data: (entries) {
if (entries.isEmpty) {
return Center(
child: Text(
'No attendance logs for this period.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
);
}
return _buildLogList(
final currentUserId = ref.read(currentUserIdProvider) ?? '';
return _buildShiftGroupList(
context,
entries,
currentUserId: currentUserId,
onReverify: (logId) => _reverify(context, ref, logId),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Failed to load logs: $e')),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
@ -1968,6 +1964,173 @@ class _LogbookTab extends ConsumerWidget {
);
}
void _recomputeEntriesIfNeeded({
required List<AttendanceLog> logs,
required ReportDateRange range,
required List<DutySchedule> schedules,
required List<LeaveOfAbsence> leaves,
required List<Profile> profileList,
required List<String> selectedUserIds,
}) {
final signature = Object.hashAll([
logs.length,
range.start.millisecondsSinceEpoch,
range.end.millisecondsSinceEpoch,
schedules.length,
leaves.length,
profileList.length,
selectedUserIds.length,
selectedUserIds.hashCode,
]);
if (signature == _lastSignature) return;
_lastSignature = signature;
setState(() {
_entriesAsync = const AsyncValue.loading();
});
Future(() {
final profileById = {for (final p in profileList) p.id: p};
return _computeEntries(
logs: logs,
range: range,
schedules: schedules,
leaves: leaves,
profileById: profileById,
selectedUserIds: selectedUserIds,
);
})
.then((entries) {
if (!mounted) return;
setState(() {
_entriesAsync = AsyncValue.data(entries);
});
})
.catchError((e, st) {
if (!mounted) return;
setState(() {
_entriesAsync = AsyncValue.error(e, st);
});
});
}
List<_LogbookEntry> _computeEntries({
required List<AttendanceLog> logs,
required ReportDateRange range,
required List<DutySchedule> schedules,
required List<LeaveOfAbsence> leaves,
required Map<String, Profile> profileById,
required List<String> selectedUserIds,
}) {
final filtered = logs.where((log) {
return !log.checkInAt.isBefore(range.start) &&
log.checkInAt.isBefore(range.end);
}).toList();
final logScheduleIds = filtered.map((l) => l.dutyScheduleId).toSet();
final overtimeDaysByUser = <String, Set<String>>{};
for (final log in filtered) {
if (log.shiftType == 'overtime') {
final d = log.checkInAt;
final key = '${d.year}-${d.month}-${d.day}';
overtimeDaysByUser.putIfAbsent(log.userId, () => {}).add(key);
}
}
final now = AppTime.now();
final absentSchedules = schedules.where((s) {
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;
}
final d = s.startTime;
final dayKey = '${d.year}-${d.month}-${d.day}';
if (overtimeDaysByUser[s.userId]?.contains(dayKey) ?? false) {
return false;
}
return true;
}).toList();
final leaveEntries = leaves
.where((l) {
return l.status == 'approved' &&
!l.startTime.isBefore(range.start) &&
l.startTime.isBefore(range.end);
})
.map((l) {
final profile = profileById[l.userId];
return _LogbookEntry(
name: profile?.fullName ?? l.userId,
userId: l.userId,
dutyScheduleId: null,
shift: '',
date: l.startTime,
checkInAt: l.startTime,
checkOutAt: l.endTime,
checkIn: AppTime.formatTime(l.startTime),
checkOut: AppTime.formatTime(l.endTime),
duration: '',
status: 'On Leave',
isAbsent: false,
isLeave: true,
leaveType: l.leaveType,
enrolledFaceUrl: profile?.facePhotoUrl,
);
});
final scheduleById = {for (final s in schedules) s.id: s};
final List<_LogbookEntry> entries = [
for (final l in filtered)
_LogbookEntry(
name: profileById[l.userId]?.fullName ?? l.userId,
userId: l.userId,
dutyScheduleId: l.dutyScheduleId,
shift: _LogbookEntry._shiftLabelFromType(
scheduleById[l.dutyScheduleId]?.shiftType ?? l.shiftType,
),
date: l.checkInAt,
checkInAt: l.checkInAt,
checkOutAt: l.checkOutAt,
checkIn: AppTime.formatTime(l.checkInAt),
checkOut: l.isCheckedOut ? AppTime.formatTime(l.checkOutAt!) : '',
duration: l.isCheckedOut
? _LogbookEntry._fmtDur(l.checkOutAt!.difference(l.checkInAt))
: 'On duty',
status: l.isCheckedOut ? 'Completed' : 'On duty',
isAbsent: false,
verificationStatus: l.verificationStatus,
logId: l.id,
logUserId: l.userId,
enrolledFaceUrl: profileById[l.userId]?.facePhotoUrl,
checkInVerificationFaceUrl: l.checkInVerificationPhotoUrl,
checkOutVerificationFaceUrl: l.checkOutVerificationPhotoUrl,
justification: l.justification,
checkOutJustification: l.checkOutJustification,
checkInLat: l.checkInLat,
checkInLng: l.checkInLng,
checkOutLat: l.checkOutLat,
checkOutLng: l.checkOutLng,
),
...absentSchedules.map((s) => _LogbookEntry.absent(s, profileById)),
...leaveEntries,
];
final filteredEntries = selectedUserIds.isEmpty
? entries
: entries.where((e) => selectedUserIds.contains(e.userId)).toList();
filteredEntries.sort((a, b) => b.date.compareTo(a.date));
return filteredEntries;
}
void _reverify(BuildContext context, WidgetRef ref, String logId) async {
final profile = ref.read(currentProfileProvider).valueOrNull;
if (profile == null || !profile.hasFaceEnrolled) {
@ -2008,45 +2171,21 @@ class _LogbookTab extends ConsumerWidget {
);
}
Widget _buildDataTable(
Widget _buildShiftGroupList(
BuildContext context,
List<_LogbookEntry> entries, {
required String currentUserId,
required void Function(String logId) onReverify,
}) {
// Group entries by date.
final grouped = _groupByDate(entries);
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: grouped.entries.map((group) {
return _DateGroupTile(
dateLabel: group.key,
entries: group.value,
useTable: true,
currentUserId: currentUserId,
onReverify: onReverify,
);
}).toList(),
),
);
}
final groupedByDate = _groupByDate(entries);
Widget _buildLogList(
BuildContext context,
List<_LogbookEntry> entries, {
required String currentUserId,
required void Function(String logId) onReverify,
}) {
// Group entries by date.
final grouped = _groupByDate(entries);
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: grouped.entries.map((group) {
children: groupedByDate.entries.map((group) {
final shiftGroups = _groupByShift(group.value);
return _DateGroupTile(
dateLabel: group.key,
entries: group.value,
useTable: false,
shiftGroups: shiftGroups,
currentUserId: currentUserId,
onReverify: onReverify,
);
@ -2054,6 +2193,39 @@ class _LogbookTab extends ConsumerWidget {
);
}
/// Group list of entries by shift (user + schedule + date).
static List<_ShiftGroup> _groupByShift(List<_LogbookEntry> entries) {
final groups = <String, _ShiftGroup>{};
for (final entry in entries) {
final dateKey = AppTime.formatDate(entry.date);
final key = entry.isLeave
? 'leave|${entry.userId}|$dateKey|${entry.leaveType ?? ''}'
: 'shift|${entry.userId}|${entry.dutyScheduleId ?? ''}|$dateKey';
final group = groups.putIfAbsent(
key,
() => _ShiftGroup(
groupKey: key,
userId: entry.userId,
dutyScheduleId: entry.dutyScheduleId,
name: entry.name,
shiftLabel: entry.shift,
sessions: [],
),
);
group.sessions.add(entry);
}
for (final g in groups.values) {
g.sessions.sort((a, b) => a.date.compareTo(b.date));
}
final list = groups.values.toList();
list.sort((a, b) => b.date.compareTo(a.date));
return list;
}
/// Group sorted entries by formatted date string (preserving order).
static Map<String, List<_LogbookEntry>> _groupByDate(
List<_LogbookEntry> entries,
@ -2074,15 +2246,13 @@ class _LogbookTab extends ConsumerWidget {
class _DateGroupTile extends StatelessWidget {
const _DateGroupTile({
required this.dateLabel,
required this.entries,
required this.useTable,
required this.shiftGroups,
required this.currentUserId,
required this.onReverify,
});
final String dateLabel;
final List<_LogbookEntry> entries;
final bool useTable;
final List<_ShiftGroup> shiftGroups;
final String currentUserId;
final void Function(String logId) onReverify;
@ -2101,145 +2271,116 @@ class _DateGroupTile extends StatelessWidget {
style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
subtitle: Text(
'${entries.length} ${entries.length == 1 ? 'entry' : 'entries'}',
'${shiftGroups.length} ${shiftGroups.length == 1 ? 'shift' : 'shifts'}',
style: textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant),
),
children: [if (useTable) _buildTable(context) else _buildList(context)],
),
);
}
Widget _buildTable(BuildContext context) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columns: const [
DataColumn(label: Text('Staff')),
DataColumn(label: Text('Shift')),
DataColumn(label: Text('Check In')),
DataColumn(label: Text('Check Out')),
DataColumn(label: Text('Duration')),
DataColumn(label: Text('Status')),
DataColumn(label: Text('Verified')),
],
rows: entries.map((entry) {
final Color statusColor;
if (entry.isLeave) {
statusColor = Colors.teal;
} else if (entry.isAbsent) {
statusColor = Colors.red;
} else if (entry.status == 'On duty') {
statusColor = Colors.orange;
} else {
statusColor = Colors.green;
}
final statusText = entry.isLeave
? 'On Leave${entry.leaveType != null ? ' (${_leaveLabel(entry.leaveType!)})' : ''}'
: entry.status;
return DataRow(
cells: [
DataCell(Text(entry.name)),
DataCell(Text(entry.shift)),
DataCell(Text(entry.checkIn)),
DataCell(Text(entry.checkOut)),
DataCell(Text(entry.duration)),
DataCell(
Text(
statusText,
style: TextStyle(
color: statusColor,
fontWeight: (entry.isAbsent || entry.isLeave)
? FontWeight.w600
: null,
),
),
),
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
_verificationBadge(context, entry),
if (entry.canReverify(currentUserId)) ...[
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.refresh, size: 16),
tooltip: 'Re-verify',
onPressed: () => onReverify(entry.logId!),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 28,
minHeight: 28,
),
visualDensity: VisualDensity.compact,
),
],
],
),
),
],
);
children: shiftGroups.map((group) {
return _buildShiftGroupTile(context, group);
}).toList(),
),
);
}
Widget _buildList(BuildContext context) {
Widget _buildShiftGroupTile(BuildContext context, _ShiftGroup group) {
// If the group is an absence or leave, render as a single row.
final first = group.sessions.first;
if (first.isAbsent || first.isLeave) {
return _buildSessionTile(context, first);
}
final colors = Theme.of(context).colorScheme;
return Column(
children: entries.map((entry) {
return ListTile(
title: Row(
children: [
Expanded(child: Text(entry.name)),
_verificationBadge(context, entry),
if (entry.canReverify(currentUserId))
IconButton(
icon: Icon(
Icons.refresh,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
tooltip: 'Re-verify',
onPressed: () => onReverify(entry.logId!),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 28,
minHeight: 28,
),
visualDensity: VisualDensity.compact,
),
],
),
subtitle: Text(
entry.isLeave
? 'On Leave${entry.leaveType != null ? '${_leaveLabel(entry.leaveType!)}' : ''}'
: entry.isAbsent
? 'Absent — no check-in recorded'
: 'Shift: ${entry.shift} · In: ${entry.checkIn}${entry.checkOut != "" ? " · Out: ${entry.checkOut}" : " · On duty"}',
),
trailing: entry.isLeave
? Chip(
label: const Text('On Leave'),
backgroundColor: Colors.teal.withValues(alpha: 0.15),
)
: entry.isAbsent
? Chip(
label: const Text('Absent'),
backgroundColor: colors.errorContainer,
)
: entry.status == 'On duty'
? Chip(
label: const Text('On duty'),
backgroundColor: colors.tertiaryContainer,
)
: Text(
entry.duration,
style: Theme.of(context).textTheme.bodySmall,
),
);
}).toList(),
final textTheme = Theme.of(context).textTheme;
final statusColor = group.status == 'Absent'
? Colors.red
: group.status == 'On duty'
? Colors.orange
: group.status == 'On Leave'
? Colors.teal
: Colors.green;
return Card(
margin: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
childrenPadding: const EdgeInsets.symmetric(horizontal: 16),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${group.name} · ${group.shiftLabel}',
style: textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
'In: ${group.checkIn} · Out: ${group.checkOut} · ${group.duration}',
style: textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
subtitle: Row(
children: [
Chip(
label: Text(group.status),
backgroundColor: statusColor.withValues(alpha: 0.15),
),
],
),
children: group.sessions.map((session) {
return _buildSessionTile(context, session);
}).toList(),
),
);
}
Widget _buildSessionTile(BuildContext context, _LogbookEntry entry) {
final colors = Theme.of(context).colorScheme;
final isReverifyable = entry.canReverify(currentUserId);
final isLeaveOrAbsent = entry.isLeave || entry.isAbsent;
return ListTile(
title: Row(
children: [
Expanded(child: Text(entry.name)),
if (!isLeaveOrAbsent) _verificationBadge(context, entry),
if (isReverifyable)
IconButton(
icon: Icon(
Icons.refresh,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
tooltip: 'Re-verify',
onPressed: () => onReverify(entry.logId!),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
visualDensity: VisualDensity.compact,
),
],
),
subtitle: Text(
entry.isLeave
? 'On Leave${entry.leaveType != null ? '${_leaveLabel(entry.leaveType!)}' : ''}'
: entry.isAbsent
? 'Absent — no check-in recorded'
: 'In: ${entry.checkIn} · Out: ${entry.checkOut} · ${entry.duration}${entry.status == 'On duty' ? ' · On duty' : ''}',
),
trailing: isLeaveOrAbsent
? Chip(
label: Text(entry.isLeave ? 'On Leave' : 'Absent'),
backgroundColor: entry.isLeave
? Colors.teal.withValues(alpha: 0.15)
: colors.errorContainer,
)
: entry.status == 'On duty'
? Chip(
label: const Text('On duty'),
backgroundColor: colors.tertiaryContainer,
)
: Text(entry.duration, style: Theme.of(context).textTheme.bodySmall),
);
}