diff --git a/lib/providers/attendance_provider.dart b/lib/providers/attendance_provider.dart index d1524ac8..c062658b 100644 --- a/lib/providers/attendance_provider.dart +++ b/lib/providers/attendance_provider.dart @@ -181,6 +181,8 @@ final attendanceLogsProvider = StreamProvider>((ref) { 'p_duty_id': params['p_duty_id'], 'p_lat': params['p_lat'], 'p_lng': params['p_lng'], + 'p_accuracy': params['p_accuracy'], + 'p_is_mocked': params['p_is_mocked'], }, ) as String?; syncedLocalIds.add(localId); @@ -273,6 +275,8 @@ final attendanceLogsProvider = StreamProvider>((ref) { 'p_lat': fields['check_out_lat'], 'p_lng': fields['check_out_lng'], 'p_justification': fields['check_out_justification'], + 'p_accuracy': fields['check_out_accuracy'], + 'p_is_mocked': fields['check_out_is_mocked'], }, ); syncedIds.add(attendanceId); @@ -390,12 +394,20 @@ class AttendanceController { required String dutyScheduleId, required double lat, required double lng, + double accuracy = 0, + bool isMocked = false, String shiftType = 'normal', }) async { try { final data = await _client.rpc( 'attendance_check_in', - params: {'p_duty_id': dutyScheduleId, 'p_lat': lat, 'p_lng': lng}, + params: { + 'p_duty_id': dutyScheduleId, + 'p_lat': lat, + 'p_lng': lng, + 'p_accuracy': accuracy, + 'p_is_mocked': isMocked, + }, ); return data as String?; } catch (e) { @@ -418,7 +430,14 @@ class AttendanceController { _queuePendingCheckIn( log, - {'p_duty_id': dutyScheduleId, 'p_lat': lat, 'p_lng': lng, 'localId': id}, + { + 'p_duty_id': dutyScheduleId, + 'p_lat': lat, + 'p_lng': lng, + 'p_accuracy': accuracy, + 'p_is_mocked': isMocked, + 'localId': id, + }, ); debugPrint('[AttendanceController] checkIn queued offline: $id'); return id; @@ -431,6 +450,8 @@ class AttendanceController { required double lat, required double lng, String? justification, + double accuracy = 0, + bool isMocked = false, }) async { try { await _client.rpc( @@ -440,6 +461,8 @@ class AttendanceController { 'p_lat': lat, 'p_lng': lng, 'p_justification': justification, + 'p_accuracy': accuracy, + 'p_is_mocked': isMocked, }, ); } catch (e) { @@ -451,6 +474,8 @@ class AttendanceController { 'check_out_lat': lat, 'check_out_lng': lng, 'check_out_justification': justification, + 'check_out_accuracy': accuracy, + 'check_out_is_mocked': isMocked, }); debugPrint( '[AttendanceController] checkOut queued offline: $attendanceId', @@ -463,10 +488,18 @@ class AttendanceController { required double lat, required double lng, String? justification, + double accuracy = 0, + bool isMocked = false, }) async { final data = await _client.rpc( 'overtime_check_in', - params: {'p_lat': lat, 'p_lng': lng, 'p_justification': justification}, + params: { + 'p_lat': lat, + 'p_lng': lng, + 'p_justification': justification, + 'p_accuracy': accuracy, + 'p_is_mocked': isMocked, + }, ); return data as String?; } diff --git a/lib/providers/chat_provider.dart b/lib/providers/chat_provider.dart index eea2f731..58fd62cc 100644 --- a/lib/providers/chat_provider.dart +++ b/lib/providers/chat_provider.dart @@ -163,6 +163,21 @@ class ChatController { } catch (e) { if (!isOfflineSaveError(e)) rethrow; + final ref = _ref; + if (ref != null) { + final existing = + ref.read(offlinePendingChatMessagesProvider)[threadId] ?? []; + // Idempotency guard: suppress if the same body was queued within the + // last 10 s — prevents duplicate messages from rapid taps while offline. + final cutoff = AppTime.now().subtract(const Duration(seconds: 10)); + if (existing.any((m) => m.body == body && m.createdAt.isAfter(cutoff))) { + debugPrint( + '[ChatController] duplicate offline message suppressed for thread=$threadId', + ); + return; + } + } + final message = ChatMessage( id: id, threadId: threadId, @@ -171,13 +186,13 @@ class ChatController { createdAt: AppTime.now(), ); - final ref = _ref; - if (ref != null) { + final ref2 = _ref; + if (ref2 != null) { final current = Map>.from( - ref.read(offlinePendingChatMessagesProvider), + ref2.read(offlinePendingChatMessagesProvider), ); current[threadId] = [...(current[threadId] ?? []), message]; - ref.read(offlinePendingChatMessagesProvider.notifier).state = + ref2.read(offlinePendingChatMessagesProvider.notifier).state = Map.unmodifiable(current); } mirrorBatchToBrick([message], tag: 'offline_chat'); diff --git a/lib/providers/connectivity_provider.dart b/lib/providers/connectivity_provider.dart index c4e9939a..6d6a846c 100644 --- a/lib/providers/connectivity_provider.dart +++ b/lib/providers/connectivity_provider.dart @@ -64,7 +64,7 @@ class ConnectivityMonitor { } try { final res = await http - .head(Uri.parse('https://tasq.crmc.ph')) + .head(Uri.parse('https://google.com')) .timeout(const Duration(seconds: 5)); return res.statusCode < 500; } catch (_) { diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index bcd6a8f0..fa9b32db 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -328,9 +328,10 @@ final tasksProvider = StreamProvider>((ref) { onOfflineData: () async { if (!AppRepository.isConfigured) return []; try { - return await AppRepository.instance.get( + final all = await AppRepository.instance.get( policy: OfflineFirstGetPolicy.localOnly, ); + return {for (final t in all) t.id: t}.values.toList(); } catch (_) { return []; } @@ -363,6 +364,7 @@ final tasksProvider = StreamProvider>((ref) { '[tasksProvider] reconnected — syncing ${pending.length} pending task(s)', ); } + final failedTaskIds = {}; for (final task in pending) { try { final syncPayload = { @@ -393,18 +395,26 @@ final tasksProvider = StreamProvider>((ref) { } catch (e) { final msg = e.toString(); if (msg.contains('23505') || msg.contains('duplicate key')) { - // Task already in DB (Brick may have synced it first) — safe to - // leave cleanup to the realtime stream listener in tasks_list_screen. + // Task already in DB — treat as success so it's cleared from pending. debugPrint( '[tasksProvider] pending task already in DB id=${task.id}', ); } else { + failedTaskIds.add(task.id); debugPrint( '[tasksProvider] failed to sync pending task id=${task.id}: $e', ); } } } + // Clear successfully synced pending tasks. Don't rely on the + // tasks_list_screen listener alone — it only fires when that screen is + // active, leaving the banner stuck on other screens indefinitely. + if (pending.isNotEmpty) { + final remaining = + pending.where((t) => failedTaskIds.contains(t.id)).toList(); + ref.read(offlinePendingTasksProvider.notifier).state = remaining; + } // ── 2. Sync offline field edits for EXISTING tasks (PATCH) ────────── // pendingUpdates already had pending-task entries removed in step 1. @@ -573,9 +583,10 @@ final tasksProvider = StreamProvider>((ref) { // When offline, seed from Brick local SQLite cache instead of network. if (!ref.read(isOnlineProvider) && AppRepository.isConfigured) { try { - final cached = await AppRepository.instance.get( + final all = await AppRepository.instance.get( policy: OfflineFirstGetPolicy.localOnly, ); + final cached = {for (final t in all) t.id: t}.values.toList(); debugPrint('[tasksProvider] offline seed: ${cached.length} tasks from local cache'); final payload = _buildTaskPayload( tasks: cached, @@ -2788,3 +2799,52 @@ class TaskAssignmentsController { ref.read(offlinePendingAssignmentsProvider.notifier).state = current; } } + +// ─── Work Log helpers ──────────────────────────────────────────────────────── + +/// All task_assignments rows where user_id = [userId]. +/// Used by the Work Log tab to identify which tasks the user is the assignee of. +final taskAssignmentsForUserProvider = + FutureProvider.autoDispose.family, String>( + (ref, userId) async { + try { + final data = await Supabase.instance.client + .from('task_assignments') + .select() + .eq('user_id', userId); + return data.map(TaskAssignment.fromMap).toList(); + } catch (_) { + return []; + } + }, +); + +/// All task_activity_logs rows for the given task IDs — single batch query. +/// Key is a sorted, comma-joined string of task IDs so Riverpod caches the +/// provider correctly across rebuilds (List equality is reference-based in Dart). +/// Returns logs in DESCENDING order (newest first). +final taskActivityLogsForTasksProvider = + FutureProvider.autoDispose.family, String>( + (ref, taskIdsKey) async { + if (taskIdsKey.isEmpty) return []; + final taskIds = taskIdsKey.split(','); + try { + final data = await Supabase.instance.client + .from('task_activity_logs') + .select() + .inFilter('task_id', taskIds) + .order('created_at', ascending: false); + return data.map(TaskActivityLog.fromMap).toList(); + } catch (_) { + try { + final all = await cachedListFromBrick(); + final idSet = taskIds.toSet(); + final filtered = all.where((l) => idSet.contains(l.taskId)).toList(); + filtered.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return filtered; + } catch (_) { + return []; + } + } + }, +); diff --git a/lib/screens/attendance/attendance_screen.dart b/lib/screens/attendance/attendance_screen.dart index 7f7e8f64..3cab9e8a 100644 --- a/lib/screens/attendance/attendance_screen.dart +++ b/lib/screens/attendance/attendance_screen.dart @@ -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 @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 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 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 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 _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 _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 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 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 = >{}; + 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( + 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( diff --git a/lib/screens/attendance/logbook_day_activity.dart b/lib/screens/attendance/logbook_day_activity.dart new file mode 100644 index 00000000..3bdf59b1 --- /dev/null +++ b/lib/screens/attendance/logbook_day_activity.dart @@ -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 sessions; + final List passSlips; + final List tasks; + final List tickets; + final List 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; + } + } +} diff --git a/lib/screens/attendance/work_log_tab.dart b/lib/screens/attendance/work_log_tab.dart new file mode 100644 index 00000000..c68bc8d4 --- /dev/null +++ b/lib/screens/attendance/work_log_tab.dart @@ -0,0 +1,1326 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../models/attendance_log.dart'; +import '../../models/it_service_request.dart'; +import '../../models/it_service_request_assignment.dart'; +import '../../models/pass_slip.dart'; +import '../../models/task.dart'; +import '../../models/task_activity_log.dart'; +import '../../models/task_assignment.dart'; +import '../../models/ticket.dart'; +import '../../providers/attendance_provider.dart'; +import '../../providers/it_service_request_provider.dart'; +import '../../providers/pass_slip_provider.dart'; +import '../../providers/profile_provider.dart'; +import '../../providers/tasks_provider.dart'; +import '../../providers/tickets_provider.dart'; +import '../../theme/m3_motion.dart'; +import '../../utils/app_time.dart'; + +// ─── Domain types ──────────────────────────────────────────────────────────── + +enum _WorkItemType { attendance, task, ticket, isr, passSlip, vacant } + +class _WorkItem { + const _WorkItem({ + required this.type, + required this.title, + required this.start, + this.subtitle, + this.end, + this.referenceNumber, + this.isCreatorEvent = false, + }); + + final _WorkItemType type; + final String title; + final String? subtitle; + final String? referenceNumber; + final DateTime start; + final DateTime? end; + // True for state-change markers (zero work time, excluded from summary totals). + final bool isCreatorEvent; + + Duration get duration { + if (isCreatorEvent) return Duration.zero; + // Any non-marker item without an end time counts elapsed time to now + // (attendance clocked in, task segment in progress, ISR not yet closed). + if (end == null) return DateTime.now().difference(start).abs(); + return end!.difference(start).abs(); + } + + bool get isVacant => type == _WorkItemType.vacant; +} + +class _WorkSummary { + const _WorkSummary({ + required this.totalAttendance, + required this.totalTasks, + required this.totalTickets, + required this.totalIsrs, + required this.totalPassSlip, + required this.totalVacant, + }); + + final Duration totalAttendance; + final Duration totalTasks; + final Duration totalTickets; + final Duration totalIsrs; + final Duration totalPassSlip; + final Duration totalVacant; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +String _fmtDur(Duration d) { + if (d.inMinutes < 1) return '< 1m'; + final h = d.inHours; + final m = d.inMinutes % 60; + return h > 0 ? '${h}h ${m}m' : '${m}m'; +} + +bool _onDay(DateTime? dt, DateTime dayStart, DateTime dayEnd) => + dt != null && !dt.isBefore(dayStart) && dt.isBefore(dayEnd); + +// Generates per-event work items for one task assignment. +// Working segments (In progress / Resumed) get non-zero duration; +// state-change markers (Paused, Completed, Cancelled) are zero-duration. +void _addTaskSegments({ + required List<_WorkItem> raw, + required Task task, + required List taskLogs, // must be ascending + required DateTime dayStart, + required DateTime dayEnd, +}) { + DateTime? segStart; + String segLabel = 'In progress'; + + for (final event in taskLogs) { + if (event.actionType == 'started') { + segStart = event.createdAt; + segLabel = 'In progress'; + } else if (event.actionType == 'resumed') { + segStart = event.createdAt; + segLabel = 'Resumed'; + } else if (event.actionType == 'paused') { + if (segStart != null && _onDay(segStart, dayStart, dayEnd)) { + raw.add(_WorkItem( + type: _WorkItemType.task, + title: task.title, + subtitle: segLabel, + referenceNumber: task.taskNumber, + start: segStart, + end: event.createdAt, + )); + } + segStart = null; + if (_onDay(event.createdAt, dayStart, dayEnd)) { + raw.add(_WorkItem( + type: _WorkItemType.task, + title: task.title, + subtitle: 'Paused', + referenceNumber: task.taskNumber, + start: event.createdAt, + end: event.createdAt, + isCreatorEvent: true, + )); + } + } + } + + // Open segment (currently in progress or completed without a trailing pause) + if (segStart != null && _onDay(segStart, dayStart, dayEnd)) { + raw.add(_WorkItem( + type: _WorkItemType.task, + title: task.title, + subtitle: segLabel, + referenceNumber: task.taskNumber, + start: segStart, + end: task.completedAt ?? task.cancelledAt, // null = still in progress + )); + } + + // Completion / cancellation markers + if (task.completedAt != null && _onDay(task.completedAt, dayStart, dayEnd)) { + raw.add(_WorkItem( + type: _WorkItemType.task, + title: task.title, + subtitle: 'Completed', + referenceNumber: task.taskNumber, + start: task.completedAt!, + end: task.completedAt, + isCreatorEvent: true, + )); + } else if (task.cancelledAt != null && + _onDay(task.cancelledAt, dayStart, dayEnd)) { + raw.add(_WorkItem( + type: _WorkItemType.task, + title: task.title, + subtitle: 'Cancelled', + referenceNumber: task.taskNumber, + start: task.cancelledAt!, + end: task.cancelledAt, + isCreatorEvent: true, + )); + } +} + +String _isrAssigneeSubtitle(ItServiceRequest r) { + if (r.completedAt != null) return 'Completed'; + if (r.dateTimeChecked != null) return 'Checked · in progress'; + if (r.dateTimeReceived != null) return 'Received'; + return 'Assigned · ${r.status}'; +} + +List<_WorkItem> _buildTimeline({ + required List sessions, + required List passSlips, + required List tasks, + required List tickets, + required List isrs, + required DateTime dayStart, + required DateTime dayEnd, + required String? effectiveUserId, + required List taskAssignments, + required List isrAssignments, + required List allActivityLogs, +}) { + final raw = <_WorkItem>[]; + + // ── Attendance sessions ────────────────────────────────────────────────── + for (final s in sessions) { + if (!_onDay(s.checkInAt, dayStart, dayEnd)) continue; + raw.add(_WorkItem( + type: _WorkItemType.attendance, + title: 'Check-in Session', + subtitle: s.justification, + start: s.checkInAt, + end: s.checkOutAt, + )); + } + + // ── Pass slips ─────────────────────────────────────────────────────────── + for (final slip in passSlips) { + if (slip.slipStart == null) continue; + if (!_onDay(slip.slipStart, dayStart, dayEnd)) continue; + raw.add(_WorkItem( + type: _WorkItemType.passSlip, + title: 'Pass Slip', + subtitle: slip.reason, + start: slip.slipStart!, + end: slip.slipEnd, + )); + } + + // ── Tasks ──────────────────────────────────────────────────────────────── + // Each task generates multiple timeline items — one per lifecycle event. + // Working segments (In progress / Resumed) carry non-zero duration; + // state-change markers (Assigned, Paused, Completed) are zero-duration. + for (final t in tasks) { + // Creator marker + if (t.creatorId == effectiveUserId && _onDay(t.createdAt, dayStart, dayEnd)) { + raw.add(_WorkItem( + type: _WorkItemType.task, + title: t.title, + subtitle: 'Created', + referenceNumber: t.taskNumber, + start: t.createdAt, + end: t.createdAt, + isCreatorEvent: true, + )); + } + + final userAssignments = + taskAssignments.where((a) => a.taskId == t.id).toList(); + if (userAssignments.isEmpty) continue; + + // Assigned markers (one per assignment record) + for (final a in userAssignments) { + if (_onDay(a.createdAt, dayStart, dayEnd)) { + raw.add(_WorkItem( + type: _WorkItemType.task, + title: t.title, + subtitle: 'Assigned', + referenceNumber: t.taskNumber, + start: a.createdAt, + end: a.createdAt, + isCreatorEvent: true, + )); + } + } + + // Working segments from activity logs — processed once per task + final taskLogs = allActivityLogs + .where((l) => l.taskId == t.id) + .toList() + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + _addTaskSegments( + raw: raw, + task: t, + taskLogs: taskLogs, + dayStart: dayStart, + dayEnd: dayEnd, + ); + } + + // ── Tickets ────────────────────────────────────────────────────────────── + // No assignment model for tickets; keep creator-based display. + // Duration is capped at closedAt — open tickets return Duration.zero (see getter). + for (final t in tickets) { + if (!_onDay(t.createdAt, dayStart, dayEnd)) continue; + raw.add(_WorkItem( + type: _WorkItemType.ticket, + title: t.subject, + start: t.createdAt, + end: t.closedAt ?? t.promotedAt, + )); + } + + // ── IT Service Requests ────────────────────────────────────────────────── + // Creator/requester: zero-duration marker on creation day. + // Assignee (IT staff): work item from assignment time to completion. + for (final r in isrs) { + // Creator/requester event + if ((r.creatorId == effectiveUserId || + r.requestedByUserId == effectiveUserId) && + _onDay(r.createdAt, dayStart, dayEnd)) { + raw.add(_WorkItem( + type: _WorkItemType.isr, + title: 'IT Service Request', + subtitle: 'Requested', + referenceNumber: r.requestNumber, + start: r.createdAt, + end: r.createdAt, + isCreatorEvent: true, + )); + } + + // Assignee work items + for (final a in isrAssignments.where((a) => a.requestId == r.id)) { + final assignedOnDay = _onDay(a.createdAt, dayStart, dayEnd); + final completedOnDay = _onDay(r.completedAt, dayStart, dayEnd); + if (!assignedOnDay && !completedOnDay) continue; + + raw.add(_WorkItem( + type: _WorkItemType.isr, + title: 'IT Service Request', + subtitle: _isrAssigneeSubtitle(r), + referenceNumber: r.requestNumber, + start: a.createdAt, + end: r.completedAt, + )); + } + } + + raw.sort((a, b) => a.start.compareTo(b.start)); + + final result = <_WorkItem>[]; + for (int i = 0; i < raw.length; i++) { + result.add(raw[i]); + if (i + 1 < raw.length && raw[i].end != null) { + final gap = raw[i + 1].start.difference(raw[i].end!); + if (gap.inMinutes > 5) { + result.add(_WorkItem( + type: _WorkItemType.vacant, + title: 'Vacant', + start: raw[i].end!, + end: raw[i + 1].start, + )); + } + } + } + return result; +} + +_WorkSummary _computeSummary(List<_WorkItem> items) { + Duration att = Duration.zero; + Duration tasks = Duration.zero; + Duration tickets = Duration.zero; + Duration isrs = Duration.zero; + Duration slips = Duration.zero; + Duration vacant = Duration.zero; + + for (final item in items) { + if (item.isCreatorEvent) continue; + final d = item.duration; + switch (item.type) { + case _WorkItemType.attendance: + att += d; + case _WorkItemType.task: + tasks += d; + case _WorkItemType.ticket: + tickets += d; + case _WorkItemType.isr: + isrs += d; + case _WorkItemType.passSlip: + slips += d; + case _WorkItemType.vacant: + vacant += d; + } + } + + return _WorkSummary( + totalAttendance: att, + totalTasks: tasks, + totalTickets: tickets, + totalIsrs: isrs, + totalPassSlip: slips, + totalVacant: vacant, + ); +} + +// ─── Dashed border painter ─────────────────────────────────────────────────── + +class _DashedBorder extends CustomPainter { + const _DashedBorder({required this.color}); + + final Color color; + static const double _radius = 8.0; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke; + + final path = Path() + ..addRRect(RRect.fromRectAndRadius( + Rect.fromLTWH(0.5, 0.5, size.width - 1, size.height - 1), + Radius.circular(_radius), + )); + + const dashLen = 5.0; + const gapLen = 4.0; + for (final metric in path.computeMetrics()) { + double dist = 0; + while (dist < metric.length) { + canvas.drawPath( + metric.extractPath(dist, (dist + dashLen).clamp(0, metric.length)), + paint, + ); + dist += dashLen + gapLen; + } + } + } + + @override + bool shouldRepaint(_DashedBorder old) => old.color != color; +} + +// ─── Tab widget ─────────────────────────────────────────────────────────────── + +class WorkLogTab extends ConsumerStatefulWidget { + const WorkLogTab({super.key}); + + @override + ConsumerState createState() => _WorkLogTabState(); +} + +class _WorkLogTabState extends ConsumerState { + DateTime _selectedDate = DateTime.now(); + String? _selectedUserId; + final _exportKey = GlobalKey(); + + void _prevDay() => + setState(() => _selectedDate = _selectedDate.subtract(const Duration(days: 1))); + + void _nextDay() => + setState(() => _selectedDate = _selectedDate.add(const Duration(days: 1))); + + DateTime get _dayStart => + DateTime(_selectedDate.year, _selectedDate.month, _selectedDate.day); + + DateTime get _dayEnd => _dayStart.add(const Duration(days: 1)); + + String get _dateLabel => + '${_isToday(_selectedDate) ? 'Today' : _weekday(_selectedDate)}, ${AppTime.formatDate(_selectedDate)}'; + + bool _isToday(DateTime d) { + final now = DateTime.now(); + return d.year == now.year && d.month == now.month && d.day == now.day; + } + + String _weekday(DateTime d) { + const names = [ + 'Monday', 'Tuesday', 'Wednesday', 'Thursday', + 'Friday', 'Saturday', 'Sunday', + ]; + return names[d.weekday - 1]; + } + + String _resolvePersonName(dynamic currentProfile, List profiles) { + if (_selectedUserId == null) { + return currentProfile?.fullName as String? ?? ''; + } + for (final p in profiles) { + if (p.id == _selectedUserId) return p.fullName as String? ?? ''; + } + return currentProfile?.fullName as String? ?? ''; + } + + Future _captureAndShare( + BuildContext context, + List<_WorkItem> items, + _WorkSummary summary, + String personName, + ) async { + final screenWidth = MediaQuery.of(context).size.width; + final theme = Theme.of(context); + final overlay = Overlay.of(context); + late OverlayEntry entry; + + entry = OverlayEntry( + builder: (ctx) => Positioned( + left: screenWidth * 2, + top: 0, + width: screenWidth, + child: RepaintBoundary( + key: _exportKey, + child: Material( + color: theme.colorScheme.surface, + child: _WorkLogPrintView( + dateLabel: _dateLabel, + personName: personName, + items: items, + summary: summary, + ), + ), + ), + ), + ); + + overlay.insert(entry); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Preparing image…'), + duration: Duration(seconds: 1), + behavior: SnackBarBehavior.floating, + ), + ); + } + + // Two frames: first to insert the overlay, second to ensure it's painted. + WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + try { + final boundary = _exportKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + if (boundary == null) return; + + final img = await boundary.toImage(pixelRatio: 2.5); + final byteData = + await img.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) return; + + final pngBytes = byteData.buffer.asUint8List(); + final safeName = AppTime.formatDate(_selectedDate) + .replaceAll(' ', '_') + .replaceAll(',', ''); + + if (context.mounted) { + await Share.shareXFiles( + [ + XFile.fromData( + pngBytes, + name: 'worklog_$safeName.png', + mimeType: 'image/png', + ) + ], + subject: + 'Work Log – $personName – ${AppTime.formatDate(_selectedDate)}', + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Export failed: $e')), + ); + } + } finally { + entry.remove(); + } + }); + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + + final currentProfile = ref.watch(currentProfileProvider).valueOrNull; + final isAdmin = currentProfile?.role == 'admin' || + currentProfile?.role == 'programmer' || + currentProfile?.role == 'dispatcher'; + + final effectiveUserId = _selectedUserId ?? currentProfile?.id; + + final logsAsync = ref.watch(attendanceLogsProvider); + final tasksAsync = ref.watch(tasksProvider); + final ticketsAsync = ref.watch(ticketsProvider); + final isrsAsync = ref.watch(itServiceRequestsProvider); + final passSlipsAsync = ref.watch(passSlipsProvider); + + final sessions = (logsAsync.valueOrNull ?? []) + .where((l) => + l.userId == effectiveUserId && + _onDay(l.checkInAt, _dayStart, _dayEnd)) + .toList(); + + final passSlips = (passSlipsAsync.valueOrNull ?? []) + .where((s) => s.userId == effectiveUserId) + .toList(); + + // ── Assignment-aware providers ────────────────────────────────────────── + final taskAssignmentsForUser = effectiveUserId == null + ? const [] + : ref + .watch(taskAssignmentsForUserProvider(effectiveUserId)) + .valueOrNull ?? + []; + + final assignedTaskIds = + taskAssignmentsForUser.map((a) => a.taskId).toList()..sort(); + final taskIdsKey = assignedTaskIds.join(','); + final allActivityLogs = + ref.watch(taskActivityLogsForTasksProvider(taskIdsKey)).valueOrNull ?? + []; + + final allIsrAssignments = + ref.watch(itServiceRequestAssignmentsProvider).valueOrNull ?? []; + final isrAssignmentsForUser = + allIsrAssignments.where((a) => a.userId == effectiveUserId).toList(); + + // Include creator-on-day tasks AND any task this user is assigned to + // (day filtering for assignee items is done inside _buildTimeline). + final assignedTaskIdSet = + taskAssignmentsForUser.map((a) => a.taskId).toSet(); + final tasks = (tasksAsync.valueOrNull ?? []) + .where((t) => + (t.creatorId == effectiveUserId && + (_onDay(t.startedAt, _dayStart, _dayEnd) || + _onDay(t.completedAt, _dayStart, _dayEnd) || + _onDay(t.createdAt, _dayStart, _dayEnd))) || + assignedTaskIdSet.contains(t.id)) + .toList(); + + final tickets = (ticketsAsync.valueOrNull ?? []) + .where((t) => + t.creatorId == effectiveUserId && + _onDay(t.createdAt, _dayStart, _dayEnd)) + .toList(); + + final isrAssignedIdSet = + isrAssignmentsForUser.map((a) => a.requestId).toSet(); + final isrs = (isrsAsync.valueOrNull ?? []) + .where((r) => + ((r.creatorId == effectiveUserId || + r.requestedByUserId == effectiveUserId) && + _onDay(r.createdAt, _dayStart, _dayEnd)) || + isrAssignedIdSet.contains(r.id)) + .toList(); + + final isLoading = + logsAsync.isLoading && logsAsync.valueOrNull == null; + + final items = _buildTimeline( + sessions: sessions, + passSlips: passSlips, + tasks: tasks, + tickets: tickets, + isrs: isrs, + dayStart: _dayStart, + dayEnd: _dayEnd, + effectiveUserId: effectiveUserId, + taskAssignments: taskAssignmentsForUser, + isrAssignments: isrAssignmentsForUser, + allActivityLogs: allActivityLogs, + ); + + final summary = _computeSummary(items); + + final profiles = isAdmin + ? ((ref.watch(profilesProvider).valueOrNull ?? []) + .where((p) => const { + 'admin', + 'dispatcher', + 'programmer', + 'it_staff', + }.contains(p.role)) + .toList() + ..sort((a, b) => a.fullName.compareTo(b.fullName))) + : []; + + final personName = _resolvePersonName(currentProfile, profiles); + + return Column( + children: [ + // ── Date navigator ──────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Card( + elevation: 0, + color: colors.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Row( + children: [ + IconButton.filledTonal( + icon: const Icon(Icons.chevron_left), + onPressed: _prevDay, + tooltip: 'Previous day', + ), + Expanded( + child: GestureDetector( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: _selectedDate, + firstDate: DateTime(2020), + lastDate: + DateTime.now().add(const Duration(days: 1)), + ); + if (picked != null) { + setState(() => _selectedDate = picked); + } + }, + child: Column( + children: [ + Text( + AppTime.formatDate(_selectedDate), + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + Text( + _isToday(_selectedDate) + ? 'Today' + : _weekday(_selectedDate), + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + IconButton( + icon: const Icon(Icons.share_rounded), + tooltip: 'Share as image', + onPressed: items.isEmpty + ? null + : () => _captureAndShare( + context, items, summary, personName), + ), + IconButton.filledTonal( + icon: const Icon(Icons.chevron_right), + onPressed: + _dayEnd.isAfter(DateTime.now()) ? null : _nextDay, + tooltip: 'Next day', + ), + ], + ), + ), + ), + ), + + // ── Personnel selector (admin only) ─────────────────────────────── + if (isAdmin && profiles.isNotEmpty) ...[ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Card( + elevation: 0, + color: colors.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + children: [ + Icon(Icons.person_outline, + size: 18, color: colors.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: DropdownButton( + value: _selectedUserId, + isExpanded: true, + underline: const SizedBox.shrink(), + icon: Icon(Icons.expand_more, + size: 20, color: colors.onSurfaceVariant), + hint: Text( + currentProfile?.fullName ?? 'Select personnel', + style: theme.textTheme.bodyMedium, + ), + items: [ + DropdownMenuItem( + value: null, + child: + Text(currentProfile?.fullName ?? 'Me'), + ), + ...profiles.map((p) => DropdownMenuItem( + value: p.id as String, + child: Text(p.fullName as String), + )), + ], + onChanged: (v) => + setState(() => _selectedUserId = v), + ), + ), + ], + ), + ), + ), + ), + ], + + const SizedBox(height: 4), + + if (isLoading) const LinearProgressIndicator(minHeight: 2), + + // ── Timeline + summary ──────────────────────────────────────────── + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: items.isEmpty && !isLoading + ? _EmptyDay(key: ValueKey(_selectedDate)) + : ListView.builder( + key: ValueKey(_selectedDate), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + itemCount: items.length + 1, + itemBuilder: (ctx, i) { + if (i == items.length) { + return M3FadeSlideIn( + delay: Duration( + milliseconds: + (items.length.clamp(0, 15) * 45) + 100), + child: _SummaryCard(summary: summary), + ); + } + return M3FadeSlideIn( + delay: Duration( + milliseconds: i.clamp(0, 15) * 45), + child: Opacity( + opacity: items[i].isVacant ? 0.7 : 1.0, + child: _WorkItemTile( + item: items[i], + isFirst: i == 0, + isLast: i == items.length - 1, + ), + ), + ); + }, + ), + ), + ), + ], + ); + } +} + +// ─── Reference number badge ─────────────────────────────────────────────────── + +class _RefBadge extends StatelessWidget { + const _RefBadge({ + required this.number, + required this.bg, + required this.fg, + }); + + final String number; + final Color bg; + final Color fg; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + number, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: fg, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + ); + } +} + +// ─── Timeline tile ──────────────────────────────────────────────────────────── + +class _WorkItemTile extends StatelessWidget { + const _WorkItemTile({ + required this.item, + required this.isFirst, + required this.isLast, + }); + + final _WorkItem item; + 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); + + // Markers show a single point in time; working segments show a range. + final timeLabel = item.isCreatorEvent + ? AppTime.formatTime(item.start) + : item.end != null + ? '${AppTime.formatTime(item.start)} – ${AppTime.formatTime(item.end!)}' + : '${AppTime.formatTime(item.start)} – ongoing'; + + final contentRow = Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CircleAvatar( + radius: 14, + backgroundColor: _avatarBg(colors), + child: Icon(_icon, size: 14, color: _avatarFg(colors)), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (item.referenceNumber?.isNotEmpty == true) + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: _RefBadge( + number: item.referenceNumber!, + bg: _avatarBg(colors), + fg: _avatarFg(colors), + ), + ), + Text( + item.title, + style: textTheme.bodyMedium?.copyWith( + fontStyle: item.isVacant + ? FontStyle.italic + : FontStyle.normal, + color: item.isVacant + ? colors.onSurfaceVariant + : null, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (item.subtitle?.isNotEmpty == true) + Text( + item.subtitle!, + style: textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + timeLabel, + style: textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + if (!item.isCreatorEvent) ...[ + const SizedBox(width: 8), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: _avatarBg(colors), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _fmtDur(item.duration), + style: textTheme.labelSmall?.copyWith( + color: _avatarFg(colors), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + ); + + final contentContainer = item.isVacant + ? CustomPaint( + painter: _DashedBorder( + color: colors.outline.withValues(alpha: 0.35), + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: colors.surfaceContainerHighest + .withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(8), + ), + child: contentRow, + ), + ) + : contentRow; + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── Dot + connector line ───────────────────────────────────── + SizedBox( + width: 32, + child: Column( + children: [ + // Fixed 16dp offset so the dot aligns with the avatar center. + const SizedBox(height: 16), + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: dotColor, + ), + ), + if (!isLast) + Expanded( + child: Container( + width: 2, + color: item.isVacant + ? colors.outline.withValues(alpha: 0.25) + : colors.outlineVariant, + ), + ), + ], + ), + ), + + // ── Content ─────────────────────────────────────────────────── + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 8, bottom: 8), + child: contentContainer, + ), + ), + ], + ), + ); + } + + Color _dotColor(ColorScheme c) { + if (item.isCreatorEvent && item.subtitle == 'Paused') return c.outline; + if (item.isCreatorEvent) return _avatarBg(c); + return switch (item.type) { + _WorkItemType.attendance => c.primary, + _WorkItemType.task => c.secondary, + _WorkItemType.ticket => c.secondaryContainer, + _WorkItemType.isr => c.tertiaryContainer, + _WorkItemType.passSlip => c.tertiary, + _WorkItemType.vacant => c.outline, + }; + } + + Color _avatarBg(ColorScheme c) => switch (item.type) { + _WorkItemType.attendance => c.primaryContainer, + _WorkItemType.task => c.secondaryContainer, + _WorkItemType.ticket => c.secondaryContainer, + _WorkItemType.isr => c.tertiaryContainer, + _WorkItemType.passSlip => c.tertiaryContainer, + _WorkItemType.vacant => c.surfaceContainerHighest, + }; + + Color _avatarFg(ColorScheme c) => switch (item.type) { + _WorkItemType.attendance => c.onPrimaryContainer, + _WorkItemType.task => c.onSecondaryContainer, + _WorkItemType.ticket => c.onSecondaryContainer, + _WorkItemType.isr => c.onTertiaryContainer, + _WorkItemType.passSlip => c.onTertiaryContainer, + _WorkItemType.vacant => c.onSurfaceVariant, + }; + + IconData get _icon { + if (item.isCreatorEvent) { + return switch (item.subtitle) { + 'Assigned' => Icons.person_add_alt_1_rounded, + 'Paused' => Icons.pause_circle_outline_rounded, + 'Completed' => Icons.check_circle_rounded, + 'Cancelled' => Icons.cancel_rounded, + _ => Icons.add_task_rounded, // Created, Requested, etc. + }; + } + return switch (item.type) { + _WorkItemType.attendance => Icons.login_rounded, + _WorkItemType.task => Icons.task_alt_rounded, + _WorkItemType.ticket => Icons.confirmation_number_outlined, + _WorkItemType.isr => Icons.computer_outlined, + _WorkItemType.passSlip => Icons.badge_outlined, + _WorkItemType.vacant => Icons.hourglass_empty_rounded, + }; + } +} + +// ─── Summary card ───────────────────────────────────────────────────────────── + +class _SummaryCard extends StatelessWidget { + const _SummaryCard({required this.summary}); + + final _WorkSummary summary; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + final totalActive = summary.totalAttendance + + summary.totalTasks + + summary.totalTickets + + summary.totalIsrs + + summary.totalPassSlip; + + final rows = [ + ( + label: 'Attendance', + dur: summary.totalAttendance, + bg: colors.primaryContainer, + fg: colors.onPrimaryContainer, + icon: Icons.login_rounded, + ), + ( + label: 'Tasks', + dur: summary.totalTasks, + bg: colors.secondaryContainer, + fg: colors.onSecondaryContainer, + icon: Icons.task_alt_rounded, + ), + ( + label: 'Tickets', + dur: summary.totalTickets, + bg: colors.secondaryContainer, + fg: colors.onSecondaryContainer, + icon: Icons.confirmation_number_outlined, + ), + ( + label: 'ISRs', + dur: summary.totalIsrs, + bg: colors.tertiaryContainer, + fg: colors.onTertiaryContainer, + icon: Icons.computer_outlined, + ), + ( + label: 'Pass Slips', + dur: summary.totalPassSlip, + bg: colors.tertiaryContainer, + fg: colors.onTertiaryContainer, + icon: Icons.badge_outlined, + ), + ( + label: 'Idle', + dur: summary.totalVacant, + bg: colors.surfaceContainerHighest, + fg: colors.onSurfaceVariant, + icon: Icons.hourglass_empty_rounded, + ), + ].where((r) => r.dur > Duration.zero).toList(); + + return Card.outlined( + margin: const EdgeInsets.only(top: 8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.summarize_rounded, + size: 16, color: colors.primary), + const SizedBox(width: 6), + Text('Day Summary', style: textTheme.titleSmall), + const Spacer(), + if (totalActive > Duration.zero) + Text( + 'Active: ${_fmtDur(totalActive)}', + style: textTheme.labelMedium?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + if (rows.isNotEmpty) ...[ + const SizedBox(height: 8), + const Divider(height: 1), + const SizedBox(height: 4), + ...rows.map( + (r) => Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + children: [ + CircleAvatar( + radius: 12, + backgroundColor: r.bg, + child: Icon(r.icon, size: 13, color: r.fg), + ), + const SizedBox(width: 10), + Text(r.label, style: textTheme.bodyMedium), + const Spacer(), + Text( + _fmtDur(r.dur), + style: textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ], + ], + ), + ), + ); + } +} + +// ─── Empty state ────────────────────────────────────────────────────────────── + +class _EmptyDay extends StatelessWidget { + const _EmptyDay({super.key}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.event_busy_outlined, + size: 48, + color: colors.onSurfaceVariant.withValues(alpha: 0.4)), + const SizedBox(height: 12), + Text( + 'No activity recorded for this day', + style: textTheme.bodyMedium + ?.copyWith(color: colors.onSurfaceVariant), + ), + ], + ), + ); + } +} + +// ─── Print / export view (off-screen rendering target) ─────────────────────── + +class _WorkLogPrintView extends StatelessWidget { + const _WorkLogPrintView({ + required this.dateLabel, + required this.personName, + required this.items, + required this.summary, + }); + + final String dateLabel; + final String personName; + final List<_WorkItem> items; + final _WorkSummary summary; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Container( + color: colors.surface, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Work Log', + style: textTheme.titleLarge + ?.copyWith(color: colors.primary), + ), + const SizedBox(height: 2), + Text(dateLabel, style: textTheme.titleMedium), + const SizedBox(height: 4), + Row( + children: [ + Icon(Icons.person_outline, + size: 14, + color: colors.onSurfaceVariant), + const SizedBox(width: 4), + Text( + personName, + style: textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Divider(color: colors.outlineVariant), + const SizedBox(height: 8), + + // Timeline (no animations in export) + ...items.asMap().entries.map((e) { + final i = e.key; + return Opacity( + opacity: e.value.isVacant ? 0.7 : 1.0, + child: _WorkItemTile( + item: e.value, + isFirst: i == 0, + isLast: i == items.length - 1, + ), + ); + }), + + if (items.isNotEmpty) _SummaryCard(summary: summary), + + const SizedBox(height: 12), + Divider(color: colors.outlineVariant), + const SizedBox(height: 4), + Align( + alignment: Alignment.centerRight, + child: Text( + 'Generated by Tasq', + style: textTheme.labelSmall + ?.copyWith(color: colors.onSurfaceVariant), + ), + ), + ], + ), + ); + } +} diff --git a/lib/utils/device_security.dart b/lib/utils/device_security.dart new file mode 100644 index 00000000..a3d2c76d --- /dev/null +++ b/lib/utils/device_security.dart @@ -0,0 +1,17 @@ +import 'dart:io'; + +import 'package:safe_device/safe_device.dart'; + +/// Returns true if the device appears compromised for attendance purposes: +/// rooted (Android) / jailbroken (iOS), or developer mode enabled. +/// Returns false on any error so a detection failure never blocks a valid user. +Future isDeviceCompromised() async { + if (!Platform.isAndroid && !Platform.isIOS) return false; + try { + final isJailBroken = await SafeDevice.isJailBroken; + final isDeveloperMode = await SafeDevice.isDevelopmentModeEnable; + return isJailBroken || isDeveloperMode; + } catch (_) { + return false; + } +} diff --git a/lib/utils/task_duration.dart b/lib/utils/task_duration.dart new file mode 100644 index 00000000..9ee0affc --- /dev/null +++ b/lib/utils/task_duration.dart @@ -0,0 +1,65 @@ +import '../models/task_activity_log.dart'; + +/// Finds the canonical execution start time for a task. +/// +/// [logs] is expected in DESCENDING order (newest first), matching the +/// `order('created_at', ascending: false)` used by Supabase queries. +/// The reversed iteration therefore walks chronologically forward and stops +/// at the earliest 'started' event. +DateTime? resolveTaskExecutionStart( + List logs, + DateTime? fallbackStartedAt, +) { + DateTime? started; + for (final entry in logs.reversed) { + if (entry.actionType == 'started') { + started = entry.createdAt; + break; + } + } + return started ?? fallbackStartedAt; +} + +/// Computes the effective worked duration for a task, subtracting all +/// paused intervals between [fallbackStartedAt] (or the first 'started' +/// log event) and [endAt]. +/// +/// [logs] must be in DESCENDING order (newest first) — the same order +/// returned by `task_activity_logs` queries with `ascending: false`. +Duration computeEffectiveTaskDuration({ + required DateTime? fallbackStartedAt, + required DateTime endAt, + required List logs, +}) { + final start = resolveTaskExecutionStart(logs, fallbackStartedAt); + if (start == null || !endAt.isAfter(start)) return Duration.zero; + + Duration pausedTotal = Duration.zero; + DateTime? pausedSince; + + // logs.reversed → ascending (chronological) order within [start, endAt] + final events = logs.reversed.where((e) { + if (e.createdAt.isBefore(start)) return false; + if (e.createdAt.isAfter(endAt)) return false; + return e.actionType == 'paused' || e.actionType == 'resumed'; + }); + + for (final event in events) { + if (event.actionType == 'paused') { + pausedSince ??= event.createdAt; + } else if (event.actionType == 'resumed' && pausedSince != null) { + if (event.createdAt.isAfter(pausedSince)) { + pausedTotal += event.createdAt.difference(pausedSince); + } + pausedSince = null; + } + } + + // Task is still paused at endAt — count the open pause interval. + if (pausedSince != null && endAt.isAfter(pausedSince)) { + pausedTotal += endAt.difference(pausedSince); + } + + final total = endAt.difference(start) - pausedTotal; + return total.isNegative ? Duration.zero : total; +} diff --git a/lib/widgets/offline_readiness_sheet.dart b/lib/widgets/offline_readiness_sheet.dart index 604c9030..78e2c56a 100644 --- a/lib/widgets/offline_readiness_sheet.dart +++ b/lib/widgets/offline_readiness_sheet.dart @@ -254,11 +254,18 @@ class _StatusChip extends StatelessWidget { } } -class _CacheGrid extends StatelessWidget { +class _CacheGrid extends StatefulWidget { final Map counts; const _CacheGrid({required this.counts}); + @override + State<_CacheGrid> createState() => _CacheGridState(); +} + +class _CacheGridState extends State<_CacheGrid> with SingleTickerProviderStateMixin { + late final AnimationController _controller; + static const _groups = [ ('Tasks', 'tasks', Icons.task_alt_rounded), ('Tickets', 'tickets', Icons.confirmation_number_rounded), @@ -274,6 +281,21 @@ class _CacheGrid extends StatelessWidget { ('Offices', 'offices', Icons.business_rounded), ]; + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + )..forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; @@ -284,22 +306,41 @@ class _CacheGrid extends StatelessWidget { crossAxisCount: 3, mainAxisSpacing: 8, crossAxisSpacing: 8, - childAspectRatio: 1.1, + childAspectRatio: 0.9, ), itemCount: _groups.length, itemBuilder: (context, i) { final (label, key, icon) = _groups[i]; - final count = counts[key] ?? -1; + final count = widget.counts[key] ?? -1; final cached = count >= 0; - return AnimatedSwitcher( - duration: const Duration(milliseconds: 400), - child: _CacheTile( - key: ValueKey('$key-$count'), - label: label, - icon: icon, - count: count, - cached: cached, - scheme: scheme, + + final start = i * 0.055; + final end = (start + 0.45).clamp(0.0, 1.0); + final curve = CurvedAnimation( + parent: _controller, + curve: Interval(start, end, curve: Curves.easeOutCubic), + ); + final fadeAnim = Tween(begin: 0.0, end: 1.0).animate(curve); + final slideAnim = Tween( + begin: const Offset(0, 0.25), + end: Offset.zero, + ).animate(curve); + + return FadeTransition( + opacity: fadeAnim, + child: SlideTransition( + position: slideAnim, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + child: _CacheTile( + key: ValueKey('$key-$count'), + label: label, + icon: icon, + count: count, + cached: cached, + scheme: scheme, + ), + ), ), ); }, @@ -328,24 +369,25 @@ class _CacheTile extends StatelessWidget { final bg = cached ? scheme.primaryContainer.withValues(alpha: 0.5) : scheme.surfaceContainerHighest; final fg = cached ? scheme.onPrimaryContainer : scheme.onSurfaceVariant; return Container( - decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.all(10), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(14)), + padding: const EdgeInsets.all(12), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, color: fg, size: 22), - const SizedBox(height: 4), + Icon(icon, color: fg, size: 24), + const SizedBox(height: 6), Text( label, - style: TextStyle(color: fg, fontSize: 10, fontWeight: FontWeight.w500), + style: TextStyle(color: fg, fontSize: 11, fontWeight: FontWeight.w500), textAlign: TextAlign.center, - maxLines: 1, + maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( cached ? '$count items' : 'Not cached', - style: TextStyle(color: fg.withValues(alpha: 0.7), fontSize: 9), + style: TextStyle(color: fg.withValues(alpha: 0.7), fontSize: 10), + textAlign: TextAlign.center, ), ], ), diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 29fde400..ccd1910f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -18,6 +18,7 @@ import geolocator_apple import package_info_plus import printing import quill_native_bridge_macos +import share_plus import shared_preferences_foundation import sqflite_darwin import url_launcher_macos @@ -36,6 +37,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) QuillNativeBridgePlugin.register(with: registry.registrar(forPlugin: "QuillNativeBridgePlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/pubspec.lock b/pubspec.lock index f9aed953..69595a5e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1789,6 +1789,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + safe_device: + dependency: "direct main" + description: + name: safe_device + sha256: f6404b240191e83bcf814abddeb350158cdc69a6cc749aff73f199090b08e402 + url: "https://pub.dev" + source: hosted + version: "1.3.10" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + url: "https://pub.dev" + source: hosted + version: "10.1.4" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + url: "https://pub.dev" + source: hosted + version: "5.0.2" shared_preferences: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 9d53d2fe..dbb09fc0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: google_fonts: ^8.0.2 audioplayers: ^6.1.0 geolocator: ^13.0.1 + safe_device: ^1.1.4 timezone: ^0.10.1 flutter_map: ^8.2.2 latlong2: ^0.9.0 @@ -52,6 +53,7 @@ dependencies: flutter_image_compress: ^2.4.0 dio: ^5.1.2 package_info_plus: ^9.0.0 + share_plus: ^10.0.0 pub_semver: ^2.1.1 ota_update: ^7.1.0 brick_offline_first: ^4.0.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index b8332c20..ae71ff61 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -13,6 +13,7 @@ #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -30,6 +31,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); PrintingPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PrintingPlugin")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index d1a7b3e5..a3ed56bc 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST geolocator_windows permission_handler_windows printing + share_plus url_launcher_windows )