From 425fe8e6838376bbdee6b020d29d7fd879bb9741 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sat, 16 May 2026 05:10:04 +0800 Subject: [PATCH] Weekly, Monthly and Custom Range Worklog --- lib/screens/attendance/work_log_pdf.dart | 321 ++++++++ lib/screens/attendance/work_log_tab.dart | 906 +++++++++++++++++++---- 2 files changed, 1089 insertions(+), 138 deletions(-) create mode 100644 lib/screens/attendance/work_log_pdf.dart diff --git a/lib/screens/attendance/work_log_pdf.dart b/lib/screens/attendance/work_log_pdf.dart new file mode 100644 index 00000000..5d5fc56c --- /dev/null +++ b/lib/screens/attendance/work_log_pdf.dart @@ -0,0 +1,321 @@ +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; + +// ─── DTOs ───────────────────────────────────────────────────────────────────── + +class WorkLogEntryDto { + const WorkLogEntryDto({ + required this.type, + required this.title, + this.subtitle, + required this.start, + this.end, + required this.isCreatorEvent, + required this.duration, + }); + + // 'attendance' | 'task' | 'ticket' | 'isr' | 'passSlip' | 'vacant' + final String type; + final String title; + final String? subtitle; + final DateTime start; + final DateTime? end; + final bool isCreatorEvent; + final Duration duration; +} + +class WorkLogSummaryDto { + const WorkLogSummaryDto({ + 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; + + Duration get totalActive => + totalAttendance + totalTasks + totalTickets + totalIsrs + totalPassSlip; +} + +class DailyWorkLogDto { + const DailyWorkLogDto({ + required this.date, + required this.entries, + required this.summary, + }); + + final DateTime date; + final List entries; + final WorkLogSummaryDto summary; + + bool get hasData => entries.any((e) => !e.isCreatorEvent); +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +String _fmt(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'; +} + +String _fmtTime(DateTime dt) => DateFormat('h:mm a').format(dt); + +Future _font(String path) async { + final data = await rootBundle.load(path); + return pw.Font.ttf(data); +} + +String _typeLabel(String type) => switch (type) { + 'attendance' => 'Attendance', + 'task' => 'Task', + 'ticket' => 'Ticket', + 'isr' => 'ISR', + 'passSlip' => 'Pass Slip', + 'vacant' => 'Vacant', + _ => type, + }; + +pw.TableRow _headerRow(pw.Font bold, List labels) => pw.TableRow( + decoration: const pw.BoxDecoration(color: PdfColors.grey200), + children: labels + .map((l) => pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text(l, style: pw.TextStyle(font: bold, fontSize: 8)), + )) + .toList(), + ); + +pw.Widget _cell(String text, {bool bold = false, pw.Font? boldFont}) => + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text( + text, + style: bold && boldFont != null + ? pw.TextStyle(font: boldFont, fontSize: 8) + : const pw.TextStyle(fontSize: 8), + ), + ); + +pw.TableRow _summaryRow( + String label, + Duration dur, { + bool isBold = false, + pw.Font? boldFont, +}) => + pw.TableRow(children: [ + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text( + label, + style: isBold && boldFont != null + ? pw.TextStyle(font: boldFont, fontSize: 9) + : const pw.TextStyle(fontSize: 9), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text( + _fmt(dur), + style: isBold && boldFont != null + ? pw.TextStyle(font: boldFont, fontSize: 9) + : const pw.TextStyle(fontSize: 9), + ), + ), + ]); + +// ─── Daily PDF ──────────────────────────────────────────────────────────────── + +Future buildDailyWorkLogPdfBytes({ + required String personName, + required DateTime date, + required List entries, + required WorkLogSummaryDto summary, +}) async { + final regular = await _font('assets/fonts/Roboto-Regular.ttf'); + final bold = await _font('assets/fonts/Roboto-Bold.ttf'); + final dateFmt = DateFormat('EEEE, MMMM d, yyyy'); + + final doc = pw.Document(); + doc.addPage(pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(32), + theme: pw.ThemeData.withFont(base: regular, bold: bold), + header: (ctx) => pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('Work Log', + style: pw.TextStyle(font: bold, fontSize: 18)), + pw.SizedBox(height: 2), + pw.Text(personName, style: const pw.TextStyle(fontSize: 12)), + pw.Text(dateFmt.format(date), + style: + const pw.TextStyle(fontSize: 11, color: PdfColors.grey700)), + pw.Divider(thickness: 0.5), + ], + ), + footer: (ctx) => pw.Align( + alignment: pw.Alignment.centerRight, + child: pw.Text( + 'Page ${ctx.pageNumber} of ${ctx.pagesCount} • Generated by Tasq', + style: + const pw.TextStyle(fontSize: 8, color: PdfColors.grey600), + ), + ), + build: (ctx) => [ + pw.Table( + border: pw.TableBorder.all(width: 0.4, color: PdfColors.grey400), + columnWidths: const { + 0: pw.FixedColumnWidth(68), + 1: pw.FixedColumnWidth(68), + 2: pw.FixedColumnWidth(58), + 3: pw.FlexColumnWidth(), + 4: pw.FixedColumnWidth(44), + }, + children: [ + _headerRow(bold, ['Start', 'End', 'Type', 'Description', 'Duration']), + for (final e in entries.where((e) => !e.isCreatorEvent)) + pw.TableRow(children: [ + _cell(_fmtTime(e.start)), + _cell(e.end != null ? _fmtTime(e.end!) : '—'), + _cell(_typeLabel(e.type)), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text(e.title, + style: const pw.TextStyle(fontSize: 8)), + if (e.subtitle != null) + pw.Text(e.subtitle!, + style: const pw.TextStyle( + fontSize: 7, color: PdfColors.grey700)), + ], + ), + ), + _cell(_fmt(e.duration)), + ]), + ], + ), + pw.SizedBox(height: 12), + pw.Text('Summary', + style: pw.TextStyle(font: bold, fontSize: 11)), + pw.SizedBox(height: 4), + pw.Table( + border: pw.TableBorder.all(width: 0.4, color: PdfColors.grey400), + columnWidths: const { + 0: pw.FlexColumnWidth(), + 1: pw.FixedColumnWidth(60), + }, + children: [ + if (summary.totalAttendance > Duration.zero) + _summaryRow('Attendance', summary.totalAttendance, boldFont: bold), + if (summary.totalTasks > Duration.zero) + _summaryRow('Tasks', summary.totalTasks, boldFont: bold), + if (summary.totalTickets > Duration.zero) + _summaryRow('Tickets', summary.totalTickets, boldFont: bold), + if (summary.totalIsrs > Duration.zero) + _summaryRow('IT Service Requests', summary.totalIsrs, boldFont: bold), + if (summary.totalPassSlip > Duration.zero) + _summaryRow('Pass Slips', summary.totalPassSlip, boldFont: bold), + _summaryRow('Total Active', summary.totalActive, + isBold: true, boldFont: bold), + ], + ), + ], + )); + return doc.save(); +} + +// ─── Multi-day Summary PDF ──────────────────────────────────────────────────── + +Future buildMultiDayWorkLogPdfBytes({ + required String personName, + required DateTime rangeStart, + required DateTime rangeEnd, + required String modeLabel, + required List days, +}) async { + final regular = await _font('assets/fonts/Roboto-Regular.ttf'); + final bold = await _font('assets/fonts/Roboto-Bold.ttf'); + final dateFmt = DateFormat('MMM d, yyyy'); + final dayFmt = DateFormat('EEE, MMM d'); + + final doc = pw.Document(); + doc.addPage(pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(32), + theme: pw.ThemeData.withFont(base: regular, bold: bold), + header: (ctx) => pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text('Work Log — $modeLabel', + style: pw.TextStyle(font: bold, fontSize: 18)), + pw.SizedBox(height: 2), + pw.Text(personName, style: const pw.TextStyle(fontSize: 12)), + pw.Text( + '${dateFmt.format(rangeStart)} – ' + '${dateFmt.format(rangeEnd.subtract(const Duration(days: 1)))}', + style: + const pw.TextStyle(fontSize: 11, color: PdfColors.grey700), + ), + pw.Divider(thickness: 0.5), + ], + ), + footer: (ctx) => pw.Align( + alignment: pw.Alignment.centerRight, + child: pw.Text( + 'Page ${ctx.pageNumber} of ${ctx.pagesCount} • Generated by Tasq', + style: + const pw.TextStyle(fontSize: 8, color: PdfColors.grey600), + ), + ), + build: (ctx) => [ + pw.Table( + border: pw.TableBorder.all(width: 0.4, color: PdfColors.grey400), + columnWidths: const { + 0: pw.FixedColumnWidth(80), + 1: pw.FixedColumnWidth(54), + 2: pw.FixedColumnWidth(40), + 3: pw.FixedColumnWidth(40), + 4: pw.FixedColumnWidth(36), + 5: pw.FixedColumnWidth(46), + 6: pw.FixedColumnWidth(38), + 7: pw.FixedColumnWidth(48), + }, + children: [ + _headerRow(bold, + ['Date', 'Attend.', 'Tasks', 'Tickets', 'ISR', 'Pass Slip', 'Idle', 'Total']), + for (final day in days) + pw.TableRow( + decoration: day.hasData + ? null + : const pw.BoxDecoration(color: PdfColors.grey100), + children: [ + _cell(dayFmt.format(day.date)), + _cell(day.hasData ? _fmt(day.summary.totalAttendance) : '—'), + _cell(day.hasData ? _fmt(day.summary.totalTasks) : '—'), + _cell(day.hasData ? _fmt(day.summary.totalTickets) : '—'), + _cell(day.hasData ? _fmt(day.summary.totalIsrs) : '—'), + _cell(day.hasData ? _fmt(day.summary.totalPassSlip) : '—'), + _cell(day.hasData ? _fmt(day.summary.totalVacant) : '—'), + _cell(day.hasData ? _fmt(day.summary.totalActive) : '—'), + ], + ), + ], + ), + ], + )); + return doc.save(); +} diff --git a/lib/screens/attendance/work_log_tab.dart b/lib/screens/attendance/work_log_tab.dart index c68bc8d4..2af935be 100644 --- a/lib/screens/attendance/work_log_tab.dart +++ b/lib/screens/attendance/work_log_tab.dart @@ -1,8 +1,11 @@ +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'; @@ -21,6 +24,7 @@ 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 ──────────────────────────────────────────────────────────── @@ -75,6 +79,24 @@ class _WorkSummary { 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) { @@ -427,11 +449,9 @@ class _WorkLogTabState extends ConsumerState { 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))); + _ViewMode _viewMode = _ViewMode.daily; + DateTimeRange? _customRange; + DateTime? _drilledDate; DateTime get _dayStart => DateTime(_selectedDate.year, _selectedDate.month, _selectedDate.day); @@ -441,6 +461,120 @@ class _WorkLogTabState extends ConsumerState { 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 _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; @@ -551,6 +685,228 @@ class _WorkLogTabState extends ConsumerState { }); } + Future _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 sessions, + List passSlips, + List tasks, + List tickets, + List isrs, + }) _filterDataForDay( + DateTime dayStart, + DateTime dayEnd, + String effectiveUserId, + List allLogs, + List allPassSlips, + List allTasks, + List allTickets, + List allIsrs, + Set assignedTaskIdSet, + Set 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 allLogs, + List allPassSlips, + List allTasks, + List allTickets, + List allIsrs, + List allActivityLogs, + List taskAssignments, + List isrAssignments, + Set assignedTaskIdSet, + Set 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 _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); @@ -569,15 +925,12 @@ class _WorkLogTabState extends ConsumerState { 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(); + // 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 @@ -599,54 +952,59 @@ class _WorkLogTabState extends ConsumerState { 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; + // ── 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: sessions, - passSlips: passSlips, - tasks: tasks, - tickets: tickets, - isrs: isrs, - dayStart: _dayStart, - dayEnd: _dayEnd, + 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 { @@ -663,74 +1021,153 @@ class _WorkLogTabState extends ConsumerState { return Column( children: [ - // ── Date navigator ──────────────────────────────────────────────── + // ── Mode selector + 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', - ), + 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', + ), + ], + ), + ), + ), + ], ), ), @@ -793,35 +1230,81 @@ class _WorkLogTabState extends ConsumerState { 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, - ), + 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, + ), + ), + ); + }, + )), ), ), ], @@ -1228,6 +1711,153 @@ class _EmptyDay extends StatelessWidget { } } +// ─── 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 {