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

1327 lines
44 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:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:share_plus/share_plus.dart';
import '../../models/attendance_log.dart';
import '../../models/it_service_request.dart';
import '../../models/it_service_request_assignment.dart';
import '../../models/pass_slip.dart';
import '../../models/task.dart';
import '../../models/task_activity_log.dart';
import '../../models/task_assignment.dart';
import '../../models/ticket.dart';
import '../../providers/attendance_provider.dart';
import '../../providers/it_service_request_provider.dart';
import '../../providers/pass_slip_provider.dart';
import '../../providers/profile_provider.dart';
import '../../providers/tasks_provider.dart';
import '../../providers/tickets_provider.dart';
import '../../theme/m3_motion.dart';
import '../../utils/app_time.dart';
// ─── Domain types ────────────────────────────────────────────────────────────
enum _WorkItemType { attendance, task, ticket, isr, passSlip, vacant }
class _WorkItem {
const _WorkItem({
required this.type,
required this.title,
required this.start,
this.subtitle,
this.end,
this.referenceNumber,
this.isCreatorEvent = false,
});
final _WorkItemType type;
final String title;
final String? subtitle;
final String? referenceNumber;
final DateTime start;
final DateTime? end;
// True for state-change markers (zero work time, excluded from summary totals).
final bool isCreatorEvent;
Duration get duration {
if (isCreatorEvent) return Duration.zero;
// Any non-marker item without an end time counts elapsed time to now
// (attendance clocked in, task segment in progress, ISR not yet closed).
if (end == null) return DateTime.now().difference(start).abs();
return end!.difference(start).abs();
}
bool get isVacant => type == _WorkItemType.vacant;
}
class _WorkSummary {
const _WorkSummary({
required this.totalAttendance,
required this.totalTasks,
required this.totalTickets,
required this.totalIsrs,
required this.totalPassSlip,
required this.totalVacant,
});
final Duration totalAttendance;
final Duration totalTasks;
final Duration totalTickets;
final Duration totalIsrs;
final Duration totalPassSlip;
final Duration totalVacant;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
String _fmtDur(Duration d) {
if (d.inMinutes < 1) return '< 1m';
final h = d.inHours;
final m = d.inMinutes % 60;
return h > 0 ? '${h}h ${m}m' : '${m}m';
}
bool _onDay(DateTime? dt, DateTime dayStart, DateTime dayEnd) =>
dt != null && !dt.isBefore(dayStart) && dt.isBefore(dayEnd);
// Generates per-event work items for one task assignment.
// Working segments (In progress / Resumed) get non-zero duration;
// state-change markers (Paused, Completed, Cancelled) are zero-duration.
void _addTaskSegments({
required List<_WorkItem> raw,
required Task task,
required List<TaskActivityLog> taskLogs, // must be ascending
required DateTime dayStart,
required DateTime dayEnd,
}) {
DateTime? segStart;
String segLabel = 'In progress';
for (final event in taskLogs) {
if (event.actionType == 'started') {
segStart = event.createdAt;
segLabel = 'In progress';
} else if (event.actionType == 'resumed') {
segStart = event.createdAt;
segLabel = 'Resumed';
} else if (event.actionType == 'paused') {
if (segStart != null && _onDay(segStart, dayStart, dayEnd)) {
raw.add(_WorkItem(
type: _WorkItemType.task,
title: task.title,
subtitle: segLabel,
referenceNumber: task.taskNumber,
start: segStart,
end: event.createdAt,
));
}
segStart = null;
if (_onDay(event.createdAt, dayStart, dayEnd)) {
raw.add(_WorkItem(
type: _WorkItemType.task,
title: task.title,
subtitle: 'Paused',
referenceNumber: task.taskNumber,
start: event.createdAt,
end: event.createdAt,
isCreatorEvent: true,
));
}
}
}
// Open segment (currently in progress or completed without a trailing pause)
if (segStart != null && _onDay(segStart, dayStart, dayEnd)) {
raw.add(_WorkItem(
type: _WorkItemType.task,
title: task.title,
subtitle: segLabel,
referenceNumber: task.taskNumber,
start: segStart,
end: task.completedAt ?? task.cancelledAt, // null = still in progress
));
}
// Completion / cancellation markers
if (task.completedAt != null && _onDay(task.completedAt, dayStart, dayEnd)) {
raw.add(_WorkItem(
type: _WorkItemType.task,
title: task.title,
subtitle: 'Completed',
referenceNumber: task.taskNumber,
start: task.completedAt!,
end: task.completedAt,
isCreatorEvent: true,
));
} else if (task.cancelledAt != null &&
_onDay(task.cancelledAt, dayStart, dayEnd)) {
raw.add(_WorkItem(
type: _WorkItemType.task,
title: task.title,
subtitle: 'Cancelled',
referenceNumber: task.taskNumber,
start: task.cancelledAt!,
end: task.cancelledAt,
isCreatorEvent: true,
));
}
}
String _isrAssigneeSubtitle(ItServiceRequest r) {
if (r.completedAt != null) return 'Completed';
if (r.dateTimeChecked != null) return 'Checked · in progress';
if (r.dateTimeReceived != null) return 'Received';
return 'Assigned · ${r.status}';
}
List<_WorkItem> _buildTimeline({
required List<AttendanceLog> sessions,
required List<PassSlip> passSlips,
required List<Task> tasks,
required List<Ticket> tickets,
required List<ItServiceRequest> isrs,
required DateTime dayStart,
required DateTime dayEnd,
required String? effectiveUserId,
required List<TaskAssignment> taskAssignments,
required List<ItServiceRequestAssignment> isrAssignments,
required List<TaskActivityLog> allActivityLogs,
}) {
final raw = <_WorkItem>[];
// ── Attendance sessions ──────────────────────────────────────────────────
for (final s in sessions) {
if (!_onDay(s.checkInAt, dayStart, dayEnd)) continue;
raw.add(_WorkItem(
type: _WorkItemType.attendance,
title: 'Check-in Session',
subtitle: s.justification,
start: s.checkInAt,
end: s.checkOutAt,
));
}
// ── Pass slips ───────────────────────────────────────────────────────────
for (final slip in passSlips) {
if (slip.slipStart == null) continue;
if (!_onDay(slip.slipStart, dayStart, dayEnd)) continue;
raw.add(_WorkItem(
type: _WorkItemType.passSlip,
title: 'Pass Slip',
subtitle: slip.reason,
start: slip.slipStart!,
end: slip.slipEnd,
));
}
// ── Tasks ────────────────────────────────────────────────────────────────
// Each task generates multiple timeline items — one per lifecycle event.
// Working segments (In progress / Resumed) carry non-zero duration;
// state-change markers (Assigned, Paused, Completed) are zero-duration.
for (final t in tasks) {
// Creator marker
if (t.creatorId == effectiveUserId && _onDay(t.createdAt, dayStart, dayEnd)) {
raw.add(_WorkItem(
type: _WorkItemType.task,
title: t.title,
subtitle: 'Created',
referenceNumber: t.taskNumber,
start: t.createdAt,
end: t.createdAt,
isCreatorEvent: true,
));
}
final userAssignments =
taskAssignments.where((a) => a.taskId == t.id).toList();
if (userAssignments.isEmpty) continue;
// Assigned markers (one per assignment record)
for (final a in userAssignments) {
if (_onDay(a.createdAt, dayStart, dayEnd)) {
raw.add(_WorkItem(
type: _WorkItemType.task,
title: t.title,
subtitle: 'Assigned',
referenceNumber: t.taskNumber,
start: a.createdAt,
end: a.createdAt,
isCreatorEvent: true,
));
}
}
// Working segments from activity logs — processed once per task
final taskLogs = allActivityLogs
.where((l) => l.taskId == t.id)
.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
_addTaskSegments(
raw: raw,
task: t,
taskLogs: taskLogs,
dayStart: dayStart,
dayEnd: dayEnd,
);
}
// ── Tickets ──────────────────────────────────────────────────────────────
// No assignment model for tickets; keep creator-based display.
// Duration is capped at closedAt — open tickets return Duration.zero (see getter).
for (final t in tickets) {
if (!_onDay(t.createdAt, dayStart, dayEnd)) continue;
raw.add(_WorkItem(
type: _WorkItemType.ticket,
title: t.subject,
start: t.createdAt,
end: t.closedAt ?? t.promotedAt,
));
}
// ── IT Service Requests ──────────────────────────────────────────────────
// Creator/requester: zero-duration marker on creation day.
// Assignee (IT staff): work item from assignment time to completion.
for (final r in isrs) {
// Creator/requester event
if ((r.creatorId == effectiveUserId ||
r.requestedByUserId == effectiveUserId) &&
_onDay(r.createdAt, dayStart, dayEnd)) {
raw.add(_WorkItem(
type: _WorkItemType.isr,
title: 'IT Service Request',
subtitle: 'Requested',
referenceNumber: r.requestNumber,
start: r.createdAt,
end: r.createdAt,
isCreatorEvent: true,
));
}
// Assignee work items
for (final a in isrAssignments.where((a) => a.requestId == r.id)) {
final assignedOnDay = _onDay(a.createdAt, dayStart, dayEnd);
final completedOnDay = _onDay(r.completedAt, dayStart, dayEnd);
if (!assignedOnDay && !completedOnDay) continue;
raw.add(_WorkItem(
type: _WorkItemType.isr,
title: 'IT Service Request',
subtitle: _isrAssigneeSubtitle(r),
referenceNumber: r.requestNumber,
start: a.createdAt,
end: r.completedAt,
));
}
}
raw.sort((a, b) => a.start.compareTo(b.start));
final result = <_WorkItem>[];
for (int i = 0; i < raw.length; i++) {
result.add(raw[i]);
if (i + 1 < raw.length && raw[i].end != null) {
final gap = raw[i + 1].start.difference(raw[i].end!);
if (gap.inMinutes > 5) {
result.add(_WorkItem(
type: _WorkItemType.vacant,
title: 'Vacant',
start: raw[i].end!,
end: raw[i + 1].start,
));
}
}
}
return result;
}
_WorkSummary _computeSummary(List<_WorkItem> items) {
Duration att = Duration.zero;
Duration tasks = Duration.zero;
Duration tickets = Duration.zero;
Duration isrs = Duration.zero;
Duration slips = Duration.zero;
Duration vacant = Duration.zero;
for (final item in items) {
if (item.isCreatorEvent) continue;
final d = item.duration;
switch (item.type) {
case _WorkItemType.attendance:
att += d;
case _WorkItemType.task:
tasks += d;
case _WorkItemType.ticket:
tickets += d;
case _WorkItemType.isr:
isrs += d;
case _WorkItemType.passSlip:
slips += d;
case _WorkItemType.vacant:
vacant += d;
}
}
return _WorkSummary(
totalAttendance: att,
totalTasks: tasks,
totalTickets: tickets,
totalIsrs: isrs,
totalPassSlip: slips,
totalVacant: vacant,
);
}
// ─── Dashed border painter ───────────────────────────────────────────────────
class _DashedBorder extends CustomPainter {
const _DashedBorder({required this.color});
final Color color;
static const double _radius = 8.0;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = 1.0
..style = PaintingStyle.stroke;
final path = Path()
..addRRect(RRect.fromRectAndRadius(
Rect.fromLTWH(0.5, 0.5, size.width - 1, size.height - 1),
Radius.circular(_radius),
));
const dashLen = 5.0;
const gapLen = 4.0;
for (final metric in path.computeMetrics()) {
double dist = 0;
while (dist < metric.length) {
canvas.drawPath(
metric.extractPath(dist, (dist + dashLen).clamp(0, metric.length)),
paint,
);
dist += dashLen + gapLen;
}
}
}
@override
bool shouldRepaint(_DashedBorder old) => old.color != color;
}
// ─── Tab widget ───────────────────────────────────────────────────────────────
class WorkLogTab extends ConsumerStatefulWidget {
const WorkLogTab({super.key});
@override
ConsumerState<WorkLogTab> createState() => _WorkLogTabState();
}
class _WorkLogTabState extends ConsumerState<WorkLogTab> {
DateTime _selectedDate = DateTime.now();
String? _selectedUserId;
final _exportKey = GlobalKey();
void _prevDay() =>
setState(() => _selectedDate = _selectedDate.subtract(const Duration(days: 1)));
void _nextDay() =>
setState(() => _selectedDate = _selectedDate.add(const Duration(days: 1)));
DateTime get _dayStart =>
DateTime(_selectedDate.year, _selectedDate.month, _selectedDate.day);
DateTime get _dayEnd => _dayStart.add(const Duration(days: 1));
String get _dateLabel =>
'${_isToday(_selectedDate) ? 'Today' : _weekday(_selectedDate)}, ${AppTime.formatDate(_selectedDate)}';
bool _isToday(DateTime d) {
final now = DateTime.now();
return d.year == now.year && d.month == now.month && d.day == now.day;
}
String _weekday(DateTime d) {
const names = [
'Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday',
];
return names[d.weekday - 1];
}
String _resolvePersonName(dynamic currentProfile, List<dynamic> profiles) {
if (_selectedUserId == null) {
return currentProfile?.fullName as String? ?? '';
}
for (final p in profiles) {
if (p.id == _selectedUserId) return p.fullName as String? ?? '';
}
return currentProfile?.fullName as String? ?? '';
}
Future<void> _captureAndShare(
BuildContext context,
List<_WorkItem> items,
_WorkSummary summary,
String personName,
) async {
final screenWidth = MediaQuery.of(context).size.width;
final theme = Theme.of(context);
final overlay = Overlay.of(context);
late OverlayEntry entry;
entry = OverlayEntry(
builder: (ctx) => Positioned(
left: screenWidth * 2,
top: 0,
width: screenWidth,
child: RepaintBoundary(
key: _exportKey,
child: Material(
color: theme.colorScheme.surface,
child: _WorkLogPrintView(
dateLabel: _dateLabel,
personName: personName,
items: items,
summary: summary,
),
),
),
),
);
overlay.insert(entry);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Preparing image…'),
duration: Duration(seconds: 1),
behavior: SnackBarBehavior.floating,
),
);
}
// Two frames: first to insert the overlay, second to ensure it's painted.
WidgetsBinding.instance.addPostFrameCallback((_) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
try {
final boundary = _exportKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary == null) return;
final img = await boundary.toImage(pixelRatio: 2.5);
final byteData =
await img.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) return;
final pngBytes = byteData.buffer.asUint8List();
final safeName = AppTime.formatDate(_selectedDate)
.replaceAll(' ', '_')
.replaceAll(',', '');
if (context.mounted) {
await Share.shareXFiles(
[
XFile.fromData(
pngBytes,
name: 'worklog_$safeName.png',
mimeType: 'image/png',
)
],
subject:
'Work Log $personName ${AppTime.formatDate(_selectedDate)}',
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Export failed: $e')),
);
}
} finally {
entry.remove();
}
});
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final currentProfile = ref.watch(currentProfileProvider).valueOrNull;
final isAdmin = currentProfile?.role == 'admin' ||
currentProfile?.role == 'programmer' ||
currentProfile?.role == 'dispatcher';
final effectiveUserId = _selectedUserId ?? currentProfile?.id;
final logsAsync = ref.watch(attendanceLogsProvider);
final tasksAsync = ref.watch(tasksProvider);
final ticketsAsync = ref.watch(ticketsProvider);
final isrsAsync = ref.watch(itServiceRequestsProvider);
final passSlipsAsync = ref.watch(passSlipsProvider);
final sessions = (logsAsync.valueOrNull ?? [])
.where((l) =>
l.userId == effectiveUserId &&
_onDay(l.checkInAt, _dayStart, _dayEnd))
.toList();
final passSlips = (passSlipsAsync.valueOrNull ?? [])
.where((s) => s.userId == effectiveUserId)
.toList();
// ── Assignment-aware providers ──────────────────────────────────────────
final taskAssignmentsForUser = effectiveUserId == null
? const <TaskAssignment>[]
: ref
.watch(taskAssignmentsForUserProvider(effectiveUserId))
.valueOrNull ??
[];
final assignedTaskIds =
taskAssignmentsForUser.map((a) => a.taskId).toList()..sort();
final taskIdsKey = assignedTaskIds.join(',');
final allActivityLogs =
ref.watch(taskActivityLogsForTasksProvider(taskIdsKey)).valueOrNull ??
[];
final allIsrAssignments =
ref.watch(itServiceRequestAssignmentsProvider).valueOrNull ?? [];
final isrAssignmentsForUser =
allIsrAssignments.where((a) => a.userId == effectiveUserId).toList();
// Include creator-on-day tasks AND any task this user is assigned to
// (day filtering for assignee items is done inside _buildTimeline).
final assignedTaskIdSet =
taskAssignmentsForUser.map((a) => a.taskId).toSet();
final tasks = (tasksAsync.valueOrNull ?? [])
.where((t) =>
(t.creatorId == effectiveUserId &&
(_onDay(t.startedAt, _dayStart, _dayEnd) ||
_onDay(t.completedAt, _dayStart, _dayEnd) ||
_onDay(t.createdAt, _dayStart, _dayEnd))) ||
assignedTaskIdSet.contains(t.id))
.toList();
final tickets = (ticketsAsync.valueOrNull ?? [])
.where((t) =>
t.creatorId == effectiveUserId &&
_onDay(t.createdAt, _dayStart, _dayEnd))
.toList();
final isrAssignedIdSet =
isrAssignmentsForUser.map((a) => a.requestId).toSet();
final isrs = (isrsAsync.valueOrNull ?? [])
.where((r) =>
((r.creatorId == effectiveUserId ||
r.requestedByUserId == effectiveUserId) &&
_onDay(r.createdAt, _dayStart, _dayEnd)) ||
isrAssignedIdSet.contains(r.id))
.toList();
final isLoading =
logsAsync.isLoading && logsAsync.valueOrNull == null;
final items = _buildTimeline(
sessions: sessions,
passSlips: passSlips,
tasks: tasks,
tickets: tickets,
isrs: isrs,
dayStart: _dayStart,
dayEnd: _dayEnd,
effectiveUserId: effectiveUserId,
taskAssignments: taskAssignmentsForUser,
isrAssignments: isrAssignmentsForUser,
allActivityLogs: allActivityLogs,
);
final summary = _computeSummary(items);
final profiles = isAdmin
? ((ref.watch(profilesProvider).valueOrNull ?? [])
.where((p) => const {
'admin',
'dispatcher',
'programmer',
'it_staff',
}.contains(p.role))
.toList()
..sort((a, b) => a.fullName.compareTo(b.fullName)))
: <dynamic>[];
final personName = _resolvePersonName(currentProfile, profiles);
return Column(
children: [
// ── Date navigator ────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Card(
elevation: 0,
color: colors.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: Row(
children: [
IconButton.filledTonal(
icon: const Icon(Icons.chevron_left),
onPressed: _prevDay,
tooltip: 'Previous day',
),
Expanded(
child: GestureDetector(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime(2020),
lastDate:
DateTime.now().add(const Duration(days: 1)),
);
if (picked != null) {
setState(() => _selectedDate = picked);
}
},
child: Column(
children: [
Text(
AppTime.formatDate(_selectedDate),
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
Text(
_isToday(_selectedDate)
? 'Today'
: _weekday(_selectedDate),
style: theme.textTheme.labelMedium?.copyWith(
color: colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
),
IconButton(
icon: const Icon(Icons.share_rounded),
tooltip: 'Share as image',
onPressed: items.isEmpty
? null
: () => _captureAndShare(
context, items, summary, personName),
),
IconButton.filledTonal(
icon: const Icon(Icons.chevron_right),
onPressed:
_dayEnd.isAfter(DateTime.now()) ? null : _nextDay,
tooltip: 'Next day',
),
],
),
),
),
),
// ── Personnel selector (admin only) ───────────────────────────────
if (isAdmin && profiles.isNotEmpty) ...[
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Card(
elevation: 0,
color: colors.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
Icon(Icons.person_outline,
size: 18, color: colors.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: DropdownButton<String?>(
value: _selectedUserId,
isExpanded: true,
underline: const SizedBox.shrink(),
icon: Icon(Icons.expand_more,
size: 20, color: colors.onSurfaceVariant),
hint: Text(
currentProfile?.fullName ?? 'Select personnel',
style: theme.textTheme.bodyMedium,
),
items: [
DropdownMenuItem<String?>(
value: null,
child:
Text(currentProfile?.fullName ?? 'Me'),
),
...profiles.map((p) => DropdownMenuItem<String?>(
value: p.id as String,
child: Text(p.fullName as String),
)),
],
onChanged: (v) =>
setState(() => _selectedUserId = v),
),
),
],
),
),
),
),
],
const SizedBox(height: 4),
if (isLoading) const LinearProgressIndicator(minHeight: 2),
// ── Timeline + summary ────────────────────────────────────────────
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: items.isEmpty && !isLoading
? _EmptyDay(key: ValueKey(_selectedDate))
: ListView.builder(
key: ValueKey(_selectedDate),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
itemCount: items.length + 1,
itemBuilder: (ctx, i) {
if (i == items.length) {
return M3FadeSlideIn(
delay: Duration(
milliseconds:
(items.length.clamp(0, 15) * 45) + 100),
child: _SummaryCard(summary: summary),
);
}
return M3FadeSlideIn(
delay: Duration(
milliseconds: i.clamp(0, 15) * 45),
child: Opacity(
opacity: items[i].isVacant ? 0.7 : 1.0,
child: _WorkItemTile(
item: items[i],
isFirst: i == 0,
isLast: i == items.length - 1,
),
),
);
},
),
),
),
],
);
}
}
// ─── Reference number badge ───────────────────────────────────────────────────
class _RefBadge extends StatelessWidget {
const _RefBadge({
required this.number,
required this.bg,
required this.fg,
});
final String number;
final Color bg;
final Color fg;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(4),
),
child: Text(
number,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: fg,
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
),
),
);
}
}
// ─── Timeline tile ────────────────────────────────────────────────────────────
class _WorkItemTile extends StatelessWidget {
const _WorkItemTile({
required this.item,
required this.isFirst,
required this.isLast,
});
final _WorkItem item;
final bool isFirst;
final bool isLast;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final dotColor = _dotColor(colors);
// Markers show a single point in time; working segments show a range.
final timeLabel = item.isCreatorEvent
? AppTime.formatTime(item.start)
: item.end != null
? '${AppTime.formatTime(item.start)} ${AppTime.formatTime(item.end!)}'
: '${AppTime.formatTime(item.start)} ongoing';
final contentRow = Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CircleAvatar(
radius: 14,
backgroundColor: _avatarBg(colors),
child: Icon(_icon, size: 14, color: _avatarFg(colors)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (item.referenceNumber?.isNotEmpty == true)
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: _RefBadge(
number: item.referenceNumber!,
bg: _avatarBg(colors),
fg: _avatarFg(colors),
),
),
Text(
item.title,
style: textTheme.bodyMedium?.copyWith(
fontStyle: item.isVacant
? FontStyle.italic
: FontStyle.normal,
color: item.isVacant
? colors.onSurfaceVariant
: null,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (item.subtitle?.isNotEmpty == true)
Text(
item.subtitle!,
style: textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
timeLabel,
style: textTheme.labelSmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
),
if (!item.isCreatorEvent) ...[
const SizedBox(width: 8),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: _avatarBg(colors),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_fmtDur(item.duration),
style: textTheme.labelSmall?.copyWith(
color: _avatarFg(colors),
fontWeight: FontWeight.w600,
),
),
),
],
],
),
);
final contentContainer = item.isVacant
? CustomPaint(
painter: _DashedBorder(
color: colors.outline.withValues(alpha: 0.35),
),
child: DecoratedBox(
decoration: BoxDecoration(
color: colors.surfaceContainerHighest
.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(8),
),
child: contentRow,
),
)
: contentRow;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── Dot + connector line ─────────────────────────────────────
SizedBox(
width: 32,
child: Column(
children: [
// Fixed 16dp offset so the dot aligns with the avatar center.
const SizedBox(height: 16),
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: dotColor,
),
),
if (!isLast)
Expanded(
child: Container(
width: 2,
color: item.isVacant
? colors.outline.withValues(alpha: 0.25)
: colors.outlineVariant,
),
),
],
),
),
// ── Content ───────────────────────────────────────────────────
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 8, bottom: 8),
child: contentContainer,
),
),
],
),
);
}
Color _dotColor(ColorScheme c) {
if (item.isCreatorEvent && item.subtitle == 'Paused') return c.outline;
if (item.isCreatorEvent) return _avatarBg(c);
return switch (item.type) {
_WorkItemType.attendance => c.primary,
_WorkItemType.task => c.secondary,
_WorkItemType.ticket => c.secondaryContainer,
_WorkItemType.isr => c.tertiaryContainer,
_WorkItemType.passSlip => c.tertiary,
_WorkItemType.vacant => c.outline,
};
}
Color _avatarBg(ColorScheme c) => switch (item.type) {
_WorkItemType.attendance => c.primaryContainer,
_WorkItemType.task => c.secondaryContainer,
_WorkItemType.ticket => c.secondaryContainer,
_WorkItemType.isr => c.tertiaryContainer,
_WorkItemType.passSlip => c.tertiaryContainer,
_WorkItemType.vacant => c.surfaceContainerHighest,
};
Color _avatarFg(ColorScheme c) => switch (item.type) {
_WorkItemType.attendance => c.onPrimaryContainer,
_WorkItemType.task => c.onSecondaryContainer,
_WorkItemType.ticket => c.onSecondaryContainer,
_WorkItemType.isr => c.onTertiaryContainer,
_WorkItemType.passSlip => c.onTertiaryContainer,
_WorkItemType.vacant => c.onSurfaceVariant,
};
IconData get _icon {
if (item.isCreatorEvent) {
return switch (item.subtitle) {
'Assigned' => Icons.person_add_alt_1_rounded,
'Paused' => Icons.pause_circle_outline_rounded,
'Completed' => Icons.check_circle_rounded,
'Cancelled' => Icons.cancel_rounded,
_ => Icons.add_task_rounded, // Created, Requested, etc.
};
}
return switch (item.type) {
_WorkItemType.attendance => Icons.login_rounded,
_WorkItemType.task => Icons.task_alt_rounded,
_WorkItemType.ticket => Icons.confirmation_number_outlined,
_WorkItemType.isr => Icons.computer_outlined,
_WorkItemType.passSlip => Icons.badge_outlined,
_WorkItemType.vacant => Icons.hourglass_empty_rounded,
};
}
}
// ─── Summary card ─────────────────────────────────────────────────────────────
class _SummaryCard extends StatelessWidget {
const _SummaryCard({required this.summary});
final _WorkSummary summary;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final totalActive = summary.totalAttendance +
summary.totalTasks +
summary.totalTickets +
summary.totalIsrs +
summary.totalPassSlip;
final rows = [
(
label: 'Attendance',
dur: summary.totalAttendance,
bg: colors.primaryContainer,
fg: colors.onPrimaryContainer,
icon: Icons.login_rounded,
),
(
label: 'Tasks',
dur: summary.totalTasks,
bg: colors.secondaryContainer,
fg: colors.onSecondaryContainer,
icon: Icons.task_alt_rounded,
),
(
label: 'Tickets',
dur: summary.totalTickets,
bg: colors.secondaryContainer,
fg: colors.onSecondaryContainer,
icon: Icons.confirmation_number_outlined,
),
(
label: 'ISRs',
dur: summary.totalIsrs,
bg: colors.tertiaryContainer,
fg: colors.onTertiaryContainer,
icon: Icons.computer_outlined,
),
(
label: 'Pass Slips',
dur: summary.totalPassSlip,
bg: colors.tertiaryContainer,
fg: colors.onTertiaryContainer,
icon: Icons.badge_outlined,
),
(
label: 'Idle',
dur: summary.totalVacant,
bg: colors.surfaceContainerHighest,
fg: colors.onSurfaceVariant,
icon: Icons.hourglass_empty_rounded,
),
].where((r) => r.dur > Duration.zero).toList();
return Card.outlined(
margin: const EdgeInsets.only(top: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.summarize_rounded,
size: 16, color: colors.primary),
const SizedBox(width: 6),
Text('Day Summary', style: textTheme.titleSmall),
const Spacer(),
if (totalActive > Duration.zero)
Text(
'Active: ${_fmtDur(totalActive)}',
style: textTheme.labelMedium?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w600,
),
),
],
),
if (rows.isNotEmpty) ...[
const SizedBox(height: 8),
const Divider(height: 1),
const SizedBox(height: 4),
...rows.map(
(r) => Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(
children: [
CircleAvatar(
radius: 12,
backgroundColor: r.bg,
child: Icon(r.icon, size: 13, color: r.fg),
),
const SizedBox(width: 10),
Text(r.label, style: textTheme.bodyMedium),
const Spacer(),
Text(
_fmtDur(r.dur),
style: textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
),
),
],
],
),
),
);
}
}
// ─── Empty state ──────────────────────────────────────────────────────────────
class _EmptyDay extends StatelessWidget {
const _EmptyDay({super.key});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.event_busy_outlined,
size: 48,
color: colors.onSurfaceVariant.withValues(alpha: 0.4)),
const SizedBox(height: 12),
Text(
'No activity recorded for this day',
style: textTheme.bodyMedium
?.copyWith(color: colors.onSurfaceVariant),
),
],
),
);
}
}
// ─── Print / export view (off-screen rendering target) ───────────────────────
class _WorkLogPrintView extends StatelessWidget {
const _WorkLogPrintView({
required this.dateLabel,
required this.personName,
required this.items,
required this.summary,
});
final String dateLabel;
final String personName;
final List<_WorkItem> items;
final _WorkSummary summary;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Container(
color: colors.surface,
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Header
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Work Log',
style: textTheme.titleLarge
?.copyWith(color: colors.primary),
),
const SizedBox(height: 2),
Text(dateLabel, style: textTheme.titleMedium),
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.person_outline,
size: 14,
color: colors.onSurfaceVariant),
const SizedBox(width: 4),
Text(
personName,
style: textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
],
),
),
],
),
const SizedBox(height: 12),
Divider(color: colors.outlineVariant),
const SizedBox(height: 8),
// Timeline (no animations in export)
...items.asMap().entries.map((e) {
final i = e.key;
return Opacity(
opacity: e.value.isVacant ? 0.7 : 1.0,
child: _WorkItemTile(
item: e.value,
isFirst: i == 0,
isLast: i == items.length - 1,
),
);
}),
if (items.isNotEmpty) _SummaryCard(summary: summary),
const SizedBox(height: 12),
Divider(color: colors.outlineVariant),
const SizedBox(height: 4),
Align(
alignment: Alignment.centerRight,
child: Text(
'Generated by Tasq',
style: textTheme.labelSmall
?.copyWith(color: colors.onSurfaceVariant),
),
),
],
),
);
}
}