Worklog, anti geo spoofing and UI Polish

This commit is contained in:
2026-05-05 05:51:19 +08:00
parent d068887354
commit ccedf6e5f0
15 changed files with 2589 additions and 45 deletions
+372 -14
View File
@@ -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(
@@ -0,0 +1,596 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/attendance_log.dart';
import '../../models/it_service_request.dart';
import '../../models/pass_slip.dart';
import '../../models/task.dart';
import '../../models/ticket.dart';
import '../../theme/m3_motion.dart';
import '../../utils/app_time.dart';
// ─────────────────────────────────────────────────────────────────────────────
// Data class
// ─────────────────────────────────────────────────────────────────────────────
class LogbookDayActivityData {
final String userId;
final String name;
final DateTime date;
final String shiftLabel;
final DateTime? scheduledStart;
final DateTime? scheduledEnd;
final bool isSwapped;
final List<AttendanceLog> sessions;
final List<PassSlip> passSlips;
final List<Task> tasks;
final List<Ticket> tickets;
final List<ItServiceRequest> serviceRequests;
const LogbookDayActivityData({
required this.userId,
required this.name,
required this.date,
required this.shiftLabel,
this.scheduledStart,
this.scheduledEnd,
this.isSwapped = false,
required this.sessions,
required this.passSlips,
required this.tasks,
required this.tickets,
required this.serviceRequests,
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Timeline event model
// ─────────────────────────────────────────────────────────────────────────────
enum _DayEventType {
shiftScheduled,
swapNote,
checkIn,
passSlipOut,
passSlipReturn,
checkOut,
stillOnDuty,
taskCreated,
taskStarted,
taskCompleted,
ticketCreated,
ticketResponded,
ticketPromoted,
isrCreated,
isrUpdated,
isrCompleted,
}
class _DayEvent {
final _DayEventType type;
final DateTime time;
final String title;
final String? subtitle;
const _DayEvent({
required this.type,
required this.time,
required this.title,
this.subtitle,
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Event assembly
// ─────────────────────────────────────────────────────────────────────────────
List<_DayEvent> _buildEvents(LogbookDayActivityData data) {
final events = <_DayEvent>[];
final t = AppTime.formatTime;
if (data.scheduledStart != null) {
events.add(_DayEvent(
type: _DayEventType.shiftScheduled,
time: data.scheduledStart!,
title: 'Shift scheduled — ${data.shiftLabel}',
subtitle: data.scheduledEnd != null
? '${t(data.scheduledStart!)} ${t(data.scheduledEnd!)}'
: t(data.scheduledStart!),
));
if (data.isSwapped) {
events.add(_DayEvent(
type: _DayEventType.swapNote,
time: data.scheduledStart!,
title: 'Shift received via swap',
));
}
}
final sortedSessions = [...data.sessions]
..sort((a, b) => a.checkInAt.compareTo(b.checkInAt));
for (final session in sortedSessions) {
events.add(_DayEvent(
type: _DayEventType.checkIn,
time: session.checkInAt,
title: 'Checked in',
subtitle: t(session.checkInAt),
));
// Pass slips that started during this session window.
final sessionEnd = session.checkOutAt ?? AppTime.now();
for (final slip in data.passSlips) {
if (slip.slipStart == null) continue;
if (slip.slipStart!.isBefore(session.checkInAt) ||
slip.slipStart!.isAfter(sessionEnd)) {
continue;
}
events.add(_DayEvent(
type: _DayEventType.passSlipOut,
time: slip.slipStart!,
title: 'Pass slip — left',
subtitle: slip.reason,
));
if (slip.slipEnd != null) {
events.add(_DayEvent(
type: _DayEventType.passSlipReturn,
time: slip.slipEnd!,
title: 'Pass slip — returned',
subtitle: '${t(slip.slipStart!)} ${t(slip.slipEnd!)}',
));
} else {
events.add(_DayEvent(
type: _DayEventType.passSlipReturn,
time: slip.slipStart!.add(const Duration(seconds: 1)),
title: 'Pass slip — still out',
));
}
}
if (session.checkOutAt != null) {
events.add(_DayEvent(
type: _DayEventType.checkOut,
time: session.checkOutAt!,
title: 'Checked out',
subtitle: t(session.checkOutAt!),
));
} else {
events.add(_DayEvent(
type: _DayEventType.stillOnDuty,
time: AppTime.now(),
title: 'Currently on duty',
));
}
}
for (final task in data.tasks) {
if (task.startedAt != null) {
events.add(_DayEvent(
type: _DayEventType.taskStarted,
time: task.startedAt!,
title: 'Task started',
subtitle: task.title,
));
} else if (task.completedAt != null) {
events.add(_DayEvent(
type: _DayEventType.taskCompleted,
time: task.completedAt!,
title: 'Task completed',
subtitle: task.title,
));
} else {
events.add(_DayEvent(
type: _DayEventType.taskCreated,
time: task.createdAt,
title: 'Task created',
subtitle: task.title,
));
}
}
for (final ticket in data.tickets) {
if (ticket.promotedAt != null) {
events.add(_DayEvent(
type: _DayEventType.ticketPromoted,
time: ticket.promotedAt!,
title: 'Ticket promoted to task',
subtitle: ticket.subject,
));
} else if (ticket.respondedAt != null) {
events.add(_DayEvent(
type: _DayEventType.ticketResponded,
time: ticket.respondedAt!,
title: 'Ticket responded',
subtitle: ticket.subject,
));
} else {
events.add(_DayEvent(
type: _DayEventType.ticketCreated,
time: ticket.createdAt,
title: 'Ticket created',
subtitle: ticket.subject,
));
}
}
for (final isr in data.serviceRequests) {
if (isr.completedAt != null) {
events.add(_DayEvent(
type: _DayEventType.isrCompleted,
time: isr.completedAt!,
title: 'IT request completed',
subtitle: isr.requestNumber,
));
} else {
final isCreatedDay = _sameDay(isr.createdAt, data.date);
events.add(_DayEvent(
type: isCreatedDay ? _DayEventType.isrCreated : _DayEventType.isrUpdated,
time: isCreatedDay ? isr.createdAt : isr.updatedAt,
title: isCreatedDay ? 'IT request created' : 'IT request updated',
subtitle: isr.requestNumber,
));
}
}
events.sort((a, b) => a.time.compareTo(b.time));
return events;
}
bool _sameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
// ─────────────────────────────────────────────────────────────────────────────
// Main sheet widget
// ─────────────────────────────────────────────────────────────────────────────
class LogbookDayActivitySheet extends ConsumerWidget {
const LogbookDayActivitySheet({super.key, required this.data});
final LogbookDayActivityData data;
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final events = _buildEvents(data);
final dateStr = _formatDate(data.date);
final initials = _initials(data.name);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── Drag handle (mobile) ──────────────────────────────
Center(
child: Container(
margin: const EdgeInsets.only(top: 12, bottom: 4),
width: 36,
height: 4,
decoration: BoxDecoration(
color: colors.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
),
// ── Header ───────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 16, 0),
child: Row(
children: [
CircleAvatar(
radius: 22,
backgroundColor: colors.primaryContainer,
child: Text(
initials,
style: textTheme.titleSmall?.copyWith(
color: colors.onPrimaryContainer,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(data.name,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
)),
Text(dateStr,
style: textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
)),
],
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// ── Summary chips ─────────────────────────────────────
if (data.tasks.isNotEmpty ||
data.tickets.isNotEmpty ||
data.serviceRequests.isNotEmpty ||
data.passSlips.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 0),
child: Wrap(
spacing: 6,
runSpacing: 4,
children: [
if (data.tasks.isNotEmpty)
_SummaryChip(
label: '${data.tasks.length} task${data.tasks.length == 1 ? '' : 's'}',
icon: Icons.task_alt_rounded,
color: colors.secondaryContainer,
onColor: colors.onSecondaryContainer,
),
if (data.tickets.isNotEmpty)
_SummaryChip(
label: '${data.tickets.length} ticket${data.tickets.length == 1 ? '' : 's'}',
icon: Icons.confirmation_num_outlined,
color: colors.tertiaryContainer,
onColor: colors.onTertiaryContainer,
),
if (data.serviceRequests.isNotEmpty)
_SummaryChip(
label: '${data.serviceRequests.length} ISR${data.serviceRequests.length == 1 ? '' : 's'}',
icon: Icons.computer_outlined,
color: colors.surfaceContainerHighest,
onColor: colors.onSurface,
),
if (data.passSlips.isNotEmpty)
_SummaryChip(
label: '${data.passSlips.length} pass slip${data.passSlips.length == 1 ? '' : 's'}',
icon: Icons.badge_outlined,
color: colors.primaryContainer,
onColor: colors.onPrimaryContainer,
),
],
),
),
const SizedBox(height: 12),
const Divider(height: 1),
// ── Timeline ─────────────────────────────────────────
Flexible(
child: events.isEmpty
? Padding(
padding: const EdgeInsets.all(32),
child: Center(
child: Text(
'No activity recorded for this day.',
style: textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
),
)
: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
itemCount: events.length,
itemBuilder: (context, index) {
return M3FadeSlideIn(
delay: Duration(
milliseconds: index.clamp(0, 14) * 45),
child: _TimelineTile(
event: events[index],
isFirst: index == 0,
isLast: index == events.length - 1,
),
);
},
),
),
],
);
}
static String _formatDate(DateTime d) {
const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
final wd = weekdays[d.weekday - 1];
final mo = months[d.month - 1];
return '$wd, $mo ${d.day}, ${d.year}';
}
static String _initials(String name) {
final parts = name.trim().split(RegExp(r'\s+'));
if (parts.isEmpty) return '?';
if (parts.length == 1) return parts[0][0].toUpperCase();
return '${parts[0][0]}${parts[parts.length - 1][0]}'.toUpperCase();
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Summary chip
// ─────────────────────────────────────────────────────────────────────────────
class _SummaryChip extends StatelessWidget {
const _SummaryChip({
required this.label,
required this.icon,
required this.color,
required this.onColor,
});
final String label;
final IconData icon;
final Color color;
final Color onColor;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: onColor),
const SizedBox(width: 5),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: onColor,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Timeline tile
// ─────────────────────────────────────────────────────────────────────────────
class _TimelineTile extends StatelessWidget {
const _TimelineTile({
required this.event,
required this.isFirst,
required this.isLast,
});
final _DayEvent event;
final bool isFirst;
final bool isLast;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final dotColor = _dotColor(colors);
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── Dot + connector line ──────────────────────────
SizedBox(
width: 28,
child: Column(
children: [
// Top connector (hidden for first item)
SizedBox(
height: isFirst ? 6 : 0,
width: 2,
child: isFirst
? null
: ColoredBox(color: colors.outlineVariant),
),
// Dot
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: dotColor,
border: Border.all(
color: colors.surface,
width: 1.5,
),
),
),
// Bottom connector (hidden for last item)
if (!isLast)
Expanded(
child: Center(
child: SizedBox(
width: 2,
child: ColoredBox(color: colors.outlineVariant),
),
),
),
],
),
),
const SizedBox(width: 10),
// ── Content ───────────────────────────────────────
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
event.title,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
if (event.subtitle != null) ...[
const SizedBox(height: 2),
Text(
event.subtitle!,
style: textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
const SizedBox(width: 8),
Text(
AppTime.formatTime(event.time),
style: textTheme.labelSmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
),
),
],
),
);
}
Color _dotColor(ColorScheme colors) {
switch (event.type) {
case _DayEventType.shiftScheduled:
case _DayEventType.swapNote:
return colors.outline;
case _DayEventType.checkIn:
case _DayEventType.checkOut:
return colors.primary;
case _DayEventType.passSlipOut:
case _DayEventType.passSlipReturn:
return colors.tertiary;
case _DayEventType.stillOnDuty:
return colors.primaryContainer;
case _DayEventType.taskCreated:
case _DayEventType.taskStarted:
case _DayEventType.taskCompleted:
return colors.secondary;
case _DayEventType.ticketCreated:
case _DayEventType.ticketResponded:
case _DayEventType.ticketPromoted:
return colors.secondaryContainer;
case _DayEventType.isrCreated:
case _DayEventType.isrUpdated:
case _DayEventType.isrCompleted:
return colors.tertiaryContainer;
}
}
}
File diff suppressed because it is too large Load Diff