1327 lines
44 KiB
Dart
1327 lines
44 KiB
Dart
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<TaskActivityLog> 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<AttendanceLog> sessions,
|
||
required List<PassSlip> passSlips,
|
||
required List<Task> tasks,
|
||
required List<Ticket> tickets,
|
||
required List<ItServiceRequest> isrs,
|
||
required DateTime dayStart,
|
||
required DateTime dayEnd,
|
||
required String? effectiveUserId,
|
||
required List<TaskAssignment> taskAssignments,
|
||
required List<ItServiceRequestAssignment> isrAssignments,
|
||
required List<TaskActivityLog> 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<WorkLogTab> createState() => _WorkLogTabState();
|
||
}
|
||
|
||
class _WorkLogTabState extends ConsumerState<WorkLogTab> {
|
||
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<dynamic> 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<void> _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 <TaskAssignment>[]
|
||
: 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)))
|
||
: <dynamic>[];
|
||
|
||
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<String?>(
|
||
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<String?>(
|
||
value: null,
|
||
child:
|
||
Text(currentProfile?.fullName ?? 'Me'),
|
||
),
|
||
...profiles.map((p) => DropdownMenuItem<String?>(
|
||
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),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|