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 ?? '____________________',
),
],
);
}
+81
View File
@@ -0,0 +1,81 @@
import 'package:flutter/services.dart' show rootBundle;
import 'package:pdf/pdf.dart' as pdf;
import 'package:pdf/widgets.dart' as pw;
/// Shared brand assets and letterhead widget for CRMC PDF documents.
class PdfBrand {
final pw.Font regular;
final pw.Font bold;
final pw.ImageProvider logo;
const PdfBrand({
required this.regular,
required this.bold,
required this.logo,
});
/// Load fonts and logo from the Flutter asset bundle.
static Future<PdfBrand> load() async {
final logoData = await rootBundle.load('assets/crmc_logo.png');
final logo = pw.MemoryImage(logoData.buffer.asUint8List());
final regular = pw.Font.ttf(
await rootBundle.load('assets/fonts/Roboto-Regular.ttf'),
);
final bold = pw.Font.ttf(
await rootBundle.load('assets/fonts/Roboto-Bold.ttf'),
);
return PdfBrand(regular: regular, bold: bold, logo: logo);
}
/// Standard CRMC letterhead: 60×60 logo + four-line organisation text.
pw.Widget letterhead() {
return pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.center,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Image(logo, width: 60, height: 60),
pw.SizedBox(width: 16),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Text(
'Republic of the Philippines',
style: pw.TextStyle(font: regular, fontSize: 9),
),
pw.Text(
'Department of Health',
style: pw.TextStyle(font: regular, fontSize: 9),
),
pw.Text(
'Cotabato Regional and Medical Center',
style: pw.TextStyle(font: regular, fontSize: 9),
),
pw.SizedBox(height: 3),
pw.Text(
'Integrated Hospital Operations and Management Program (IHOMP)',
style: pw.TextStyle(font: bold, fontSize: 9),
),
],
),
],
);
}
/// Convenience: create a ThemeData from these fonts.
pw.ThemeData theme() => pw.ThemeData.withFont(base: regular, bold: bold);
/// Footer widget: right-aligned "Page x of y".
pw.Widget pageFooter(pw.Context ctx) => pw.Align(
alignment: pw.Alignment.centerRight,
child: pw.Text(
'Page ${ctx.pageNumber} of ${ctx.pagesCount}',
style: pw.TextStyle(
font: regular,
fontSize: 8,
color: pdf.PdfColors.grey600,
),
),
);
}
+124
View File
@@ -0,0 +1,124 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:printing/printing.dart';
/// Show a PDF preview dialog with Print and Download buttons.
///
/// [buildBytes] is called once and cached; the result is shared between the
/// preview viewer and the action buttons.
Future<void> showPdfPreviewDialog(
BuildContext context, {
required String title,
required String filename,
required Future<Uint8List> Function() buildBytes,
}) {
return showDialog<void>(
context: context,
builder: (_) => _PdfPreviewDialog(
title: title,
filename: filename,
buildBytes: buildBytes,
),
);
}
class _PdfPreviewDialog extends StatefulWidget {
const _PdfPreviewDialog({
required this.title,
required this.filename,
required this.buildBytes,
});
final String title;
final String filename;
final Future<Uint8List> Function() buildBytes;
@override
State<_PdfPreviewDialog> createState() => _PdfPreviewDialogState();
}
class _PdfPreviewDialogState extends State<_PdfPreviewDialog> {
late final Future<Uint8List> _future;
@override
void initState() {
super.initState();
_future = widget.buildBytes();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
contentPadding: EdgeInsets.zero,
content: SizedBox(
width: 800,
height: 900,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Expanded(
child: Text(
widget.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
tooltip: 'Print',
icon: const Icon(Icons.print),
onPressed: () async {
final bytes = await _future;
await Printing.layoutPdf(
onLayout: (_) async => bytes,
name: widget.filename,
);
},
),
IconButton(
tooltip: 'Download',
icon: const Icon(Icons.download),
onPressed: () async {
final bytes = await _future;
await Printing.sharePdf(
bytes: bytes,
filename: widget.filename,
);
},
),
IconButton(
tooltip: 'Close',
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 1),
Expanded(
child: FutureBuilder<Uint8List>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text(snapshot.error.toString()));
}
final data = snapshot.data;
if (data == null) return const SizedBox.shrink();
return PdfViewer.data(data, sourceName: widget.filename);
},
),
),
],
),
),
);
}
}
+87
View File
@@ -0,0 +1,87 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:tasq/screens/programmer_tasks/report/accomplishment_report_data.dart';
import 'package:tasq/screens/programmer_tasks/report/accomplishment_report_pdf.dart';
import 'package:tasq/utils/programmer_day_rows.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
AccomplishmentReportData populated() => AccomplishmentReportData(
programmerName: 'John Doe',
positionLabel: 'Programmer',
periodStart: DateTime.utc(2026, 9, 1),
periodEndInclusive: DateTime.utc(2026, 9, 30),
days: [
ReportDay(
day: DateTime.utc(2026, 9, 1),
statusTag: null,
rows: [
DaySheetRow(
taskId: 't1',
title: 'Fix login bug',
category: 'Backend',
kind: 'assignee',
projectName: 'AuthService',
helpedName: null,
seconds: 3600,
notes: ['Resolved null pointer exception'],
),
],
),
ReportDay(
day: DateTime.utc(2026, 9, 2),
statusTag: 'Pending',
rows: [
DaySheetRow(
taskId: 't2',
title: 'Add unit tests',
category: 'Testing',
kind: 'helper',
projectName: null,
helpedName: 'Jane Smith',
seconds: 1800,
notes: [],
),
],
),
],
excludedPending: 1,
excludedDisapproved: 0,
daysReported: 2,
totalSeconds: 5400,
tasksCompleted: 1,
helpSeconds: 1800,
secondsByCategory: {'Backend': 3600, 'Testing': 1800},
approverName: 'Jane Reviewer',
);
AccomplishmentReportData emptyPeriod() => AccomplishmentReportData(
programmerName: 'John Doe',
positionLabel: 'Programmer',
periodStart: DateTime.utc(2026, 9, 1),
periodEndInclusive: DateTime.utc(2026, 9, 30),
days: [],
excludedPending: 0,
excludedDisapproved: 0,
daysReported: 0,
totalSeconds: 0,
tasksCompleted: 0,
helpSeconds: 0,
secondsByCategory: {},
approverName: null,
);
test('populated report builds non-empty PDF bytes starting with %PDF',
() async {
final bytes = await buildAccomplishmentReportPdf(populated());
expect(bytes.isNotEmpty, isTrue);
expect(utf8.decode(bytes.sublist(0, 4)), equals('%PDF'));
});
test('empty period builds a valid PDF', () async {
final bytes = await buildAccomplishmentReportPdf(emptyPeriod());
expect(bytes.isNotEmpty, isTrue);
expect(utf8.decode(bytes.sublist(0, 4)), equals('%PDF'));
});
}