635 lines
21 KiB
Dart
635 lines
21 KiB
Dart
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import '../../theme/m3_motion.dart';
|
|
import 'package:flutter/services.dart' show rootBundle;
|
|
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
|
import 'package:pdf/widgets.dart' as pw;
|
|
import 'package:pdf/pdf.dart' as pdf;
|
|
import 'package:printing/printing.dart';
|
|
import 'package:pdfrx/pdfrx.dart';
|
|
|
|
import '../../models/task.dart';
|
|
import '../../models/ticket.dart';
|
|
import '../../models/task_activity_log.dart';
|
|
import '../../models/task_assignment.dart';
|
|
import '../../models/profile.dart';
|
|
import '../../utils/app_time.dart';
|
|
|
|
Future<Uint8List> buildTaskPdfBytes(
|
|
Task task,
|
|
Ticket? ticket,
|
|
String officeName,
|
|
String serviceName,
|
|
List<TaskActivityLog> logs,
|
|
List<TaskAssignment> assignments,
|
|
List<Profile> profiles,
|
|
pdf.PdfPageFormat? format,
|
|
) async {
|
|
final logoData = await rootBundle.load('assets/crmc_logo.png');
|
|
final logoImage = pw.MemoryImage(logoData.buffer.asUint8List());
|
|
|
|
final regularFontData = await rootBundle.load(
|
|
'assets/fonts/Roboto-Regular.ttf',
|
|
);
|
|
final boldFontData = await rootBundle.load('assets/fonts/Roboto-Bold.ttf');
|
|
final regularFont = pw.Font.ttf(regularFontData);
|
|
final boldFont = pw.Font.ttf(boldFontData);
|
|
|
|
final doc = pw.Document();
|
|
final created = AppTime.formatDate(task.createdAt);
|
|
|
|
final descriptionText = ticket?.description ?? task.description;
|
|
|
|
String plainFromAction(String? at) {
|
|
if (at == null || at.trim().isEmpty) return '';
|
|
dynamic decoded = at;
|
|
for (var i = 0; i < 3; i++) {
|
|
if (decoded is String) {
|
|
try {
|
|
decoded = jsonDecode(decoded);
|
|
continue;
|
|
} catch (_) {
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (decoded is Map && decoded['ops'] is List) {
|
|
final ops = decoded['ops'] as List;
|
|
final buf = StringBuffer();
|
|
for (final op in ops) {
|
|
if (op is Map) {
|
|
final insert = op['insert'];
|
|
if (insert is String) {
|
|
buf.write(insert);
|
|
} else if (insert is Map && insert.containsKey('image')) {
|
|
buf.write('[image]');
|
|
} else {
|
|
buf.write(insert?.toString() ?? '');
|
|
}
|
|
} else {
|
|
buf.write(op.toString());
|
|
}
|
|
}
|
|
return buf.toString().trim();
|
|
}
|
|
|
|
if (decoded is List) {
|
|
try {
|
|
final doc = quill.Document.fromJson(decoded);
|
|
return doc.toPlainText().trim();
|
|
} catch (_) {
|
|
return decoded.join();
|
|
}
|
|
}
|
|
return decoded.toString();
|
|
}
|
|
|
|
final actionTakenText = plainFromAction(task.actionTaken);
|
|
final requestedBy = task.requestedBy ?? '';
|
|
final notedBy = task.notedBy ?? '';
|
|
final receivedBy = task.receivedBy ?? '';
|
|
final profileById = {for (final p in profiles) p.id: p};
|
|
final assignedForTask = assignments.where((a) => a.taskId == task.id).toList()
|
|
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
|
final assignedUserIds = {for (final a in assignedForTask) a.userId};
|
|
final performedBy = assignedUserIds.isEmpty
|
|
? ''
|
|
: assignedUserIds.map((id) => profileById[id]?.fullName ?? id).join(', ');
|
|
|
|
// Use MultiPage to avoid overflow on long content and apply embedded fonts via ThemeData
|
|
doc.addPage(
|
|
pw.MultiPage(
|
|
pageFormat: format ?? pdf.PdfPageFormat.a4,
|
|
margin: pw.EdgeInsets.all(28),
|
|
theme: pw.ThemeData.withFont(base: regularFont, bold: boldFont),
|
|
footer: (pw.Context ctx) => pw.Container(
|
|
alignment: pw.Alignment.centerRight,
|
|
child: pw.Text(
|
|
'MC-IHO-F-05 Rev. 2',
|
|
style: pw.TextStyle(fontSize: 9, color: pdf.PdfColors.grey),
|
|
),
|
|
),
|
|
build: (pw.Context ctx) => [
|
|
pw.Center(
|
|
child: pw.Row(
|
|
mainAxisAlignment: pw.MainAxisAlignment.center,
|
|
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
|
children: [
|
|
pw.Container(width: 80, height: 80, child: pw.Image(logoImage)),
|
|
pw.SizedBox(width: 16),
|
|
pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
|
children: [
|
|
pw.Text(
|
|
'Republic of the Philippines',
|
|
textAlign: pw.TextAlign.center,
|
|
),
|
|
pw.Text(
|
|
'Department of Health',
|
|
textAlign: pw.TextAlign.center,
|
|
),
|
|
pw.Text(
|
|
'Regional and Medical Center',
|
|
textAlign: pw.TextAlign.center,
|
|
),
|
|
pw.SizedBox(height: 6),
|
|
pw.Text(
|
|
'Cotabato Regional and Medical Center',
|
|
textAlign: pw.TextAlign.center,
|
|
style: pw.TextStyle(fontWeight: pw.FontWeight.bold),
|
|
),
|
|
pw.Text(
|
|
'Integrated Hospital Operations and Management Program',
|
|
textAlign: pw.TextAlign.center,
|
|
),
|
|
pw.Text('(IHOMP)', textAlign: pw.TextAlign.center),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
pw.SizedBox(height: 12),
|
|
pw.Center(
|
|
child: pw.Text(
|
|
'IT Job / Maintenance Request Form',
|
|
style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold),
|
|
),
|
|
),
|
|
pw.SizedBox(height: 12),
|
|
pw.Row(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
pw.Expanded(
|
|
child: pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
pw.Row(
|
|
children: [
|
|
pw.Text('Task Number: '),
|
|
pw.Container(
|
|
padding: pw.EdgeInsets.only(bottom: 2),
|
|
decoration: pw.BoxDecoration(
|
|
border: pw.Border(
|
|
bottom: pw.BorderSide(
|
|
width: 0.8,
|
|
color: pdf.PdfColors.black,
|
|
),
|
|
),
|
|
),
|
|
child: pw.Text(task.taskNumber ?? task.id),
|
|
),
|
|
],
|
|
),
|
|
pw.SizedBox(height: 8),
|
|
pw.Row(
|
|
children: [
|
|
pw.Text('Service: '),
|
|
pw.Container(
|
|
padding: pw.EdgeInsets.only(bottom: 2),
|
|
decoration: pw.BoxDecoration(
|
|
border: pw.Border(
|
|
bottom: pw.BorderSide(
|
|
width: 0.8,
|
|
color: pdf.PdfColors.black,
|
|
),
|
|
),
|
|
),
|
|
child: pw.Text(serviceName),
|
|
),
|
|
],
|
|
),
|
|
pw.SizedBox(height: 8),
|
|
pw.Row(
|
|
children: [
|
|
pw.Text('Type: '),
|
|
pw.Container(
|
|
padding: pw.EdgeInsets.only(bottom: 2),
|
|
decoration: pw.BoxDecoration(
|
|
border: pw.Border(
|
|
bottom: pw.BorderSide(
|
|
width: 0.8,
|
|
color: pdf.PdfColors.black,
|
|
),
|
|
),
|
|
),
|
|
child: pw.Text(task.requestType ?? ''),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
pw.SizedBox(width: 12),
|
|
pw.Container(
|
|
width: 180,
|
|
child: pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
pw.Row(
|
|
children: [
|
|
pw.Text('Filed At: '),
|
|
pw.Container(
|
|
padding: pw.EdgeInsets.only(bottom: 2),
|
|
decoration: pw.BoxDecoration(
|
|
border: pw.Border(
|
|
bottom: pw.BorderSide(
|
|
width: 0.8,
|
|
color: pdf.PdfColors.black,
|
|
),
|
|
),
|
|
),
|
|
child: pw.Text(created),
|
|
),
|
|
],
|
|
),
|
|
pw.SizedBox(height: 8),
|
|
pw.Row(
|
|
children: [
|
|
pw.Text('Office: '),
|
|
pw.Container(
|
|
padding: pw.EdgeInsets.only(bottom: 2),
|
|
decoration: pw.BoxDecoration(
|
|
border: pw.Border(
|
|
bottom: pw.BorderSide(
|
|
width: 0.8,
|
|
color: pdf.PdfColors.black,
|
|
),
|
|
),
|
|
),
|
|
child: pw.Text(officeName),
|
|
),
|
|
],
|
|
),
|
|
pw.SizedBox(height: 8),
|
|
pw.Row(
|
|
children: [
|
|
pw.Text('Category: '),
|
|
pw.Container(
|
|
padding: pw.EdgeInsets.only(bottom: 2),
|
|
decoration: pw.BoxDecoration(
|
|
border: pw.Border(
|
|
bottom: pw.BorderSide(
|
|
width: 0.8,
|
|
color: pdf.PdfColors.black,
|
|
),
|
|
),
|
|
),
|
|
child: pw.Text(task.requestCategory ?? ''),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
pw.SizedBox(height: 12),
|
|
pw.Divider(thickness: 0.8, color: pdf.PdfColors.grey),
|
|
pw.SizedBox(height: 6),
|
|
pw.Center(
|
|
child: pw.Text(
|
|
task.title,
|
|
textAlign: pw.TextAlign.center,
|
|
style: pw.TextStyle(fontWeight: pw.FontWeight.bold),
|
|
),
|
|
),
|
|
pw.SizedBox(height: 6),
|
|
pw.Divider(thickness: 0.8, color: pdf.PdfColors.grey),
|
|
pw.SizedBox(height: 6),
|
|
pw.Text('Description:'),
|
|
pw.SizedBox(height: 6),
|
|
pw.Container(
|
|
padding: pw.EdgeInsets.all(6),
|
|
child: pw.Text(descriptionText),
|
|
),
|
|
pw.SizedBox(height: 12),
|
|
// Requested/Noted signature lines (bottom-aligned to match Performed/Received)
|
|
pw.Row(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.end,
|
|
children: [
|
|
pw.Expanded(
|
|
child: pw.Container(
|
|
height: 56,
|
|
child: pw.Column(
|
|
mainAxisAlignment: pw.MainAxisAlignment.end,
|
|
children: [
|
|
pw.Container(height: 1, color: pdf.PdfColors.black),
|
|
pw.SizedBox(height: 6),
|
|
pw.Text(
|
|
requestedBy,
|
|
style: pw.TextStyle(fontWeight: pw.FontWeight.bold),
|
|
),
|
|
pw.Text('Requested By'),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
pw.SizedBox(width: 12),
|
|
pw.Expanded(
|
|
child: pw.Container(
|
|
height: 56,
|
|
child: pw.Column(
|
|
mainAxisAlignment: pw.MainAxisAlignment.end,
|
|
children: [
|
|
pw.Container(height: 1, color: pdf.PdfColors.black),
|
|
pw.SizedBox(height: 6),
|
|
pw.Text(
|
|
notedBy,
|
|
style: pw.TextStyle(fontWeight: pw.FontWeight.bold),
|
|
),
|
|
pw.Text('Noted by Supervisor/Senior'),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
pw.SizedBox(height: 12),
|
|
pw.Text('Action Taken:'),
|
|
pw.SizedBox(height: 6),
|
|
pw.Container(
|
|
padding: pw.EdgeInsets.all(6),
|
|
child: pw.Text(actionTakenText),
|
|
),
|
|
pw.SizedBox(height: 6),
|
|
pw.Divider(thickness: 0.8, color: pdf.PdfColors.grey),
|
|
pw.SizedBox(height: 12),
|
|
// Historical timestamps side-by-side: Created / Started / Closed
|
|
pw.Row(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
pw.Expanded(
|
|
child: pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
pw.Text('Created At:'),
|
|
pw.SizedBox(height: 4),
|
|
pw.Text(
|
|
'${AppTime.formatDate(task.createdAt)} ${AppTime.formatTime(task.createdAt)}',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
pw.SizedBox(width: 12),
|
|
pw.Expanded(
|
|
child: pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
pw.Text('Started At:'),
|
|
pw.SizedBox(height: 4),
|
|
pw.Text(
|
|
task.startedAt == null
|
|
? ''
|
|
: '${AppTime.formatDate(task.startedAt!)} ${AppTime.formatTime(task.startedAt!)}',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
pw.SizedBox(width: 12),
|
|
pw.Expanded(
|
|
child: pw.Column(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
|
children: [
|
|
pw.Text('Closed At:'),
|
|
pw.SizedBox(height: 4),
|
|
pw.Text(
|
|
task.completedAt == null
|
|
? ''
|
|
: '${AppTime.formatDate(task.completedAt!)} ${AppTime.formatTime(task.completedAt!)}',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
pw.SizedBox(height: 36),
|
|
|
|
// Wrap signature block to prevent awkward page breaks
|
|
pw.Wrap(
|
|
spacing: 6,
|
|
runSpacing: 6,
|
|
children: [
|
|
// Signature lines row (fixed)
|
|
pw.Row(
|
|
children: [
|
|
pw.Expanded(
|
|
child: pw.Container(
|
|
height: 24,
|
|
alignment: pw.Alignment.center,
|
|
child: pw.Container(height: 1, color: pdf.PdfColors.black),
|
|
),
|
|
),
|
|
pw.SizedBox(width: 12),
|
|
pw.Expanded(
|
|
child: pw.Container(
|
|
height: 24,
|
|
alignment: pw.Alignment.center,
|
|
child: pw.Container(height: 1, color: pdf.PdfColors.black),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
// Names row
|
|
pw.Row(
|
|
children: [
|
|
pw.Expanded(
|
|
child: pw.Container(
|
|
padding: pw.EdgeInsets.only(right: 6),
|
|
alignment: pw.Alignment.center,
|
|
child: pw.Text(
|
|
performedBy,
|
|
textAlign: pw.TextAlign.center,
|
|
style: pw.TextStyle(fontWeight: pw.FontWeight.bold),
|
|
),
|
|
),
|
|
),
|
|
pw.SizedBox(width: 12),
|
|
pw.Expanded(
|
|
child: pw.Container(
|
|
padding: pw.EdgeInsets.only(left: 6),
|
|
alignment: pw.Alignment.center,
|
|
child: pw.Text(
|
|
receivedBy,
|
|
textAlign: pw.TextAlign.center,
|
|
style: pw.TextStyle(fontWeight: pw.FontWeight.bold),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
// Labels row
|
|
pw.Row(
|
|
children: [
|
|
pw.Expanded(
|
|
child: pw.Text(
|
|
'Performed By',
|
|
textAlign: pw.TextAlign.center,
|
|
),
|
|
),
|
|
pw.SizedBox(width: 12),
|
|
pw.Expanded(
|
|
child: pw.Text('Received By', textAlign: pw.TextAlign.center),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
pw.SizedBox(height: 12),
|
|
],
|
|
),
|
|
);
|
|
|
|
return doc.save();
|
|
}
|
|
|
|
Future<void> showTaskPdfPreview(
|
|
BuildContext context,
|
|
Task task,
|
|
Ticket? ticket,
|
|
String officeName,
|
|
String serviceName,
|
|
List<TaskActivityLog> logs,
|
|
List<TaskAssignment> assignments,
|
|
List<Profile> profiles,
|
|
) async {
|
|
await m3ShowDialog<void>(
|
|
context: context,
|
|
builder: (ctx) => TaskPdfDialog(
|
|
task: task,
|
|
ticket: ticket,
|
|
officeName: officeName,
|
|
serviceName: serviceName,
|
|
logs: logs,
|
|
assignments: assignments,
|
|
profiles: profiles,
|
|
),
|
|
);
|
|
}
|
|
|
|
class TaskPdfDialog extends StatefulWidget {
|
|
final Task task;
|
|
final Ticket? ticket;
|
|
final String officeName;
|
|
final String serviceName;
|
|
final List<TaskActivityLog> logs;
|
|
final List<TaskAssignment> assignments;
|
|
final List<Profile> profiles;
|
|
|
|
const TaskPdfDialog({
|
|
super.key,
|
|
required this.task,
|
|
this.ticket,
|
|
required this.officeName,
|
|
required this.serviceName,
|
|
required this.logs,
|
|
required this.assignments,
|
|
required this.profiles,
|
|
});
|
|
|
|
@override
|
|
State<TaskPdfDialog> createState() => _TaskPdfDialogState();
|
|
}
|
|
|
|
class _TaskPdfDialogState extends State<TaskPdfDialog> {
|
|
Future<Uint8List>? _pdfFuture;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Initialize once
|
|
_pdfFuture = buildTaskPdfBytes(
|
|
widget.task,
|
|
widget.ticket,
|
|
widget.officeName,
|
|
widget.serviceName,
|
|
widget.logs,
|
|
widget.assignments,
|
|
widget.profiles,
|
|
null,
|
|
);
|
|
}
|
|
|
|
@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: [
|
|
const Expanded(
|
|
child: Text(
|
|
'IT Job Preview',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Print',
|
|
icon: const Icon(Icons.print),
|
|
onPressed: () async {
|
|
final bytes = await _pdfFuture;
|
|
if (bytes == null) return;
|
|
await Printing.layoutPdf(
|
|
onLayout: (_) async => bytes,
|
|
name:
|
|
'Task - ${widget.task.taskNumber ?? widget.task.id}.pdf',
|
|
);
|
|
},
|
|
),
|
|
IconButton(
|
|
tooltip: 'Download',
|
|
icon: const Icon(Icons.download),
|
|
onPressed: () async {
|
|
final bytes = await _pdfFuture;
|
|
if (bytes == null) return;
|
|
await Printing.sharePdf(
|
|
bytes: bytes,
|
|
filename:
|
|
'Task - ${widget.task.taskNumber ?? widget.task.id}.pdf',
|
|
);
|
|
},
|
|
),
|
|
IconButton(
|
|
tooltip: 'Close',
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(height: 1),
|
|
Expanded(
|
|
child: FutureBuilder<Uint8List>(
|
|
future: _pdfFuture,
|
|
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: 'task.pdf');
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|