diff --git a/lib/screens/programmer_tasks/report/accomplishment_report_pdf.dart b/lib/screens/programmer_tasks/report/accomplishment_report_pdf.dart new file mode 100644 index 00000000..6db9343a --- /dev/null +++ b/lib/screens/programmer_tasks/report/accomplishment_report_pdf.dart @@ -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 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 = [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 ?? '____________________', + ), + ], + ); +} diff --git a/lib/utils/pdf/pdf_letterhead.dart b/lib/utils/pdf/pdf_letterhead.dart new file mode 100644 index 00000000..01807e74 --- /dev/null +++ b/lib/utils/pdf/pdf_letterhead.dart @@ -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 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, + ), + ), + ); +} diff --git a/lib/widgets/pdf_preview_dialog.dart b/lib/widgets/pdf_preview_dialog.dart new file mode 100644 index 00000000..973be866 --- /dev/null +++ b/lib/widgets/pdf_preview_dialog.dart @@ -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 showPdfPreviewDialog( + BuildContext context, { + required String title, + required String filename, + required Future Function() buildBytes, +}) { + return showDialog( + 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 Function() buildBytes; + + @override + State<_PdfPreviewDialog> createState() => _PdfPreviewDialogState(); +} + +class _PdfPreviewDialogState extends State<_PdfPreviewDialog> { + late final Future _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( + 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); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/test/accomplishment_report_pdf_test.dart b/test/accomplishment_report_pdf_test.dart new file mode 100644 index 00000000..a6179717 --- /dev/null +++ b/test/accomplishment_report_pdf_test.dart @@ -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')); + }); +}