322 lines
11 KiB
Dart
322 lines
11 KiB
Dart
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<WorkLogEntryDto> 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<pw.Font> _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<String> 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<Uint8List> buildDailyWorkLogPdfBytes({
|
||
required String personName,
|
||
required DateTime date,
|
||
required List<WorkLogEntryDto> 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<Uint8List> buildMultiDayWorkLogPdfBytes({
|
||
required String personName,
|
||
required DateTime rangeStart,
|
||
required DateTime rangeEnd,
|
||
required String modeLabel,
|
||
required List<DailyWorkLogDto> 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();
|
||
}
|