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). /// All visible attendance logs (own for standard, all for admin/dispatcher/it_staff).
final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) { final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);

View File

@ -32,6 +32,7 @@ import '../../widgets/face_verification_overlay.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
import '../../widgets/gemini_animated_text_field.dart'; import '../../widgets/gemini_animated_text_field.dart';
import '../../widgets/gemini_button.dart'; import '../../widgets/gemini_button.dart';
import '../../widgets/multi_select_picker.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
class AttendanceScreen extends ConsumerStatefulWidget { class AttendanceScreen extends ConsumerStatefulWidget {
@ -1652,8 +1653,12 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
class _LogbookEntry { class _LogbookEntry {
_LogbookEntry({ _LogbookEntry({
required this.name, required this.name,
required this.userId,
required this.dutyScheduleId,
required this.shift, required this.shift,
required this.date, required this.date,
required this.checkInAt,
required this.checkOutAt,
required this.checkIn, required this.checkIn,
required this.checkOut, required this.checkOut,
required this.duration, required this.duration,
@ -1676,8 +1681,12 @@ class _LogbookEntry {
}); });
final String name; final String name;
final String userId;
final String? dutyScheduleId;
final String shift; final String shift;
final DateTime date; final DateTime date;
final DateTime checkInAt;
final DateTime? checkOutAt;
final String checkIn; final String checkIn;
final String checkOut; final String checkOut;
final String duration; final String duration;
@ -1708,48 +1717,20 @@ class _LogbookEntry {
return elapsed.inMinutes <= 10; return elapsed.inMinutes <= 10;
} }
factory _LogbookEntry.fromLog( // NOTE: logbook entry creation is handled in an async provider (so that
AttendanceLog log, // filtering/sorting does not block the UI). Use `_computeLogbookEntries`.
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,
);
}
factory _LogbookEntry.absent(DutySchedule s, Map<String, Profile> byId) { factory _LogbookEntry.absent(DutySchedule s, Map<String, Object?> byId) {
final p = byId[s.userId]; final p = byId[s.userId];
final name = p is Profile ? p.fullName : (p as String?) ?? s.userId;
return _LogbookEntry( return _LogbookEntry(
name: p?.fullName ?? s.userId, name: name,
userId: s.userId,
dutyScheduleId: s.id,
shift: _shiftLabelFromType(s.shiftType), shift: _shiftLabelFromType(s.shiftType),
date: s.startTime, date: s.startTime,
checkInAt: s.startTime,
checkOutAt: null,
checkIn: '', checkIn: '',
checkOut: '', checkOut: '',
duration: '', 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 // Tab 2 Logbook
// //
class _LogbookTab extends ConsumerWidget { class _LogbookTab extends ConsumerStatefulWidget {
const _LogbookTab(); const _LogbookTab();
@override @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 theme = Theme.of(context);
final colors = theme.colorScheme; final colors = theme.colorScheme;
final range = ref.watch(attendanceDateRangeProvider); final range = ref.watch(attendanceDateRangeProvider);
@ -1797,11 +1842,15 @@ class _LogbookTab extends ConsumerWidget {
final schedulesAsync = ref.watch(dutySchedulesProvider); final schedulesAsync = ref.watch(dutySchedulesProvider);
final leavesAsync = ref.watch(leavesProvider); final leavesAsync = ref.watch(leavesProvider);
final Map<String, Profile> profileById = { final selectedUserIds = ref.watch(attendanceUserFilterProvider);
for (final p in profilesAsync.valueOrNull ?? []) p.id: p,
};
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( return Column(
children: [ children: [
@ -1840,94 +1889,49 @@ 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), const SizedBox(height: 8),
Expanded( Expanded(
child: logsAsync.when( child: logsAsync.when(
data: (logs) { data: (logs) {
final filtered = logs.where((log) { _recomputeEntriesIfNeeded(
return !log.checkInAt.isBefore(range.start) && logs: logs,
log.checkInAt.isBefore(range.end); range: range,
}).toList(); schedules: schedulesAsync.valueOrNull ?? [],
leaves: leavesAsync.valueOrNull ?? [],
// Build absent entries from past schedules with no logs. profileList: profilesAsync.valueOrNull ?? [],
final allSchedules = schedulesAsync.valueOrNull ?? []; selectedUserIds: selectedUserIds,
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));
return _entriesAsync.when(
data: (entries) {
if (entries.isEmpty) { if (entries.isEmpty) {
return Center( return Center(
child: Text( child: Text(
@ -1941,23 +1945,15 @@ class _LogbookTab extends ConsumerWidget {
final currentUserId = ref.read(currentUserIdProvider) ?? ''; final currentUserId = ref.read(currentUserIdProvider) ?? '';
return LayoutBuilder( return _buildShiftGroupList(
builder: (context, constraints) {
if (constraints.maxWidth >= 700) {
return _buildDataTable(
context,
entries,
currentUserId: currentUserId,
onReverify: (logId) => _reverify(context, ref, logId),
);
}
return _buildLogList(
context, context,
entries, entries,
currentUserId: currentUserId, currentUserId: currentUserId,
onReverify: (logId) => _reverify(context, ref, logId), 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()), 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 { void _reverify(BuildContext context, WidgetRef ref, String logId) async {
final profile = ref.read(currentProfileProvider).valueOrNull; final profile = ref.read(currentProfileProvider).valueOrNull;
if (profile == null || !profile.hasFaceEnrolled) { if (profile == null || !profile.hasFaceEnrolled) {
@ -2008,50 +2171,59 @@ class _LogbookTab extends ConsumerWidget {
); );
} }
Widget _buildDataTable( Widget _buildShiftGroupList(
BuildContext context, BuildContext context,
List<_LogbookEntry> entries, { List<_LogbookEntry> entries, {
required String currentUserId, required String currentUserId,
required void Function(String logId) onReverify, required void Function(String logId) onReverify,
}) { }) {
// Group entries by date. final groupedByDate = _groupByDate(entries);
final grouped = _groupByDate(entries);
return SingleChildScrollView( return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column( children: groupedByDate.entries.map((group) {
children: grouped.entries.map((group) { final shiftGroups = _groupByShift(group.value);
return _DateGroupTile( return _DateGroupTile(
dateLabel: group.key, dateLabel: group.key,
entries: group.value, shiftGroups: shiftGroups,
useTable: true,
currentUserId: currentUserId, currentUserId: currentUserId,
onReverify: onReverify, onReverify: onReverify,
); );
}).toList(), }).toList(),
),
); );
} }
Widget _buildLogList( /// Group list of entries by shift (user + schedule + date).
BuildContext context, static List<_ShiftGroup> _groupByShift(List<_LogbookEntry> entries) {
List<_LogbookEntry> entries, { final groups = <String, _ShiftGroup>{};
required String currentUserId,
required void Function(String logId) onReverify, for (final entry in entries) {
}) { final dateKey = AppTime.formatDate(entry.date);
// Group entries by date. final key = entry.isLeave
final grouped = _groupByDate(entries); ? 'leave|${entry.userId}|$dateKey|${entry.leaveType ?? ''}'
return ListView( : 'shift|${entry.userId}|${entry.dutyScheduleId ?? ''}|$dateKey';
padding: const EdgeInsets.symmetric(horizontal: 16),
children: grouped.entries.map((group) { final group = groups.putIfAbsent(
return _DateGroupTile( key,
dateLabel: group.key, () => _ShiftGroup(
entries: group.value, groupKey: key,
useTable: false, userId: entry.userId,
currentUserId: currentUserId, dutyScheduleId: entry.dutyScheduleId,
onReverify: onReverify, name: entry.name,
); shiftLabel: entry.shift,
}).toList(), 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). /// Group sorted entries by formatted date string (preserving order).
@ -2074,15 +2246,13 @@ class _LogbookTab extends ConsumerWidget {
class _DateGroupTile extends StatelessWidget { class _DateGroupTile extends StatelessWidget {
const _DateGroupTile({ const _DateGroupTile({
required this.dateLabel, required this.dateLabel,
required this.entries, required this.shiftGroups,
required this.useTable,
required this.currentUserId, required this.currentUserId,
required this.onReverify, required this.onReverify,
}); });
final String dateLabel; final String dateLabel;
final List<_LogbookEntry> entries; final List<_ShiftGroup> shiftGroups;
final bool useTable;
final String currentUserId; final String currentUserId;
final void Function(String logId) onReverify; final void Function(String logId) onReverify;
@ -2101,100 +2271,82 @@ class _DateGroupTile extends StatelessWidget {
style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
), ),
subtitle: Text( subtitle: Text(
'${entries.length} ${entries.length == 1 ? 'entry' : 'entries'}', '${shiftGroups.length} ${shiftGroups.length == 1 ? 'shift' : 'shifts'}',
style: textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant), style: textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant),
), ),
children: [if (useTable) _buildTable(context) else _buildList(context)], children: shiftGroups.map((group) {
), return _buildShiftGroupTile(context, group);
);
}
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,
),
],
],
),
),
],
);
}).toList(), }).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; final colors = Theme.of(context).colorScheme;
return Column( final textTheme = Theme.of(context).textTheme;
children: entries.map((entry) {
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( return ListTile(
title: Row( title: Row(
children: [ children: [
Expanded(child: Text(entry.name)), Expanded(child: Text(entry.name)),
_verificationBadge(context, entry), if (!isLeaveOrAbsent) _verificationBadge(context, entry),
if (entry.canReverify(currentUserId)) if (isReverifyable)
IconButton( IconButton(
icon: Icon( icon: Icon(
Icons.refresh, Icons.refresh,
@ -2204,10 +2356,7 @@ class _DateGroupTile extends StatelessWidget {
tooltip: 'Re-verify', tooltip: 'Re-verify',
onPressed: () => onReverify(entry.logId!), onPressed: () => onReverify(entry.logId!),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints( constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
minWidth: 28,
minHeight: 28,
),
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
), ),
], ],
@ -2217,29 +2366,21 @@ class _DateGroupTile extends StatelessWidget {
? 'On Leave${entry.leaveType != null ? '${_leaveLabel(entry.leaveType!)}' : ''}' ? 'On Leave${entry.leaveType != null ? '${_leaveLabel(entry.leaveType!)}' : ''}'
: entry.isAbsent : entry.isAbsent
? 'Absent — no check-in recorded' ? 'Absent — no check-in recorded'
: 'Shift: ${entry.shift} · In: ${entry.checkIn}${entry.checkOut != "" ? " · Out: ${entry.checkOut}" : " · On duty"}', : 'In: ${entry.checkIn} · Out: ${entry.checkOut} · ${entry.duration}${entry.status == 'On duty' ? ' · On duty' : ''}',
), ),
trailing: entry.isLeave trailing: isLeaveOrAbsent
? Chip( ? Chip(
label: const Text('On Leave'), label: Text(entry.isLeave ? 'On Leave' : 'Absent'),
backgroundColor: Colors.teal.withValues(alpha: 0.15), backgroundColor: entry.isLeave
) ? Colors.teal.withValues(alpha: 0.15)
: entry.isAbsent : colors.errorContainer,
? Chip(
label: const Text('Absent'),
backgroundColor: colors.errorContainer,
) )
: entry.status == 'On duty' : entry.status == 'On duty'
? Chip( ? Chip(
label: const Text('On duty'), label: const Text('On duty'),
backgroundColor: colors.tertiaryContainer, backgroundColor: colors.tertiaryContainer,
) )
: Text( : Text(entry.duration, style: Theme.of(context).textTheme.bodySmall),
entry.duration,
style: Theme.of(context).textTheme.bodySmall,
),
);
}).toList(),
); );
} }