feat(reports): implement DepEd Transparency Board PDF export with live in-app preview

This commit is contained in:
ssbg04 committed 2026-08-31 21:56:13 +08:00
1 parent c41ecb0a40
commit 934bdae8a1
3 files changed
+928 -15

No files matched your search

@@ -0,0 +1,763 @@
import 'dart:typed_data';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import '../../domain/entities/report_models.dart';
class TransparencyBoardPdfService {
static Future<Uint8List> generatePdf({
required TransparencyBoardData data,
String? schoolName,
String? divisionName,
String? regionName,
}) async {
final pdf = pw.Document();
final years = data.years;
final latestYear = years.isNotEmpty ? years.last : null;
final previousYear = years.length > 1 ? years[years.length - 2] : null;
final hasPrev = previousYear != null;
final activeSyLabel = latestYear != null ? 'SY ${latestYear.yearRange}' : 'N/A';
final prevSyLabel = hasPrev ? 'SY ${previousYear.yearRange}' : 'Previous SY';
final generatedDate = DateTime.now().toString().substring(0, 19);
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.symmetric(horizontal: 28, vertical: 24),
header: (pw.Context context) => _buildPdfHeader(
activeSyLabel: activeSyLabel,
prevSyLabel: prevSyLabel,
hasPrev: hasPrev,
generatedDate: generatedDate,
schoolName: schoolName ?? 'TIAONG INTEGRATED SCHOOL',
divisionName: divisionName ?? 'DIVISION OF QUEZON',
regionName: regionName ?? 'REGION IV-A CALABARZON',
),
footer: (pw.Context context) => pw.Container(
alignment: pw.Alignment.centerRight,
margin: const pw.EdgeInsets.only(top: 8),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
'TIS-RMS DepEd Transparency Report',
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey600),
),
pw.Text(
'Page ${context.pageNumber} of ${context.pagesCount}',
style: const pw.TextStyle(fontSize: 8, color: PdfColors.grey600),
),
],
),
),
build: (pw.Context context) {
if (years.isEmpty || latestYear == null) {
return [
pw.Center(
child: pw.Text(
'No transparency board data available to generate report.',
style: const pw.TextStyle(fontSize: 12),
),
),
];
}
return [
pw.SizedBox(height: 12),
// ── Section 1: Data on Enrollment ────────────────────────────────
_buildSectionHeader('1. DATA ON ENROLLMENT'),
pw.SizedBox(height: 6),
_buildEnrollmentComparisonTable(
latestYear: latestYear,
previousYear: previousYear,
hasPrev: hasPrev,
prevLabel: prevSyLabel,
currentLabel: activeSyLabel,
),
pw.SizedBox(height: 14),
// ── Section 2: Dropouts & Transferees ────────────────────────────
_buildSectionHeader('2. DROPOUTS & TRANSFEREES'),
pw.SizedBox(height: 6),
pw.Text(
'A. Dropouts Summary',
style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold, color: PdfColors.red900),
),
pw.SizedBox(height: 4),
_buildMultiYearDropoutTable(years: years),
pw.SizedBox(height: 8),
pw.Text(
'B. Transferees Summary',
style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold, color: PdfColors.orange900),
),
pw.SizedBox(height: 4),
_buildMultiYearTransfereeTable(years: years),
pw.SizedBox(height: 14),
// ── Section 3: 4Ps Beneficiaries ──────────────────────────────────
_buildSectionHeader('3. 4Ps BENEFICIARIES'),
pw.SizedBox(height: 6),
_buildFourPsComparisonTable(
latestYear: latestYear,
previousYear: previousYear,
hasPrev: hasPrev,
prevLabel: prevSyLabel,
currentLabel: activeSyLabel,
),
pw.SizedBox(height: 24),
// ── Signatory Footer ─────────────────────────────────────────────
_buildSignatoryBlock(),
];
},
),
);
return pdf.save();
}
// ── Header Component ───────────────────────────────────────────────────────
static pw.Widget _buildPdfHeader({
required String activeSyLabel,
required String prevSyLabel,
required bool hasPrev,
required String generatedDate,
required String schoolName,
required String divisionName,
required String regionName,
}) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Text(
'Republic of the Philippines',
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700),
),
pw.Text(
'Department of Education',
style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold, color: PdfColors.black),
),
pw.Text(
'$regionName • $divisionName',
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey800),
),
pw.Text(
schoolName,
style: pw.TextStyle(fontSize: 13, fontWeight: pw.FontWeight.bold, color: PdfColors.green900),
),
pw.SizedBox(height: 4),
pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: pw.BoxDecoration(
color: PdfColors.green800,
borderRadius: const pw.BorderRadius.all(pw.Radius.circular(4)),
),
child: pw.Text(
'DEPED TRANSPARENCY & SCHOOL PERFORMANCE BOARD REPORT',
style: pw.TextStyle(
fontSize: 10,
fontWeight: pw.FontWeight.bold,
color: PdfColors.white,
letterSpacing: 0.4,
),
),
),
pw.SizedBox(height: 4),
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text(
hasPrev
? 'Reference Period: $prevSyLabel vs. $activeSyLabel'
: 'Reference Period: $activeSyLabel (Baseline)',
style: pw.TextStyle(fontSize: 8.5, fontWeight: pw.FontWeight.bold, color: PdfColors.grey800),
),
pw.Text(
'Date Generated: $generatedDate',
style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700),
),
],
),
pw.Divider(thickness: 1, color: PdfColors.grey400),
],
);
}
static pw.Widget _buildSectionHeader(String title) {
return pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: const pw.BoxDecoration(
color: PdfColors.grey200,
borderRadius: pw.BorderRadius.all(pw.Radius.circular(3)),
),
child: pw.Row(
children: [
pw.Text(
title,
style: pw.TextStyle(
fontSize: 10,
fontWeight: pw.FontWeight.bold,
color: PdfColors.green900,
),
),
],
),
);
}
// ── Section 1: Data on Enrollment Table ─────────────────────────────────────
static pw.Widget _buildEnrollmentComparisonTable({
required YearlyTransparencyItem latestYear,
required YearlyTransparencyItem? previousYear,
required bool hasPrev,
required String prevLabel,
required String currentLabel,
}) {
final border = const pw.TableBorder(
left: pw.BorderSide(color: PdfColors.grey300, width: 0.5),
right: pw.BorderSide(color: PdfColors.grey300, width: 0.5),
top: pw.BorderSide(color: PdfColors.grey300, width: 0.5),
bottom: pw.BorderSide(color: PdfColors.grey300, width: 0.5),
horizontalInside: pw.BorderSide(color: PdfColors.grey200, width: 0.5),
verticalInside: pw.BorderSide(color: PdfColors.grey200, width: 0.5),
);
return pw.Table(
border: border,
columnWidths: const {
0: pw.FlexColumnWidth(3.0),
1: pw.FlexColumnWidth(1.4),
2: pw.FlexColumnWidth(1.4),
3: pw.FlexColumnWidth(1.4),
4: pw.FlexColumnWidth(1.8),
},
children: [
// Header
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.green100),
children: [
_cellText('Key Stage / Grade Level', isHeader: true),
_cellText(prevLabel, isHeader: true, align: pw.TextAlign.center),
_cellText(currentLabel, isHeader: true, align: pw.TextAlign.center),
_cellText('Difference', isHeader: true, align: pw.TextAlign.center),
_cellText('Remarks', isHeader: true, align: pw.TextAlign.center),
],
),
// Key Stage 3 Header
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.grey100),
children: [
_cellText('KEY STAGE 3 (JUNIOR HIGH SCHOOL)', isBold: true),
_cellText(''),
_cellText(''),
_cellText(''),
_cellText(''),
],
),
// JHS Grades 7-10
...[7, 8, 9, 10].map((grade) {
final currRow = latestYear.enrollment.grades.firstWhere(
(g) => g.gradeLevel == grade,
orElse: () => GradeEnrollmentBreakdown(gradeLevel: grade, male: 0, female: 0, total: 0),
);
final prevRow = hasPrev
? previousYear!.enrollment.grades.firstWhere(
(g) => g.gradeLevel == grade,
orElse: () => GradeEnrollmentBreakdown(gradeLevel: grade, male: 0, female: 0, total: 0),
)
: null;
final curr = currRow.total;
final prev = prevRow?.total;
final diff = hasPrev ? (curr - (prev ?? 0)) : null;
return pw.TableRow(
children: [
_cellText(' Grade $grade'),
_cellText(hasPrev ? '$prev' : '—', align: pw.TextAlign.center),
_cellText('$curr', align: pw.TextAlign.center, isBold: true),
_cellDifference(diff, hasPrev),
_cellRemark(diff, hasPrev),
],
);
}),
// JHS Subtotal
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.grey100),
children: [
_cellText('Key Stage 3 (JHS) Subtotal', isBold: true),
_cellText(hasPrev ? '${previousYear!.enrollment.jhsTotal.total}' : '—', isBold: true, align: pw.TextAlign.center),
_cellText('${latestYear.enrollment.jhsTotal.total}', isBold: true, align: pw.TextAlign.center, textColor: PdfColors.green900),
_cellDifference(
hasPrev ? (latestYear.enrollment.jhsTotal.total - previousYear!.enrollment.jhsTotal.total) : null,
hasPrev,
isBold: true,
),
_cellRemark(
hasPrev ? (latestYear.enrollment.jhsTotal.total - previousYear!.enrollment.jhsTotal.total) : null,
hasPrev,
),
],
),
// Key Stage 4 Header
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.grey100),
children: [
_cellText('KEY STAGE 4 (SENIOR HIGH SCHOOL)', isBold: true),
_cellText(''),
_cellText(''),
_cellText(''),
_cellText(''),
],
),
// SHS Grades 11-12
...[11, 12].map((grade) {
final currRow = latestYear.enrollment.grades.firstWhere(
(g) => g.gradeLevel == grade,
orElse: () => GradeEnrollmentBreakdown(gradeLevel: grade, male: 0, female: 0, total: 0),
);
final prevRow = hasPrev
? previousYear!.enrollment.grades.firstWhere(
(g) => g.gradeLevel == grade,
orElse: () => GradeEnrollmentBreakdown(gradeLevel: grade, male: 0, female: 0, total: 0),
)
: null;
final curr = currRow.total;
final prev = prevRow?.total;
final diff = hasPrev ? (curr - (prev ?? 0)) : null;
return pw.TableRow(
children: [
_cellText(' Grade $grade'),
_cellText(hasPrev ? '$prev' : '—', align: pw.TextAlign.center),
_cellText('$curr', align: pw.TextAlign.center, isBold: true),
_cellDifference(diff, hasPrev),
_cellRemark(diff, hasPrev),
],
);
}),
// SHS Subtotal
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.grey100),
children: [
_cellText('Key Stage 4 (SHS) Subtotal', isBold: true),
_cellText(hasPrev ? '${previousYear!.enrollment.shsTotal.total}' : '—', isBold: true, align: pw.TextAlign.center),
_cellText('${latestYear.enrollment.shsTotal.total}', isBold: true, align: pw.TextAlign.center, textColor: PdfColors.green900),
_cellDifference(
hasPrev ? (latestYear.enrollment.shsTotal.total - previousYear!.enrollment.shsTotal.total) : null,
hasPrev,
isBold: true,
),
_cellRemark(
hasPrev ? (latestYear.enrollment.shsTotal.total - previousYear!.enrollment.shsTotal.total) : null,
hasPrev,
),
],
),
// Overall Total Row
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.green50),
children: [
_cellText('OVERALL ENROLLMENT TOTAL', isBold: true, textColor: PdfColors.green900),
_cellText(hasPrev ? '${previousYear!.enrollment.overallTotal.total}' : '—', isBold: true, align: pw.TextAlign.center),
_cellText('${latestYear.enrollment.overallTotal.total}', isBold: true, align: pw.TextAlign.center, textColor: PdfColors.green900),
_cellDifference(
hasPrev ? (latestYear.enrollment.overallTotal.total - previousYear!.enrollment.overallTotal.total) : null,
hasPrev,
isBold: true,
),
_cellRemark(
hasPrev ? (latestYear.enrollment.overallTotal.total - previousYear!.enrollment.overallTotal.total) : null,
hasPrev,
),
],
),
],
);
}
// ── Section 2: Dropouts & Transferees Tables ───────────────────────────────
static pw.Widget _buildMultiYearDropoutTable({required List<YearlyTransparencyItem> years}) {
final grades = [7, 8, 9, 10, 11, 12];
final colWidths = <int, pw.TableColumnWidth>{
0: const pw.FlexColumnWidth(2.5),
};
for (int i = 0; i < years.length; i++) {
colWidths[i + 1] = const pw.FlexColumnWidth(1.2);
}
return pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey300, width: 0.5),
columnWidths: colWidths,
children: [
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.red50),
children: [
_cellText('Grade Level', isHeader: true),
...years.map((y) => _cellText('SY ${y.yearRange}', isHeader: true, align: pw.TextAlign.center)),
],
),
...grades.map((grade) {
return pw.TableRow(
children: [
_cellText('Grade $grade'),
...years.map((y) {
final row = y.dropouts.grades.firstWhere(
(g) => g.gradeLevel == grade,
orElse: () => GradeDropoutCount(gradeLevel: grade, droppedCount: 0),
);
return _cellText('${row.droppedCount}', align: pw.TextAlign.center);
}),
],
);
}),
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.red100),
children: [
_cellText('Total Dropouts', isBold: true, textColor: PdfColors.red900),
...years.map((y) => _cellText('${y.dropouts.totalDropped}', isBold: true, align: pw.TextAlign.center, textColor: PdfColors.red900)),
],
),
],
);
}
static pw.Widget _buildMultiYearTransfereeTable({required List<YearlyTransparencyItem> years}) {
final grades = [7, 8, 9, 10, 11, 12];
final colWidths = <int, pw.TableColumnWidth>{
0: const pw.FlexColumnWidth(2.5),
};
for (int i = 0; i < years.length; i++) {
colWidths[i + 1] = const pw.FlexColumnWidth(1.2);
}
return pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey300, width: 0.5),
columnWidths: colWidths,
children: [
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.orange50),
children: [
_cellText('Grade Level', isHeader: true),
...years.map((y) => _cellText('SY ${y.yearRange}', isHeader: true, align: pw.TextAlign.center)),
],
),
...grades.map((grade) {
return pw.TableRow(
children: [
_cellText('Grade $grade'),
...years.map((y) {
final row = y.transferees.grades.firstWhere(
(g) => g.gradeLevel == grade,
orElse: () => GradeTransfereeCount(gradeLevel: grade, transferredCount: 0),
);
return _cellText('${row.transferredCount}', align: pw.TextAlign.center);
}),
],
);
}),
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.orange100),
children: [
_cellText('Total Transferees', isBold: true, textColor: PdfColors.orange900),
...years.map((y) => _cellText('${y.transferees.totalTransferred}', isBold: true, align: pw.TextAlign.center, textColor: PdfColors.orange900)),
],
),
],
);
}
// ── Section 3: 4Ps Beneficiaries Table ─────────────────────────────────────
static pw.Widget _buildFourPsComparisonTable({
required YearlyTransparencyItem latestYear,
required YearlyTransparencyItem? previousYear,
required bool hasPrev,
required String prevLabel,
required String currentLabel,
}) {
final border = const pw.TableBorder(
left: pw.BorderSide(color: PdfColors.grey300, width: 0.5),
right: pw.BorderSide(color: PdfColors.grey300, width: 0.5),
top: pw.BorderSide(color: PdfColors.grey300, width: 0.5),
bottom: pw.BorderSide(color: PdfColors.grey300, width: 0.5),
horizontalInside: pw.BorderSide(color: PdfColors.grey200, width: 0.5),
verticalInside: pw.BorderSide(color: PdfColors.grey200, width: 0.5),
);
return pw.Table(
border: border,
columnWidths: const {
0: pw.FlexColumnWidth(2.8),
1: pw.FlexColumnWidth(1.2),
2: pw.FlexColumnWidth(1.2),
3: pw.FlexColumnWidth(1.2),
4: pw.FlexColumnWidth(1.3),
5: pw.FlexColumnWidth(1.5),
},
children: [
// Header
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.purple50),
children: [
_cellText('Key Stage / Grade Level', isHeader: true),
_cellText(prevLabel, isHeader: true, align: pw.TextAlign.center),
_cellText(currentLabel, isHeader: true, align: pw.TextAlign.center),
_cellText('Difference', isHeader: true, align: pw.TextAlign.center),
_cellText('4Ps Share', isHeader: true, align: pw.TextAlign.center),
_cellText('Remarks', isHeader: true, align: pw.TextAlign.center),
],
),
// Key Stage 3 Header
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.grey100),
children: [
_cellText('KEY STAGE 3 (JUNIOR HIGH SCHOOL)', isBold: true),
_cellText(''),
_cellText(''),
_cellText(''),
_cellText(''),
_cellText(''),
],
),
// JHS Grades 7-10
...[7, 8, 9, 10].map((grade) {
final currRow = latestYear.fourPs.grades.firstWhere(
(g) => g.gradeLevel == grade,
orElse: () => Grade4PsCount(gradeLevel: grade, fourPsCount: 0, totalStudents: 0, percentage: 0.0),
);
final prevRow = hasPrev
? previousYear!.fourPs.grades.firstWhere(
(g) => g.gradeLevel == grade,
orElse: () => Grade4PsCount(gradeLevel: grade, fourPsCount: 0, totalStudents: 0, percentage: 0.0),
)
: null;
final curr = currRow.fourPsCount;
final prev = prevRow?.fourPsCount;
final diff = hasPrev ? (curr - (prev ?? 0)) : null;
return pw.TableRow(
children: [
_cellText(' Grade $grade'),
_cellText(hasPrev ? '$prev' : '—', align: pw.TextAlign.center),
_cellText('$curr', align: pw.TextAlign.center, isBold: true),
_cellDifference(diff, hasPrev),
_cellText('${currRow.percentage}%', align: pw.TextAlign.center, textColor: PdfColors.purple900, isBold: true),
_cellRemark(diff, hasPrev),
],
);
}),
// JHS Subtotal
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.grey100),
children: [
_cellText('Key Stage 3 (JHS) Subtotal', isBold: true),
_cellText(hasPrev ? '${previousYear!.fourPs.jhsTotal.fourPsCount}' : '—', isBold: true, align: pw.TextAlign.center),
_cellText('${latestYear.fourPs.jhsTotal.fourPsCount}', isBold: true, align: pw.TextAlign.center, textColor: PdfColors.purple900),
_cellDifference(
hasPrev ? (latestYear.fourPs.jhsTotal.fourPsCount - previousYear!.fourPs.jhsTotal.fourPsCount) : null,
hasPrev,
isBold: true,
),
_cellText('${latestYear.fourPs.jhsTotal.percentage}%', isBold: true, align: pw.TextAlign.center, textColor: PdfColors.purple900),
_cellRemark(
hasPrev ? (latestYear.fourPs.jhsTotal.fourPsCount - previousYear!.fourPs.jhsTotal.fourPsCount) : null,
hasPrev,
),
],
),
// Key Stage 4 Header
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.grey100),
children: [
_cellText('KEY STAGE 4 (SENIOR HIGH SCHOOL)', isBold: true),
_cellText(''),
_cellText(''),
_cellText(''),
_cellText(''),
_cellText(''),
],
),
// SHS Grades 11-12
...[11, 12].map((grade) {
final currRow = latestYear.fourPs.grades.firstWhere(
(g) => g.gradeLevel == grade,
orElse: () => Grade4PsCount(gradeLevel: grade, fourPsCount: 0, totalStudents: 0, percentage: 0.0),
);
final prevRow = hasPrev
? previousYear!.fourPs.grades.firstWhere(
(g) => g.gradeLevel == grade,
orElse: () => Grade4PsCount(gradeLevel: grade, fourPsCount: 0, totalStudents: 0, percentage: 0.0),
)
: null;
final curr = currRow.fourPsCount;
final prev = prevRow?.fourPsCount;
final diff = hasPrev ? (curr - (prev ?? 0)) : null;
return pw.TableRow(
children: [
_cellText(' Grade $grade'),
_cellText(hasPrev ? '$prev' : '—', align: pw.TextAlign.center),
_cellText('$curr', align: pw.TextAlign.center, isBold: true),
_cellDifference(diff, hasPrev),
_cellText('${currRow.percentage}%', align: pw.TextAlign.center, textColor: PdfColors.purple900, isBold: true),
_cellRemark(diff, hasPrev),
],
);
}),
// SHS Subtotal
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.grey100),
children: [
_cellText('Key Stage 4 (SHS) Subtotal', isBold: true),
_cellText(hasPrev ? '${previousYear!.fourPs.shsTotal.fourPsCount}' : '—', isBold: true, align: pw.TextAlign.center),
_cellText('${latestYear.fourPs.shsTotal.fourPsCount}', isBold: true, align: pw.TextAlign.center, textColor: PdfColors.purple900),
_cellDifference(
hasPrev ? (latestYear.fourPs.shsTotal.fourPsCount - previousYear!.fourPs.shsTotal.fourPsCount) : null,
hasPrev,
isBold: true,
),
_cellText('${latestYear.fourPs.shsTotal.percentage}%', isBold: true, align: pw.TextAlign.center, textColor: PdfColors.purple900),
_cellRemark(
hasPrev ? (latestYear.fourPs.shsTotal.fourPsCount - previousYear!.fourPs.shsTotal.fourPsCount) : null,
hasPrev,
),
],
),
// Overall Total Row
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.purple100),
children: [
_cellText('OVERALL 4Ps BENEFICIARIES TOTAL', isBold: true, textColor: PdfColors.purple900),
_cellText(hasPrev ? '${previousYear!.fourPs.overallTotal.fourPsCount}' : '—', isBold: true, align: pw.TextAlign.center),
_cellText('${latestYear.fourPs.overallTotal.fourPsCount}', isBold: true, align: pw.TextAlign.center, textColor: PdfColors.purple900),
_cellDifference(
hasPrev ? (latestYear.fourPs.overallTotal.fourPsCount - previousYear!.fourPs.overallTotal.fourPsCount) : null,
hasPrev,
isBold: true,
),
_cellText('${latestYear.fourPs.overallTotal.percentage}%', isBold: true, align: pw.TextAlign.center, textColor: PdfColors.purple900),
_cellRemark(
hasPrev ? (latestYear.fourPs.overallTotal.fourPsCount - previousYear!.fourPs.overallTotal.fourPsCount) : null,
hasPrev,
),
],
),
],
);
}
// ── Signatory Block ────────────────────────────────────────────────────────
static pw.Widget _buildSignatoryBlock() {
return pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
_signatoryColumn(
title: 'Prepared by:',
line: '________________________________',
role: 'Class Adviser / Guidance Counselor',
),
_signatoryColumn(
title: 'Verified by:',
line: '________________________________',
role: 'Planning Officer / Assistant Principal',
),
_signatoryColumn(
title: 'Approved by:',
line: '________________________________',
role: 'School Principal / Head Teacher',
),
],
);
}
static pw.Widget _signatoryColumn({
required String title,
required String line,
required String role,
}) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(title, style: const pw.TextStyle(fontSize: 8.5, color: PdfColors.grey700)),
pw.SizedBox(height: 28),
pw.Text(line, style: const pw.TextStyle(fontSize: 8.5)),
pw.SizedBox(height: 2),
pw.Text(role, style: pw.TextStyle(fontSize: 8, fontWeight: pw.FontWeight.bold, color: PdfColors.grey800)),
],
);
}
// ── Helper Cell Renderers ──────────────────────────────────────────────────
static pw.Widget _cellText(
String text, {
bool isHeader = false,
bool isBold = false,
pw.TextAlign align = pw.TextAlign.left,
PdfColor? textColor,
}) {
return pw.Padding(
padding: const pw.EdgeInsets.symmetric(horizontal: 5, vertical: 4),
child: pw.Text(
text,
textAlign: align,
style: pw.TextStyle(
fontSize: isHeader ? 8.5 : 8,
fontWeight: (isHeader || isBold) ? pw.FontWeight.bold : pw.FontWeight.normal,
color: textColor ?? (isHeader ? PdfColors.black : PdfColors.grey900),
),
),
);
}
static pw.Widget _cellDifference(int? diff, bool hasPrev, {bool isBold = false}) {
if (!hasPrev || diff == null) {
return _cellText('—', align: pw.TextAlign.center);
}
final text = diff > 0 ? '+$diff' : '$diff';
final color = diff > 0
? PdfColors.green800
: (diff < 0 ? PdfColors.red800 : PdfColors.grey700);
return _cellText(text, align: pw.TextAlign.center, isBold: true, textColor: color);
}
static pw.Widget _cellRemark(int? diff, bool hasPrev) {
if (!hasPrev || diff == null) {
return _cellText('Baseline', align: pw.TextAlign.center, textColor: PdfColors.grey600);
}
if (diff > 0) {
return _cellText('Increasing', align: pw.TextAlign.center, isBold: true, textColor: PdfColors.green800);
} else if (diff < 0) {
return _cellText('Decreasing', align: pw.TextAlign.center, isBold: true, textColor: PdfColors.red800);
} else {
return _cellText('Maintained', align: pw.TextAlign.center, textColor: PdfColors.grey700);
}
}
}
@@ -1,10 +1,16 @@
import 'dart:io';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/app_colors.dart';
import '../../../../core/constants/app_sizes.dart';
import '../../../../core/utils/download_service.dart';
import '../../../../core/utils/transparency_board_pdf_service.dart';
import '../../../../domain/entities/report_models.dart';
import '../../../providers/reports_provider.dart';
import '../../../shared/dialogs/error_dialog.dart';
import '../../../shared/dialogs/file_save_preview_dialog.dart';
import '../../../shared/dialogs/success_dialog.dart';
class TransparencyBoardSection extends ConsumerWidget {
const TransparencyBoardSection({super.key});
@@ -48,6 +54,7 @@ class _TransparencyBoardContent extends ConsumerStatefulWidget {
class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardContent> {
final ScrollController _enrollmentChartScrollController = ScrollController();
bool _isExportingPdf = false;
@override
void dispose() {
@@ -55,6 +62,115 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon
super.dispose();
}
Future<void> _handleExportPdf(
TransparencyBoardData data,
) async {
if (_isExportingPdf) return;
setState(() => _isExportingPdf = true);
try {
final latestYear = data.years.isNotEmpty ? data.years.last : null;
final yearLabel = latestYear != null
? latestYear.yearRange.replaceAll('-', '_')
: 'All';
final defaultFileName = 'DepEd_Transparency_Board_SY_$yearLabel.pdf';
final pdfBytes = await TransparencyBoardPdfService.generatePdf(
data: data,
schoolName: 'TIAONG INTEGRATED SCHOOL',
divisionName: 'DIVISION OF QUEZON',
regionName: 'REGION IV-A CALABARZON',
);
if (!mounted) return;
setState(() => _isExportingPdf = false);
String? savedPath;
final saved = await showFileSavePreviewDialog(
context,
fileName: defaultFileName,
fileType: SaveFileType.pdf,
fileBytes: pdfBytes,
previewRows: [
FilePreviewRow(
'School Year',
latestYear != null ? 'SY ${latestYear.yearRange}' : 'N/A',
),
const FilePreviewRow('Sections', '3 Key Sections'),
const FilePreviewRow('Format', 'Official DepEd PDF'),
],
onSave: (resolvedName) async {
await DownloadService.requestPermissions();
final dirPath = await DownloadService.getDownloadDirectoryPath();
final separator = Platform.isWindows ? '\\' : '/';
final targetPath = '$dirPath$separator$resolvedName';
final file = File(targetPath);
await file.writeAsBytes(pdfBytes);
savedPath = file.path;
},
);
if (!mounted) return;
if (saved == true && savedPath != null) {
showSuccessDialog(
context,
title: 'Export Successful',
message: 'Transparency Board PDF has been saved successfully.',
filePath: savedPath,
);
}
} catch (e) {
if (!mounted) return;
setState(() => _isExportingPdf = false);
showErrorDialog(
context,
'Export Failed',
'Failed to generate or save the PDF: $e',
);
}
}
Widget _buildExportPdfButton(
TransparencyBoardData data,
) {
return ElevatedButton.icon(
onPressed: _isExportingPdf
? null
: () => _handleExportPdf(data),
icon: _isExportingPdf
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(
Icons.picture_as_pdf_outlined,
size: 16,
color: Colors.white,
),
label: Text(
_isExportingPdf ? 'Exporting...' : 'Export PDF',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade700,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 0,
),
);
}
@override
Widget build(BuildContext context) {
final data = widget.data;
@@ -127,14 +243,21 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon
),
],
),
if (academicYears.isNotEmpty) ...[
const SizedBox(height: 8),
_buildYearSelector(
context,
academicYears: academicYears,
selectedYearId: selectedYearId,
),
],
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (academicYears.isNotEmpty)
_buildYearSelector(
context,
academicYears: academicYears,
selectedYearId: selectedYearId,
),
_buildExportPdfButton(data),
],
),
] else ...[
Row(
children: [
@@ -163,6 +286,8 @@ class _TransparencyBoardContentState extends ConsumerState<_TransparencyBoardCon
selectedYearId: selectedYearId,
),
],
const SizedBox(width: 8),
_buildExportPdfButton(data),
],
),
],
@@ -1,5 +1,7 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import '../../../core/constants/app_colors.dart';
import '../../../core/constants/app_sizes.dart';
import '../widgets/app_button_loader.dart';
@@ -349,13 +351,15 @@ class _FileSavePreviewDialogState extends State<_FileSavePreviewDialog> {
return switch (widget.fileType) {
SaveFileType.image => _buildImagePreview(isDark),
SaveFileType.excel => _buildExcelPreview(accent, isDark),
SaveFileType.pdf => _buildPlaceholderPreview(
accent,
Icons.picture_as_pdf_outlined,
'PDF Preview',
'PDF rendering requires an external viewer.\nThe file will open correctly after saving.',
isDark,
),
SaveFileType.pdf => widget.fileBytes != null && widget.fileBytes!.isNotEmpty
? _buildPdfPreview(isDark)
: _buildPlaceholderPreview(
accent,
Icons.picture_as_pdf_outlined,
'PDF Preview',
'PDF rendering requires an external viewer.\nThe file will open correctly after saving.',
isDark,
),
SaveFileType.word => _buildPlaceholderPreview(
accent,
Icons.description_outlined,
@@ -373,6 +377,27 @@ class _FileSavePreviewDialogState extends State<_FileSavePreviewDialog> {
};
}
/// Interactive live PDF preview.
Widget _buildPdfPreview(bool isDark) {
return Container(
decoration: BoxDecoration(
color: isDark ? AppColors.darkSurface2 : const Color(0xFF525659),
borderRadius: BorderRadius.circular(AppSizes.radiusMedium),
border: Border.all(
color: isDark ? AppColors.darkBorder : Colors.grey.shade300,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppSizes.radiusMedium),
child: SfPdfViewer.memory(
Uint8List.fromList(widget.fileBytes!),
canShowScrollHead: true,
canShowScrollStatus: true,
),
),
);
}
/// Full-size image preview.
Widget _buildImagePreview(bool isDark) {
if (widget.imageFile == null) {