Files
tasq/lib/screens/attendance/work_log_tab.dart
T

1957 lines
67 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:printing/printing.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';
import 'work_log_pdf.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;
}
// ─── View mode & multi-day summary ──────────────────────────────────────────
enum _ViewMode { daily, weekly, monthly, custom }
class _DailySummaryData {
const _DailySummaryData({
required this.date,
required this.items,
required this.summary,
});
final DateTime date;
final List<_WorkItem> items;
final _WorkSummary summary;
bool get hasData => items.any((i) => !i.isCreatorEvent);
}
// ─── 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();
_ViewMode _viewMode = _ViewMode.daily;
DateTimeRange? _customRange;
DateTime? _drilledDate;
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)}';
DateTimeRange get _effectiveDateRange {
switch (_viewMode) {
case _ViewMode.daily:
return DateTimeRange(start: _dayStart, end: _dayEnd);
case _ViewMode.weekly:
final d = _selectedDate;
final monday = DateTime(d.year, d.month, d.day)
.subtract(Duration(days: d.weekday - 1));
return DateTimeRange(
start: monday, end: monday.add(const Duration(days: 7)));
case _ViewMode.monthly:
final start = DateTime(_selectedDate.year, _selectedDate.month, 1);
final end = DateTime(_selectedDate.year, _selectedDate.month + 1, 1);
return DateTimeRange(start: start, end: end);
case _ViewMode.custom:
return _customRange ??
DateTimeRange(start: _dayStart, end: _dayEnd);
}
}
String get _rangeLabel {
final fmt = DateFormat('MMM d');
final fmtY = DateFormat('MMM d, yyyy');
switch (_viewMode) {
case _ViewMode.daily:
return _dateLabel;
case _ViewMode.weekly:
final r = _effectiveDateRange;
return '${fmt.format(r.start)} ${fmtY.format(r.end.subtract(const Duration(days: 1)))}';
case _ViewMode.monthly:
return DateFormat('MMMM yyyy').format(_selectedDate);
case _ViewMode.custom:
if (_customRange == null) return 'Pick range';
final r = _customRange!;
return '${fmt.format(r.start)} ${fmtY.format(r.end.subtract(const Duration(days: 1)))}';
}
}
bool get _isMultiDay => _viewMode != _ViewMode.daily;
bool get _isDrilling => _isMultiDay && _drilledDate != null;
void _prevRange() {
setState(() {
switch (_viewMode) {
case _ViewMode.daily:
_selectedDate =
_selectedDate.subtract(const Duration(days: 1));
case _ViewMode.weekly:
_selectedDate =
_selectedDate.subtract(const Duration(days: 7));
case _ViewMode.monthly:
_selectedDate =
DateTime(_selectedDate.year, _selectedDate.month - 1, 1);
case _ViewMode.custom:
if (_customRange != null) {
final span =
_customRange!.end.difference(_customRange!.start);
_customRange = DateTimeRange(
start: _customRange!.start.subtract(span),
end: _customRange!.end.subtract(span),
);
}
}
_drilledDate = null;
});
}
void _nextRange() {
setState(() {
switch (_viewMode) {
case _ViewMode.daily:
_selectedDate = _selectedDate.add(const Duration(days: 1));
case _ViewMode.weekly:
_selectedDate = _selectedDate.add(const Duration(days: 7));
case _ViewMode.monthly:
_selectedDate =
DateTime(_selectedDate.year, _selectedDate.month + 1, 1);
case _ViewMode.custom:
if (_customRange != null) {
final span =
_customRange!.end.difference(_customRange!.start);
_customRange = DateTimeRange(
start: _customRange!.start.add(span),
end: _customRange!.end.add(span),
);
}
}
_drilledDate = null;
});
}
Future<void> _pickCustomRange(BuildContext context) async {
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2020),
lastDate: DateTime.now(),
initialDateRange: _customRange,
);
if (picked != null) {
setState(() {
_customRange = DateTimeRange(
start: DateTime(
picked.start.year, picked.start.month, picked.start.day),
end: DateTime(picked.end.year, picked.end.month, picked.end.day)
.add(const Duration(days: 1)),
);
_drilledDate = null;
});
}
}
bool _canGoNext() =>
_effectiveDateRange.end.isBefore(DateTime.now());
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();
}
});
});
}
Future<void> _shareMultiDayWorkLog(
BuildContext context,
List<_DailySummaryData> multiDaySummaries,
String personName,
String rangeLabel,
) async {
final messenger = ScaffoldMessenger.of(context);
try {
final range = _effectiveDateRange;
final modeLabel = switch (_viewMode) {
_ViewMode.weekly => 'Weekly',
_ViewMode.monthly => 'Monthly',
_ViewMode.custom => 'Custom Range',
_ViewMode.daily => 'Daily',
};
final bytes = await buildMultiDayWorkLogPdfBytes(
personName: personName,
rangeStart: range.start,
rangeEnd: range.end,
modeLabel: modeLabel,
days: multiDaySummaries
.map((d) => DailyWorkLogDto(
date: d.date,
entries: d.items.map(_toEntryDto).toList(),
summary: _toSummaryDto(d.summary),
))
.toList(),
);
final safeName = rangeLabel
.replaceAll(' ', '_')
.replaceAll(',', '')
.replaceAll('', '-');
if (!context.mounted) return;
await Share.shareXFiles(
[
XFile.fromData(
bytes,
name: 'worklog_summary_$safeName.pdf',
mimeType: 'application/pdf',
)
],
subject: 'Work Log Summary $personName $rangeLabel',
);
} catch (e) {
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('Export failed: $e')),
);
}
}
// ── Multi-day data helpers ──────────────────────────────────────────────────
({
List<AttendanceLog> sessions,
List<PassSlip> passSlips,
List<Task> tasks,
List<Ticket> tickets,
List<ItServiceRequest> isrs,
}) _filterDataForDay(
DateTime dayStart,
DateTime dayEnd,
String effectiveUserId,
List<AttendanceLog> allLogs,
List<PassSlip> allPassSlips,
List<Task> allTasks,
List<Ticket> allTickets,
List<ItServiceRequest> allIsrs,
Set<String> assignedTaskIdSet,
Set<String> isrAssignedIdSet,
) {
return (
sessions: allLogs
.where((l) =>
l.userId == effectiveUserId &&
!l.checkInAt.isBefore(dayStart) &&
l.checkInAt.isBefore(dayEnd))
.toList(),
passSlips: allPassSlips
.where((s) => s.userId == effectiveUserId)
.toList(),
tasks: allTasks
.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(),
tickets: allTickets
.where((t) =>
t.creatorId == effectiveUserId &&
_onDay(t.createdAt, dayStart, dayEnd))
.toList(),
isrs: allIsrs
.where((r) =>
((r.creatorId == effectiveUserId ||
r.requestedByUserId == effectiveUserId) &&
_onDay(r.createdAt, dayStart, dayEnd)) ||
isrAssignedIdSet.contains(r.id))
.toList(),
);
}
List<_DailySummaryData> _computeDailySummaries(
DateTimeRange range,
String effectiveUserId,
List<AttendanceLog> allLogs,
List<PassSlip> allPassSlips,
List<Task> allTasks,
List<Ticket> allTickets,
List<ItServiceRequest> allIsrs,
List<TaskActivityLog> allActivityLogs,
List<TaskAssignment> taskAssignments,
List<ItServiceRequestAssignment> isrAssignments,
Set<String> assignedTaskIdSet,
Set<String> isrAssignedIdSet,
) {
final results = <_DailySummaryData>[];
var current = range.start;
while (current.isBefore(range.end)) {
final dayStart = current;
final dayEnd = current.add(const Duration(days: 1));
final fd = _filterDataForDay(
dayStart, dayEnd, effectiveUserId,
allLogs, allPassSlips, allTasks, allTickets, allIsrs,
assignedTaskIdSet, isrAssignedIdSet,
);
final items = _buildTimeline(
sessions: fd.sessions,
passSlips: fd.passSlips,
tasks: fd.tasks,
tickets: fd.tickets,
isrs: fd.isrs,
dayStart: dayStart,
dayEnd: dayEnd,
effectiveUserId: effectiveUserId,
taskAssignments: taskAssignments,
isrAssignments: isrAssignments,
allActivityLogs: allActivityLogs,
);
results.add(_DailySummaryData(
date: dayStart,
items: items,
summary: _computeSummary(items),
));
current = current.add(const Duration(days: 1));
}
return results;
}
WorkLogEntryDto _toEntryDto(_WorkItem item) => WorkLogEntryDto(
type: item.type.name,
title: item.title,
subtitle: item.subtitle,
start: item.start,
end: item.end,
isCreatorEvent: item.isCreatorEvent,
duration: item.duration,
);
WorkLogSummaryDto _toSummaryDto(_WorkSummary s) => WorkLogSummaryDto(
totalAttendance: s.totalAttendance,
totalTasks: s.totalTasks,
totalTickets: s.totalTickets,
totalIsrs: s.totalIsrs,
totalPassSlip: s.totalPassSlip,
totalVacant: s.totalVacant,
);
Future<void> _printWorkLog(
BuildContext context,
List<_WorkItem> dailyItems,
_WorkSummary dailySummary,
String personName,
List<_DailySummaryData> multiDaySummaries,
) async {
final messenger = ScaffoldMessenger.of(context);
try {
late Uint8List bytes;
if (!_isMultiDay || _isDrilling) {
final date = _drilledDate ?? _selectedDate;
bytes = await buildDailyWorkLogPdfBytes(
personName: personName,
date: date,
entries: dailyItems.map(_toEntryDto).toList(),
summary: _toSummaryDto(dailySummary),
);
} else {
final range = _effectiveDateRange;
final modeLabel = switch (_viewMode) {
_ViewMode.weekly => 'Weekly',
_ViewMode.monthly => 'Monthly',
_ViewMode.custom => 'Custom Range',
_ViewMode.daily => 'Daily',
};
bytes = await buildMultiDayWorkLogPdfBytes(
personName: personName,
rangeStart: range.start,
rangeEnd: range.end,
modeLabel: modeLabel,
days: multiDaySummaries
.map((d) => DailyWorkLogDto(
date: d.date,
entries: d.items.map(_toEntryDto).toList(),
summary: _toSummaryDto(d.summary),
))
.toList(),
);
}
await Printing.layoutPdf(
onLayout: (_) async => bytes,
name: 'worklog_${DateTime.now().millisecondsSinceEpoch}.pdf',
);
} catch (e) {
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('Failed to generate PDF: $e')),
);
}
}
@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);
// Raw unfiltered lists — used by multi-day summary computation.
final allLogs = logsAsync.valueOrNull ?? [];
final allPassSlips = passSlipsAsync.valueOrNull ?? [];
final allTasks = tasksAsync.valueOrNull ?? [];
final allTickets = ticketsAsync.valueOrNull ?? [];
final allIsrs = isrsAsync.valueOrNull ?? [];
// ── 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();
final assignedTaskIdSet =
taskAssignmentsForUser.map((a) => a.taskId).toSet();
final isrAssignedIdSet =
isrAssignmentsForUser.map((a) => a.requestId).toSet();
final isLoading =
logsAsync.isLoading && logsAsync.valueOrNull == null;
// ── Daily / drilled timeline ────────────────────────────────────────────
// In multi-day mode without a drilled date, items/summary still cover
// _selectedDate so the share button has data when the user drills in.
final displayDate = _drilledDate ?? _selectedDate;
final displayDayStart =
DateTime(displayDate.year, displayDate.month, displayDate.day);
final displayDayEnd = displayDayStart.add(const Duration(days: 1));
final fd = _filterDataForDay(
displayDayStart, displayDayEnd, effectiveUserId ?? '',
allLogs, allPassSlips, allTasks, allTickets, allIsrs,
assignedTaskIdSet, isrAssignedIdSet,
);
final items = _buildTimeline(
sessions: fd.sessions,
passSlips: fd.passSlips,
tasks: fd.tasks,
tickets: fd.tickets,
isrs: fd.isrs,
dayStart: displayDayStart,
dayEnd: displayDayEnd,
effectiveUserId: effectiveUserId,
taskAssignments: taskAssignmentsForUser,
isrAssignments: isrAssignmentsForUser,
allActivityLogs: allActivityLogs,
);
final summary = _computeSummary(items);
// ── Multi-day summaries (computed only when visible) ───────────────────
final dailySummaries = _isMultiDay && !_isDrilling
? _computeDailySummaries(
_effectiveDateRange,
effectiveUserId ?? '',
allLogs,
allPassSlips,
allTasks,
allTickets,
allIsrs,
allActivityLogs,
taskAssignmentsForUser,
isrAssignmentsForUser,
assignedTaskIdSet,
isrAssignedIdSet,
)
: <_DailySummaryData>[];
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: [
// ── Mode selector + Date navigator ────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Column(
children: [
SegmentedButton<_ViewMode>(
segments: const [
ButtonSegment(
value: _ViewMode.daily, label: Text('Daily')),
ButtonSegment(
value: _ViewMode.weekly, label: Text('Weekly')),
ButtonSegment(
value: _ViewMode.monthly, label: Text('Monthly')),
ButtonSegment(
value: _ViewMode.custom, label: Text('Custom')),
],
selected: {_viewMode},
onSelectionChanged: (set) => setState(() {
_viewMode = set.first;
_drilledDate = null;
}),
style: ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
),
const SizedBox(height: 8),
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: [
if (_isDrilling)
IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Back to overview',
onPressed: () =>
setState(() => _drilledDate = null),
)
else
IconButton.filledTonal(
icon: const Icon(Icons.chevron_left),
onPressed: _prevRange,
tooltip: 'Previous',
),
Expanded(
child: GestureDetector(
onTap: _viewMode == _ViewMode.custom &&
!_isDrilling
? () => _pickCustomRange(context)
: _viewMode == _ViewMode.daily
? () 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);
}
}
: null,
child: Column(
children: [
Text(
_isDrilling
? DateFormat('EEEE, MMM d')
.format(_drilledDate!)
: _rangeLabel,
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
if (_isDrilling)
Text(
'Full day · tap ← to go back',
style:
theme.textTheme.labelSmall?.copyWith(
color: colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
)
else if (_viewMode == _ViewMode.custom &&
_customRange == null)
Text(
'Tap to pick date range',
style:
theme.textTheme.labelSmall?.copyWith(
color: colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
),
IconButton(
icon: const Icon(Icons.print_rounded),
tooltip: 'Print as PDF',
onPressed:
(_isMultiDay && !_isDrilling &&
dailySummaries.isEmpty) ||
(!_isMultiDay && items.isEmpty)
? null
: () => _printWorkLog(
context,
items,
summary,
personName,
dailySummaries,
),
),
IconButton(
icon: const Icon(Icons.share_rounded),
tooltip: 'Share as image',
onPressed: _isMultiDay && !_isDrilling
? (dailySummaries.isEmpty
? null
: () => _shareMultiDayWorkLog(
context,
dailySummaries,
personName,
_rangeLabel,
))
: (items.isEmpty
? null
: () => _captureAndShare(
context, items, summary, personName)),
),
if (!_isDrilling)
IconButton.filledTonal(
icon: const Icon(Icons.chevron_right),
onPressed: _canGoNext() ? _nextRange : null,
tooltip: 'Next',
),
],
),
),
),
],
),
),
// ── 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: _isMultiDay && !_isDrilling
? (_viewMode == _ViewMode.custom && _customRange == null
? Center(
key: const ValueKey('custom-pick'),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.date_range_rounded,
size: 48,
color: colors.onSurfaceVariant
.withValues(alpha: 0.4)),
const SizedBox(height: 12),
Text(
'Tap the date label above to pick a range',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant),
),
],
),
)
: dailySummaries.isEmpty && !isLoading
? _EmptyDay(
key: ValueKey('multiday-${_viewMode.name}'))
: ListView.builder(
key: ValueKey(
'multiday-${_viewMode.name}-${_effectiveDateRange.start}'),
padding:
const EdgeInsets.fromLTRB(16, 8, 16, 16),
itemCount: dailySummaries.length,
itemBuilder: (ctx, i) {
final day = dailySummaries[i];
return M3FadeSlideIn(
delay: Duration(
milliseconds: i.clamp(0, 20) * 30),
child: _DaySummaryCard(
data: day,
onDrill: () => setState(
() => _drilledDate = day.date),
),
);
},
))
: (items.isEmpty && !isLoading
? _EmptyDay(
key: ValueKey(
'daily-${_drilledDate ?? _selectedDate}'))
: ListView.builder(
key: ValueKey(
'daily-${_drilledDate ?? _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),
),
],
),
);
}
}
// ─── Multi-day summary card ───────────────────────────────────────────────────
class _DaySummaryCard extends StatelessWidget {
const _DaySummaryCard({required this.data, required this.onDrill});
final _DailySummaryData data;
final VoidCallback onDrill;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
final s = data.summary;
final totalActive = s.totalAttendance +
s.totalTasks +
s.totalTickets +
s.totalIsrs +
s.totalPassSlip;
final dateLabel = DateFormat('EEE, MMM d').format(data.date);
return Card(
elevation: 0,
color: cs.surfaceContainerLow,
margin: const EdgeInsets.symmetric(vertical: 4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: InkWell(
onTap: data.hasData ? onDrill : null,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
SizedBox(
width: 80,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(dateLabel, style: tt.labelLarge),
if (data.hasData)
Text(
_fmtDur(totalActive),
style: tt.labelMedium?.copyWith(color: cs.primary),
)
else
Text(
'No data',
style: tt.labelSmall?.copyWith(color: cs.outline),
),
],
),
),
if (data.hasData) ...[
const SizedBox(width: 8),
Expanded(
child: Wrap(
spacing: 4,
runSpacing: 4,
children: [
if (s.totalAttendance > Duration.zero)
_SummaryChip(
icon: Icons.login_rounded,
label: _fmtDur(s.totalAttendance),
color: cs.primaryContainer,
onColor: cs.onPrimaryContainer,
),
if (s.totalTasks > Duration.zero)
_SummaryChip(
icon: Icons.task_alt_rounded,
label: _fmtDur(s.totalTasks),
color: cs.secondaryContainer,
onColor: cs.onSecondaryContainer,
),
if (s.totalTickets > Duration.zero)
_SummaryChip(
icon: Icons.confirmation_number_outlined,
label: _fmtDur(s.totalTickets),
color: cs.secondaryContainer,
onColor: cs.onSecondaryContainer,
),
if (s.totalIsrs > Duration.zero)
_SummaryChip(
icon: Icons.computer_outlined,
label: _fmtDur(s.totalIsrs),
color: cs.tertiaryContainer,
onColor: cs.onTertiaryContainer,
),
if (s.totalPassSlip > Duration.zero)
_SummaryChip(
icon: Icons.badge_outlined,
label: _fmtDur(s.totalPassSlip),
color: cs.tertiaryContainer,
onColor: cs.onTertiaryContainer,
),
if (s.totalVacant > Duration.zero)
_SummaryChip(
icon: Icons.hourglass_empty_rounded,
label: _fmtDur(s.totalVacant),
color: cs.surfaceContainerHighest,
onColor: cs.onSurfaceVariant,
),
],
),
),
Icon(Icons.chevron_right,
size: 20, color: cs.onSurfaceVariant),
],
],
),
),
),
);
}
}
class _SummaryChip extends StatelessWidget {
const _SummaryChip({
required this.icon,
required this.label,
required this.color,
required this.onColor,
});
final IconData icon;
final String label;
final Color color;
final Color onColor;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: onColor),
const SizedBox(width: 4),
Text(label, style: TextStyle(fontSize: 11, color: onColor)),
],
),
);
}
}
// ─── 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),
),
),
],
),
);
}
}