Worklog, anti geo spoofing and UI Polish
This commit is contained in:
@@ -28,9 +28,14 @@ import '../../providers/notifications_provider.dart';
|
||||
import '../../providers/it_service_request_provider.dart';
|
||||
import '../../models/it_service_request.dart';
|
||||
import '../../models/swap_request.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import 'logbook_day_activity.dart';
|
||||
import 'work_log_tab.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import '../../theme/app_surfaces.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
import '../../utils/device_security.dart';
|
||||
import '../../utils/location_permission.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import '../../widgets/face_verification_overlay.dart';
|
||||
@@ -63,7 +68,7 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 5, vsync: this);
|
||||
_tabController = TabController(length: 6, vsync: this);
|
||||
_tabController.addListener(_onTabChanged);
|
||||
}
|
||||
|
||||
@@ -84,7 +89,7 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final profile = ref.watch(currentProfileProvider).valueOrNull;
|
||||
final showFab = _tabController.index >= 3; // Pass Slip or Leave tabs
|
||||
final showFab = _tabController.index >= 4; // Pass Slip or Leave tabs
|
||||
|
||||
return ResponsiveBody(
|
||||
maxWidth: 1200,
|
||||
@@ -106,6 +111,7 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
|
||||
tabs: const [
|
||||
Tab(text: 'Check In'),
|
||||
Tab(text: 'Logbook'),
|
||||
Tab(text: 'Work Log'),
|
||||
Tab(text: 'My Schedule'),
|
||||
Tab(text: 'Pass Slip'),
|
||||
Tab(text: 'Leave'),
|
||||
@@ -117,6 +123,7 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
|
||||
children: [
|
||||
const _CheckInTab(),
|
||||
const _LogbookTab(),
|
||||
const WorkLogTab(),
|
||||
_MyScheduleTab(),
|
||||
const _PassSlipTab(),
|
||||
const _LeaveTab(),
|
||||
@@ -304,6 +311,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
// Geofence state
|
||||
bool _insideGeofence = false;
|
||||
bool _checkingGeofence = true;
|
||||
bool _deviceCompromised = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -312,6 +320,9 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
if (mounted) setState(() => _currentTime = AppTime.now());
|
||||
});
|
||||
_checkGeofenceStatus();
|
||||
isDeviceCompromised().then((compromised) {
|
||||
if (mounted) setState(() => _deviceCompromised = compromised);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -340,6 +351,15 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
accuracy: LocationAccuracy.high,
|
||||
),
|
||||
);
|
||||
if (position.isMocked) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_insideGeofence = false;
|
||||
_checkingGeofence = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
bool inside = true;
|
||||
if (geoCfg != null) {
|
||||
if (geoCfg.hasPolygon) {
|
||||
@@ -987,6 +1007,13 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
}
|
||||
|
||||
Future<void> _handleCheckIn(DutySchedule schedule) async {
|
||||
if (_deviceCompromised) {
|
||||
showWarningSnackBar(
|
||||
context,
|
||||
'Check-in is not allowed on rooted, jailbroken, or developer-mode devices.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
// Ensure location permission before check-in
|
||||
@@ -1006,6 +1033,17 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
accuracy: LocationAccuracy.high,
|
||||
),
|
||||
);
|
||||
if (position.isMocked || position.accuracy == 0.0) {
|
||||
if (mounted) {
|
||||
showWarningSnackBar(
|
||||
context,
|
||||
position.isMocked
|
||||
? 'Mock location detected. Check-in is not allowed.'
|
||||
: 'Location accuracy is invalid. Please disable mock location apps.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Client-side geofence check (can be bypassed in debug mode or by an approved IT request)
|
||||
final debugBypass =
|
||||
kDebugMode && ref.read(debugSettingsProvider).bypassGeofence;
|
||||
@@ -1066,6 +1104,8 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
dutyScheduleId: schedule.id,
|
||||
lat: position.latitude,
|
||||
lng: position.longitude,
|
||||
accuracy: position.accuracy,
|
||||
isMocked: position.isMocked,
|
||||
);
|
||||
// automatically enable tracking when user checks in
|
||||
try {
|
||||
@@ -1110,6 +1150,15 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
accuracy: LocationAccuracy.high,
|
||||
),
|
||||
);
|
||||
if (position.isMocked) {
|
||||
if (mounted) {
|
||||
showWarningSnackBar(
|
||||
context,
|
||||
'Mock location detected. Check-out is not allowed.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if outside geofence — require justification if so
|
||||
String? checkOutJustification;
|
||||
@@ -1150,6 +1199,8 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
lat: position.latitude,
|
||||
lng: position.longitude,
|
||||
justification: checkOutJustification,
|
||||
accuracy: position.accuracy,
|
||||
isMocked: position.isMocked,
|
||||
);
|
||||
// automatically disable tracking when user checks out
|
||||
try {
|
||||
@@ -1197,6 +1248,15 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
accuracy: LocationAccuracy.high,
|
||||
),
|
||||
);
|
||||
if (position.isMocked) {
|
||||
if (mounted) {
|
||||
showWarningSnackBar(
|
||||
context,
|
||||
'Mock location detected. Check-out is not allowed.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if outside geofence — require justification if so
|
||||
String? checkOutJustification;
|
||||
@@ -1237,6 +1297,8 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
lat: position.latitude,
|
||||
lng: position.longitude,
|
||||
justification: checkOutJustification,
|
||||
accuracy: position.accuracy,
|
||||
isMocked: position.isMocked,
|
||||
);
|
||||
// Update live position immediately on check-out
|
||||
ref.read(whereaboutsControllerProvider).updatePositionNow();
|
||||
@@ -1316,6 +1378,13 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
}
|
||||
|
||||
Future<void> _handleOvertimeCheckIn() async {
|
||||
if (_deviceCompromised) {
|
||||
showWarningSnackBar(
|
||||
context,
|
||||
'Check-in is not allowed on rooted, jailbroken, or developer-mode devices.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final justification = _justificationController.text.trim();
|
||||
if (justification.isEmpty) {
|
||||
showWarningSnackBar(
|
||||
@@ -1332,6 +1401,17 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
accuracy: LocationAccuracy.high,
|
||||
),
|
||||
);
|
||||
if (position.isMocked || position.accuracy == 0.0) {
|
||||
if (mounted) {
|
||||
showWarningSnackBar(
|
||||
context,
|
||||
position.isMocked
|
||||
? 'Mock location detected. Check-in is not allowed.'
|
||||
: 'Location accuracy is invalid. Please disable mock location apps.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final debugBypass =
|
||||
kDebugMode && ref.read(debugSettingsProvider).bypassGeofence;
|
||||
if (geoCfg != null && !debugBypass) {
|
||||
@@ -1363,6 +1443,8 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
lat: position.latitude,
|
||||
lng: position.longitude,
|
||||
justification: justification,
|
||||
accuracy: position.accuracy,
|
||||
isMocked: position.isMocked,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -1711,6 +1793,8 @@ class _LogbookEntry {
|
||||
this.checkInLng,
|
||||
this.checkOutLat,
|
||||
this.checkOutLng,
|
||||
this.rawLog,
|
||||
this.swapRequestId,
|
||||
});
|
||||
|
||||
final String name;
|
||||
@@ -1739,6 +1823,10 @@ class _LogbookEntry {
|
||||
final double? checkInLng;
|
||||
final double? checkOutLat;
|
||||
final double? checkOutLng;
|
||||
// Raw attendance log for building detailed day activity view.
|
||||
final AttendanceLog? rawLog;
|
||||
// Non-null when the duty schedule was created by an accepted swap.
|
||||
final String? swapRequestId;
|
||||
|
||||
/// Whether this entry can be re-verified (within 10 min of check-in).
|
||||
bool canReverify(String currentUserId) {
|
||||
@@ -1812,6 +1900,8 @@ class _ShiftGroup {
|
||||
final String name;
|
||||
final String shiftLabel;
|
||||
final List<_LogbookEntry> sessions;
|
||||
// Pass slips tied to this shift's duty schedule — populated after grouping.
|
||||
List<PassSlip> passSlips = const [];
|
||||
|
||||
DateTime get date => sessions.first.date;
|
||||
|
||||
@@ -1848,6 +1938,9 @@ class _ShiftGroup {
|
||||
/// Whether any session in this group is still in progress.
|
||||
bool get hasOngoingSession =>
|
||||
sessions.any((s) => !s.isAbsent && !s.isLeave && s.checkOutAt == null);
|
||||
|
||||
/// Whether this shift was created by an accepted swap request.
|
||||
bool get isSwapped => sessions.any((s) => s.swapRequestId != null);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
@@ -1874,6 +1967,7 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> {
|
||||
final profilesAsync = ref.watch(profilesProvider);
|
||||
final schedulesAsync = ref.watch(dutySchedulesProvider);
|
||||
final leavesAsync = ref.watch(leavesProvider);
|
||||
final passSlips = ref.watch(passSlipsProvider).valueOrNull ?? [];
|
||||
|
||||
final selectedUserIds = ref.watch(attendanceUserFilterProvider);
|
||||
|
||||
@@ -1981,8 +2075,11 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> {
|
||||
return _buildShiftGroupList(
|
||||
context,
|
||||
entries,
|
||||
passSlips: passSlips,
|
||||
currentUserId: currentUserId,
|
||||
onReverify: (logId) => _reverify(context, ref, logId),
|
||||
onViewActivity: (group) =>
|
||||
_showDayActivity(context, group),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
@@ -2150,6 +2247,8 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> {
|
||||
checkInLng: l.checkInLng,
|
||||
checkOutLat: l.checkOutLat,
|
||||
checkOutLng: l.checkOutLng,
|
||||
rawLog: l,
|
||||
swapRequestId: scheduleById[l.dutyScheduleId]?.swapRequestId,
|
||||
),
|
||||
...absentSchedules.map((s) => _LogbookEntry.absent(s, profileById)),
|
||||
|
||||
@@ -2207,11 +2306,21 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> {
|
||||
Widget _buildShiftGroupList(
|
||||
BuildContext context,
|
||||
List<_LogbookEntry> entries, {
|
||||
required List<PassSlip> passSlips,
|
||||
required String currentUserId,
|
||||
required void Function(String logId) onReverify,
|
||||
required void Function(_ShiftGroup group) onViewActivity,
|
||||
}) {
|
||||
final groupedByDate = _groupByDate(entries);
|
||||
|
||||
// Build lookup: dutyScheduleId → pass slips for that shift.
|
||||
final slipsBySchedule = <String, List<PassSlip>>{};
|
||||
for (final slip in passSlips) {
|
||||
slipsBySchedule
|
||||
.putIfAbsent(slip.dutyScheduleId, () => [])
|
||||
.add(slip);
|
||||
}
|
||||
|
||||
final groups = groupedByDate.entries.toList();
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
@@ -2221,9 +2330,16 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> {
|
||||
delay: Duration(milliseconds: i.clamp(0, 6) * 60),
|
||||
child: _DateGroupTile(
|
||||
dateLabel: groups[i].key,
|
||||
shiftGroups: _groupByShift(groups[i].value),
|
||||
shiftGroups: _groupByShift(groups[i].value)
|
||||
..forEach((g) {
|
||||
if (g.dutyScheduleId != null) {
|
||||
g.passSlips =
|
||||
slipsBySchedule[g.dutyScheduleId!] ?? const [];
|
||||
}
|
||||
}),
|
||||
currentUserId: currentUserId,
|
||||
onReverify: onReverify,
|
||||
onViewActivity: onViewActivity,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -2274,6 +2390,84 @@ class _LogbookTabState extends ConsumerState<_LogbookTab> {
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
void _showDayActivity(BuildContext context, _ShiftGroup group) {
|
||||
final allTasks = ref.read(tasksProvider).valueOrNull ?? [];
|
||||
final allTickets = ref.read(ticketsProvider).valueOrNull ?? [];
|
||||
final allIsrs = ref.read(itServiceRequestsProvider).valueOrNull ?? [];
|
||||
|
||||
final dayStart =
|
||||
DateTime(group.date.year, group.date.month, group.date.day);
|
||||
final dayEnd = dayStart.add(const Duration(days: 1));
|
||||
|
||||
bool onDay(DateTime? dt) =>
|
||||
dt != null && !dt.isBefore(dayStart) && dt.isBefore(dayEnd);
|
||||
|
||||
final data = LogbookDayActivityData(
|
||||
userId: group.userId,
|
||||
name: group.name,
|
||||
date: group.date,
|
||||
shiftLabel: group.shiftLabel,
|
||||
isSwapped: group.isSwapped,
|
||||
sessions: group.sessions
|
||||
.where((e) => e.rawLog != null)
|
||||
.map((e) => e.rawLog!)
|
||||
.toList(),
|
||||
passSlips: group.passSlips,
|
||||
tasks: allTasks
|
||||
.where((t) =>
|
||||
t.creatorId == group.userId &&
|
||||
(onDay(t.startedAt) ||
|
||||
onDay(t.completedAt) ||
|
||||
onDay(t.createdAt)))
|
||||
.toList(),
|
||||
tickets: allTickets
|
||||
.where((t) =>
|
||||
t.creatorId == group.userId &&
|
||||
(onDay(t.createdAt) ||
|
||||
onDay(t.respondedAt) ||
|
||||
onDay(t.promotedAt)))
|
||||
.toList(),
|
||||
serviceRequests: allIsrs
|
||||
.where((r) =>
|
||||
(r.creatorId == group.userId ||
|
||||
r.requestedByUserId == group.userId) &&
|
||||
(onDay(r.createdAt) ||
|
||||
onDay(r.updatedAt) ||
|
||||
onDay(r.completedAt)))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
final isMobile =
|
||||
MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
|
||||
if (isMobile) {
|
||||
m3ShowBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (_) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.95,
|
||||
expand: false,
|
||||
builder: (_, scrollController) => SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: LogbookDayActivitySheet(data: data),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
m3ShowDialog(
|
||||
context: context,
|
||||
builder: (_) => Dialog(
|
||||
child: SizedBox(
|
||||
width: 560,
|
||||
child: LogbookDayActivitySheet(data: data),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
@@ -2286,12 +2480,14 @@ class _DateGroupTile extends StatelessWidget {
|
||||
required this.shiftGroups,
|
||||
required this.currentUserId,
|
||||
required this.onReverify,
|
||||
required this.onViewActivity,
|
||||
});
|
||||
|
||||
final String dateLabel;
|
||||
final List<_ShiftGroup> shiftGroups;
|
||||
final String currentUserId;
|
||||
final void Function(String logId) onReverify;
|
||||
final void Function(_ShiftGroup group) onViewActivity;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -2312,17 +2508,21 @@ class _DateGroupTile extends StatelessWidget {
|
||||
style: textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant),
|
||||
),
|
||||
children: shiftGroups.map((group) {
|
||||
return _buildShiftGroupTile(context, group);
|
||||
return _buildShiftGroupTile(context, group, onViewActivity);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildShiftGroupTile(BuildContext context, _ShiftGroup group) {
|
||||
Widget _buildShiftGroupTile(
|
||||
BuildContext context,
|
||||
_ShiftGroup group,
|
||||
void Function(_ShiftGroup) onViewActivity,
|
||||
) {
|
||||
// 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);
|
||||
return _buildSessionTile(context, first, 0, 1);
|
||||
}
|
||||
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
@@ -2341,12 +2541,67 @@ class _DateGroupTile extends StatelessWidget {
|
||||
child: ExpansionTile(
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
childrenPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// View Activity button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.timeline_outlined),
|
||||
tooltip: 'View day activity',
|
||||
onPressed: () => onViewActivity(group),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
// Default expand/collapse icon
|
||||
const Icon(Icons.expand_more),
|
||||
],
|
||||
),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${group.name} · ${group.shiftLabel}',
|
||||
style: textTheme.titleSmall,
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${group.name} · ${group.shiftLabel}',
|
||||
style: textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
// Swap badge — fades in when shift is a swap
|
||||
AnimatedOpacity(
|
||||
opacity: group.isSwapped ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
child: group.isSwapped
|
||||
? Container(
|
||||
margin: const EdgeInsets.only(left: 6),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.swap_horiz_rounded,
|
||||
size: 12,
|
||||
color: colors.onSecondaryContainer),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'Swapped',
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: colors.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
@@ -2365,14 +2620,104 @@ class _DateGroupTile extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
children: group.sessions.map((session) {
|
||||
return _buildSessionTile(context, session);
|
||||
}).toList(),
|
||||
children: [
|
||||
...group.sessions.asMap().entries.map((e) {
|
||||
return _buildSessionTile(
|
||||
context, e.value, e.key, group.sessions.length);
|
||||
}),
|
||||
// Pass slip rows
|
||||
if (group.passSlips.isNotEmpty)
|
||||
...group.passSlips.asMap().entries.map((e) {
|
||||
return M3FadeSlideIn(
|
||||
delay: Duration(milliseconds: e.key * 40),
|
||||
child: _buildPassSlipRow(context, e.value),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSessionTile(BuildContext context, _LogbookEntry entry) {
|
||||
Widget _buildPassSlipRow(BuildContext context, PassSlip slip) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
Color statusColor;
|
||||
String statusLabel;
|
||||
switch (slip.status) {
|
||||
case 'approved':
|
||||
statusColor = Colors.green;
|
||||
statusLabel = 'Approved';
|
||||
case 'pending':
|
||||
statusColor = Colors.orange;
|
||||
statusLabel = 'Pending';
|
||||
case 'rejected':
|
||||
statusColor = colors.error;
|
||||
statusLabel = 'Rejected';
|
||||
default:
|
||||
statusColor = colors.outline;
|
||||
statusLabel = slip.status;
|
||||
}
|
||||
|
||||
String timeRange;
|
||||
if (slip.slipStart != null) {
|
||||
final start = AppTime.formatTime(slip.slipStart!);
|
||||
final end = slip.slipEnd != null
|
||||
? AppTime.formatTime(slip.slipEnd!)
|
||||
: 'ongoing';
|
||||
timeRange = '$start – $end';
|
||||
} else {
|
||||
timeRange = 'Pending start';
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
leading: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: colors.tertiaryContainer,
|
||||
child: Icon(Icons.badge_outlined,
|
||||
size: 16, color: colors.onTertiaryContainer),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Text('Pass Slip', style: textTheme.bodyMedium),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
statusLabel,
|
||||
style: textTheme.labelSmall?.copyWith(color: statusColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
slip.reason,
|
||||
style: textTheme.bodySmall
|
||||
?.copyWith(color: colors.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
timeRange,
|
||||
style: textTheme.labelSmall
|
||||
?.copyWith(color: colors.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSessionTile(
|
||||
BuildContext context, _LogbookEntry entry, int index, int total) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final isReverifyable = entry.canReverify(currentUserId);
|
||||
|
||||
@@ -2381,7 +2726,20 @@ class _DateGroupTile extends StatelessWidget {
|
||||
return ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(entry.name)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.isLeave
|
||||
? 'Leave${entry.leaveType != null ? ' — ${_leaveLabel(entry.leaveType!)}' : ''}'
|
||||
: entry.isAbsent
|
||||
? 'No Check-In Recorded'
|
||||
: entry.justification?.isNotEmpty == true
|
||||
? entry.justification!
|
||||
: total > 1
|
||||
? 'Session ${index + 1} of $total'
|
||||
: 'Attendance Session',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
if (!isLeaveOrAbsent) _verificationBadge(context, entry),
|
||||
if (isReverifyable)
|
||||
IconButton(
|
||||
|
||||
Reference in New Issue
Block a user