feat(report): accomplishment report PDF + shared PDF helpers

- lib/utils/pdf/pdf_letterhead.dart: PdfBrand with load(), letterhead(),
  theme(), and pageFooter() shared across CRMC PDF documents
- lib/widgets/pdf_preview_dialog.dart: showPdfPreviewDialog() with
  pdfrx PdfViewer.data preview, Print (Printing.layoutPdf) and
  Download (Printing.sharePdf) buttons
- lib/screens/programmer_tasks/report/accomplishment_report_pdf.dart:
  buildAccomplishmentReportPdf() — A4 MultiPage with letterhead, info
  box, accomplishment table, footnote, summary, signatures, page footer
- test/accomplishment_report_pdf_test.dart: 2 passing tests

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-09-28 18:55:45 +08:00
parent 87014d1656
commit 167e208ffa
4 changed files with 579 additions and 0 deletions
@@ -0,0 +1,287 @@
import 'dart:typed_data';
import 'package:intl/intl.dart';
import 'package:pdf/pdf.dart' as pdf;
import 'package:pdf/widgets.dart' as pw;
import 'package:tasq/screens/programmer_tasks/report/accomplishment_report_data.dart';
import 'package:tasq/utils/pdf/pdf_letterhead.dart';
import 'package:tasq/utils/programmer_task_ledger.dart';
/// Build an A4 Accomplishment Report PDF from [data].
Future<Uint8List> buildAccomplishmentReportPdf(
AccomplishmentReportData data) async {
final brand = await PdfBrand.load();
final doc = pw.Document();
doc.addPage(
pw.MultiPage(
pageFormat: pdf.PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(32),
theme: brand.theme(),
footer: brand.pageFooter,
build: (ctx) => [
brand.letterhead(),
pw.SizedBox(height: 8),
pw.Center(
child: pw.Text(
'ACCOMPLISHMENT REPORT',
style: pw.TextStyle(
font: brand.bold, fontSize: 14, letterSpacing: 1.5),
),
),
pw.SizedBox(height: 10),
_infoBox(data, brand),
pw.SizedBox(height: 10),
_reportTable(data, brand),
if (data.excludedPending + data.excludedDisapproved > 0)
_footnote(data, brand),
pw.SizedBox(height: 12),
_summary(data, brand),
pw.SizedBox(height: 16),
_signatures(data, brand),
],
),
);
return doc.save();
}
// ─── Section builders ─────────────────────────────────────────────────────────
pw.Widget _infoBox(AccomplishmentReportData data, PdfBrand brand) {
final fmt = DateFormat('MMMM d, yyyy');
final periodStr =
'${fmt.format(data.periodStart)} – ${fmt.format(data.periodEndInclusive)}';
pw.Widget infoRow(String label, String value) => pw.RichText(
text: pw.TextSpan(
children: [
pw.TextSpan(
text: '$label ',
style: pw.TextStyle(font: brand.bold, fontSize: 9),
),
pw.TextSpan(
text: value,
style: pw.TextStyle(font: brand.regular, fontSize: 9),
),
],
),
);
return pw.Container(
decoration: pw.BoxDecoration(
border: pw.Border.all(width: 0.8, color: pdf.PdfColors.grey400),
),
padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
infoRow('Name:', data.programmerName),
pw.SizedBox(height: 3),
infoRow('Position:', data.positionLabel),
pw.SizedBox(height: 3),
infoRow('Period:', periodStr),
],
),
);
}
pw.Widget _reportTable(AccomplishmentReportData data, PdfBrand brand) {
final dateFmt = DateFormat('MMM d, yyyy');
const colWidths = {
0: pw.FixedColumnWidth(80),
1: pw.FlexColumnWidth(2),
2: pw.FlexColumnWidth(3),
3: pw.FixedColumnWidth(50),
};
final border = pw.TableBorder.all(width: 0.5, color: pdf.PdfColors.grey400);
pw.Widget th(String text) => pw.Padding(
padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: pw.Text(
text,
style: pw.TextStyle(font: brand.bold, fontSize: 8),
),
);
pw.Widget td(String text) => pw.Padding(
padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 3),
child: pw.Text(
text,
style: pw.TextStyle(font: brand.regular, fontSize: 8),
),
);
final headerRow = pw.TableRow(
decoration: const pw.BoxDecoration(color: pdf.PdfColors.grey200),
children: [
th('DATE'),
th('TASK / PROJECT'),
th('ACCOMPLISHMENT'),
th('TIME'),
],
);
if (data.days.isEmpty) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
pw.Table(
border: border,
columnWidths: colWidths,
children: [headerRow],
),
pw.Container(
decoration: pw.BoxDecoration(
border: pw.Border(
left: const pw.BorderSide(width: 0.5, color: pdf.PdfColors.grey400),
right: const pw.BorderSide(width: 0.5, color: pdf.PdfColors.grey400),
bottom: const pw.BorderSide(width: 0.5, color: pdf.PdfColors.grey400),
),
),
padding: const pw.EdgeInsets.all(8),
child: pw.Text(
'No approved accomplishments in this period.',
style: pw.TextStyle(
font: brand.regular,
fontSize: 8,
color: pdf.PdfColors.grey700),
),
),
],
);
}
final rows = <pw.TableRow>[headerRow];
for (final day in data.days) {
String dateLabel = dateFmt.format(day.day);
if (day.statusTag != null) {
dateLabel += '\n[${day.statusTag}]';
}
for (final row in day.rows) {
final taskProject = row.kind == 'helper'
? 'Helped ${row.helpedName ?? ''} — ${row.title}'
: (row.projectName != null && row.projectName!.isNotEmpty
? '${row.title}\n(${row.projectName})'
: row.title);
final accomplishment =
row.notes.isEmpty ? '(no notes)' : row.notes.join('\n');
final timeStr = formatDurationHm(Duration(seconds: row.seconds));
rows.add(pw.TableRow(children: [
td(dateLabel),
td(taskProject),
td(accomplishment),
td(timeStr),
]));
}
}
return pw.Table(
border: border,
columnWidths: colWidths,
children: rows,
);
}
pw.Widget _footnote(AccomplishmentReportData data, PdfBrand brand) {
final n = data.excludedPending + data.excludedDisapproved;
return pw.Padding(
padding: const pw.EdgeInsets.only(top: 4),
child: pw.Text(
'$n day(s) in this period are not included '
'(${data.excludedPending} pending, ${data.excludedDisapproved} disapproved).',
style: pw.TextStyle(font: brand.regular, fontSize: 8,
color: pdf.PdfColors.grey700),
),
);
}
pw.Widget _summary(AccomplishmentReportData data, PdfBrand brand) {
pw.Widget statRow(String label, String value) => pw.Row(
children: [
pw.Expanded(
child: pw.Text(label,
style: pw.TextStyle(font: brand.bold, fontSize: 9)),
),
pw.Text(value,
style: pw.TextStyle(font: brand.regular, fontSize: 9)),
],
);
return pw.Container(
decoration: pw.BoxDecoration(
border: pw.Border.all(width: 0.8, color: pdf.PdfColors.grey400),
color: pdf.PdfColors.grey100,
),
padding: const pw.EdgeInsets.all(8),
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'SUMMARY',
style: pw.TextStyle(font: brand.bold, fontSize: 9, letterSpacing: 1),
),
pw.SizedBox(height: 6),
statRow('Days reported:', '${data.daysReported}'),
statRow(
'Total time:', formatDurationHm(Duration(seconds: data.totalSeconds))),
statRow('Tasks completed:', '${data.tasksCompleted}'),
statRow(
'Help given:', formatDurationHm(Duration(seconds: data.helpSeconds))),
if (data.secondsByCategory.isNotEmpty) ...[
pw.SizedBox(height: 4),
pw.Text(
'Time by category:',
style: pw.TextStyle(font: brand.bold, fontSize: 9),
),
for (final entry in data.secondsByCategory.entries)
pw.Padding(
padding: const pw.EdgeInsets.only(left: 8, top: 2),
child: statRow(
'${entry.key}:',
formatDurationHm(Duration(seconds: entry.value)),
),
),
],
],
),
);
}
pw.Widget _signatures(AccomplishmentReportData data, PdfBrand brand) {
pw.Widget sig(String label, String value) => pw.RichText(
text: pw.TextSpan(
children: [
pw.TextSpan(
text: '$label ',
style: pw.TextStyle(font: brand.bold, fontSize: 9),
),
pw.TextSpan(
text: value,
style: pw.TextStyle(font: brand.regular, fontSize: 9),
),
],
),
);
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
sig(
'Prepared by:',
'${data.programmerName} (${data.positionLabel})',
),
pw.SizedBox(height: 8),
sig(
'Approved by:',
data.approverName ?? '____________________',
),
],
);
}