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