1957 lines
67 KiB
Dart
1957 lines
67 KiB
Dart
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),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|