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