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:
@@ -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 ?? '____________________',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user