import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:intl/intl.dart'; import 'package:pdf/widgets.dart' as pw; import 'package:pdf/pdf.dart' as pdf; import 'package:printing/printing.dart'; import 'package:pdfrx/pdfrx.dart'; // ─── DTOs ───────────────────────────────────────────────────────────────── class DtrEntry { const DtrEntry({ required this.date, required this.shiftLabel, required this.checkInAt, this.checkOutAt, required this.checkIn, required this.checkOut, required this.duration, required this.isAbsent, required this.isLeave, required this.isLate, this.leaveType, this.justification, this.passSlips = const [], }); final DateTime date; final String shiftLabel; final DateTime checkInAt; final DateTime? checkOutAt; final String checkIn; final String checkOut; final String duration; final bool isAbsent; final bool isLeave; final bool isLate; final String? leaveType; final String? justification; final List passSlips; } class DtrPassSlipEntry { const DtrPassSlipEntry({ required this.reason, required this.timeRange, required this.status, }); final String reason; final String timeRange; final String status; } // ─── Public entry points ────────────────────────────────────────────────── void showDtrPdfDialog( BuildContext context, { required String employeeName, required String role, required DateTimeRange period, required List entries, }) { showDialog( context: context, builder: (_) => _DtrPdfDialog( title: 'DTR – $employeeName', filename: 'DTR - $employeeName.pdf', buildBytes: () => _buildPdf([(employeeName, role, entries)], period), ), ); } void showDtrPdfDialogMulti( BuildContext context, { required List<(String, String, List)> users, required DateTimeRange period, }) { showDialog( context: context, builder: (_) => _DtrPdfDialog( title: 'DTR Export (${users.length} employees)', filename: 'DTR Export.pdf', buildBytes: () => _buildPdf(users, period), ), ); } Future buildDtrPdfBytes({ required String employeeName, required String role, required DateTimeRange period, required List entries, }) => _buildPdf([(employeeName, role, entries)], period); // ─── PDF builder ────────────────────────────────────────────────────────── Future _buildPdf( List<(String, String, List)> users, DateTimeRange period, ) async { final logoData = await rootBundle.load('assets/crmc_logo.png'); final logoImage = 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'), ); final doc = pw.Document(); for (final (name, role, entries) in users) { // Sort ascending (oldest first) for a chronological time record. final sorted = [...entries]..sort((a, b) => a.date.compareTo(b.date)); doc.addPage( pw.MultiPage( pageFormat: pdf.PdfPageFormat.a4, margin: const pw.EdgeInsets.all(32), theme: pw.ThemeData.withFont(base: regular, bold: bold), footer: (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, ), ), ), build: (ctx) => [ _header(logoImage, regular, bold), pw.SizedBox(height: 8), pw.Center( child: pw.Text( 'DAILY TIME RECORD (DTR)', style: pw.TextStyle(font: bold, fontSize: 14, letterSpacing: 1.5), ), ), pw.SizedBox(height: 10), _employeeInfo(name, role, period, regular, bold), pw.SizedBox(height: 10), _attendanceTable(sorted, regular, bold), if (sorted.any((e) => e.passSlips.isNotEmpty)) ...[ pw.SizedBox(height: 8), _passSlipsSection(sorted, regular, bold), ], pw.SizedBox(height: 10), _summary(sorted, regular, bold), ], ), ); } return doc.save(); } // ─── Section builders ───────────────────────────────────────────────────── pw.Widget _header( pw.ImageProvider logo, pw.Font regular, pw.Font bold, ) { 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), ), ], ), ], ); } pw.Widget _employeeInfo( String name, String role, DateTimeRange period, pw.Font regular, pw.Font bold, ) { final fmt = DateFormat('MMMM d, yyyy'); final periodStr = '${fmt.format(period.start)} – ${fmt.format(period.end)}'; pw.Widget infoRow(String label, String value) => pw.RichText( text: pw.TextSpan( children: [ pw.TextSpan( text: '$label ', style: pw.TextStyle(font: bold, fontSize: 9), ), pw.TextSpan( text: value, style: pw.TextStyle(font: 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.Row( children: [ pw.Expanded( child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ infoRow('Name:', name), pw.SizedBox(height: 3), infoRow('Position:', _roleLabel(role)), ], ), ), pw.Expanded( child: infoRow('Period:', periodStr), ), ], ), ); } pw.Widget _attendanceTable( List entries, pw.Font regular, pw.Font bold, ) { final dayFmt = DateFormat('EEE'); final dateFmt = DateFormat('MMM d'); pw.Widget th(String text) => pw.Padding( padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: pw.Text( text, style: pw.TextStyle(font: 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: regular, fontSize: 8), ), ); String statusText(DtrEntry e) { if (e.isAbsent) return 'Absent'; if (e.isLeave) return _leaveTypeLabel(e.leaveType); if (e.isLate) return 'Late'; if (e.shiftLabel == 'Overtime') return 'Overtime'; if (e.duration == 'On duty') return 'Active'; return 'Present'; } pdf.PdfColor? rowColor(DtrEntry e) { if (e.isAbsent) return pdf.PdfColor.fromHex('#FFEBEE'); if (e.isLeave) return pdf.PdfColor.fromHex('#E3F2FD'); if (e.shiftLabel == 'Overtime') return pdf.PdfColor.fromHex('#FFF8E1'); if (e.isLate) return pdf.PdfColor.fromHex('#FFF3E0'); return null; } return pw.Table( border: pw.TableBorder.all(width: 0.5, color: pdf.PdfColors.grey400), columnWidths: const { 0: pw.FixedColumnWidth(56), 1: pw.FixedColumnWidth(28), 2: pw.FixedColumnWidth(72), 3: pw.FixedColumnWidth(60), 4: pw.FixedColumnWidth(60), 5: pw.FixedColumnWidth(50), 6: pw.FlexColumnWidth(), }, children: [ pw.TableRow( decoration: const pw.BoxDecoration(color: pdf.PdfColors.grey200), children: [ th('Date'), th('Day'), th('Shift'), th('Check-In'), th('Check-Out'), th('Hours'), th('Remarks'), ], ), for (final e in entries) pw.TableRow( decoration: pw.BoxDecoration(color: rowColor(e)), children: [ td(dateFmt.format(e.date)), td(dayFmt.format(e.date)), td(e.shiftLabel), td(e.checkIn), td(e.checkOut), td(e.duration), td(statusText(e)), ], ), ], ); } pw.Widget _passSlipsSection( List entries, pw.Font regular, pw.Font bold, ) { final dateFmt = DateFormat('MMM d (EEE)'); final slips = <(String, DtrPassSlipEntry)>[]; for (final e in entries) { for (final s in e.passSlips) { slips.add((dateFmt.format(e.date), s)); } } pw.Widget th(String text) => pw.Padding( padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 3), child: pw.Text(text, style: pw.TextStyle(font: bold, fontSize: 8)), ); pw.Widget td(String text) => pw.Padding( padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 2), child: pw.Text(text, style: pw.TextStyle(font: regular, fontSize: 7.5)), ); return pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Text( 'Pass Slips', style: pw.TextStyle(font: bold, fontSize: 9, letterSpacing: 0.5), ), pw.SizedBox(height: 4), pw.Table( border: pw.TableBorder.all(width: 0.5, color: pdf.PdfColors.grey400), columnWidths: const { 0: pw.FixedColumnWidth(88), 1: pw.FixedColumnWidth(88), 2: pw.FlexColumnWidth(), 3: pw.FixedColumnWidth(60), }, children: [ pw.TableRow( decoration: const pw.BoxDecoration(color: pdf.PdfColors.grey200), children: [ th('Date'), th('Time Range'), th('Reason'), th('Status'), ], ), for (final (date, slip) in slips) pw.TableRow( children: [ td(date), td(slip.timeRange), td(slip.reason), td(slip.status), ], ), ], ), ], ); } pw.Widget _summary(List entries, pw.Font regular, pw.Font bold) { final present = entries.where((e) => !e.isAbsent && !e.isLeave).length; final absent = entries.where((e) => e.isAbsent).length; final lates = entries.where((e) => e.isLate).length; final leaves = entries.where((e) => e.isLeave).toList(); final leaveByType = {}; for (final l in leaves) { final label = _leaveTypeLabel(l.leaveType); leaveByType[label] = (leaveByType[label] ?? 0) + 1; } final leaveSummary = leaveByType.isEmpty ? '${leaves.length} day(s)' : leaveByType.entries.map((e) => '${e.key}: ${e.value}').join(', '); final otEntries = entries.where((e) => e.shiftLabel == 'Overtime' && e.checkOutAt != null); final otMinutes = otEntries.fold( 0, (sum, e) => sum + e.checkOutAt!.difference(e.checkInAt).inMinutes, ); final otStr = otMinutes == 0 ? 'None' : '${otMinutes ~/ 60}h ${otMinutes % 60}m'; final passSlipTotal = entries.fold(0, (s, e) => s + e.passSlips.length); pw.Widget cell(String label, String value) => pw.Expanded( child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Text(label, style: pw.TextStyle(font: bold, fontSize: 8)), pw.SizedBox(height: 2), pw.Text(value, style: pw.TextStyle(font: 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: bold, fontSize: 9, letterSpacing: 1), ), pw.SizedBox(height: 6), pw.Row( children: [ cell('Days Present', '$present'), cell('Absences', '$absent'), cell('Lates', '$lates'), cell('Overtime', otStr), ], ), pw.SizedBox(height: 4), pw.Row( children: [ cell('Leaves', leaveSummary), cell('Pass Slips', '$passSlipTotal'), pw.Expanded(child: pw.SizedBox()), pw.Expanded(child: pw.SizedBox()), ], ), ], ), ); } // ─── Helpers ────────────────────────────────────────────────────────────── String _roleLabel(String role) { switch (role) { case 'admin': return 'Administrator'; case 'dispatcher': return 'Dispatcher'; case 'programmer': return 'Programmer'; case 'it_staff': return 'IT Staff'; default: return role.isEmpty ? 'Staff' : role; } } String _leaveTypeLabel(String? leaveType) { switch (leaveType) { case 'sick_leave': return 'Sick Leave'; case 'vacation_leave': return 'Vacation Leave'; case 'emergency_leave': return 'Emergency Leave'; case 'parental_leave': return 'Parental Leave'; default: return 'On Leave'; } } // ─── Dialog ─────────────────────────────────────────────────────────────── class _DtrPdfDialog extends StatefulWidget { const _DtrPdfDialog({ required this.title, required this.filename, required this.buildBytes, }); final String title; final String filename; final Future Function() buildBytes; @override State<_DtrPdfDialog> createState() => _DtrPdfDialogState(); } class _DtrPdfDialogState extends State<_DtrPdfDialog> { 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: 'dtr.pdf'); }, ), ), ], ), ), ); } }