diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index d5ae8986..e6e09be2 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -13,11 +13,8 @@ import 'dart:async'; import 'dart:convert'; import 'package:file_picker/file_picker.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; -import 'dart:typed_data'; -import 'package:flutter/services.dart' show rootBundle; -import 'package:pdf/widgets.dart' as pw; -import 'package:pdf/pdf.dart' as pdf; -import 'package:printing/printing.dart'; +import '../../providers/services_provider.dart'; +import 'task_pdf.dart'; import '../../providers/supabase_provider.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import '../../providers/profile_provider.dart'; @@ -361,7 +358,37 @@ class _TaskDetailScreenState extends ConsumerState tooltip: 'Preview/print task', onPressed: () async { try { - await _showPdfPreview(task, ticket, officeName); + final logsAsync = ref.read( + taskActivityLogsProvider(task.id), + ); + final logs = + logsAsync.valueOrNull ?? []; + final assignmentList = assignments; + final profilesList = + profilesAsync.valueOrNull ?? []; + final servicesAsync = ref.read(servicesProvider); + final servicesById = { + for (final s in servicesAsync.valueOrNull ?? []) + s.id: s, + }; + final serviceName = officeId == null + ? '' + : (officeById[officeId]?.serviceId == null + ? '' + : (servicesById[officeById[officeId]! + .serviceId] + ?.name ?? + '')); + await showTaskPdfPreview( + context, + task, + ticket, + officeName, + serviceName, + logs, + assignmentList, + profilesList, + ); } catch (_) {} }, icon: const Icon(Icons.print), @@ -2755,185 +2782,7 @@ class _TaskDetailScreenState extends ConsumerState ); } - Future _buildTaskPdfBytes( - Task task, - Ticket? ticket, - String officeName, - pdf.PdfPageFormat format, - ) async { - final logoData = await rootBundle.load('crmc_logo.png'); - final logoImage = pw.MemoryImage(logoData.buffer.asUint8List()); - final doc = pw.Document(); - final created = AppTime.formatDate(task.createdAt); - - doc.addPage( - pw.Page( - pageFormat: format, - margin: pw.EdgeInsets.all(28), - build: (pw.Context ctx) { - return pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Row( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Container( - width: 64, - height: 64, - child: pw.Image(logoImage), - ), - pw.SizedBox(width: 12), - pw.Expanded( - child: 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( - children: [ - pw.Text('Task Number: ${task.taskNumber ?? task.id}'), - pw.Spacer(), - pw.Text('Filed At: $created'), - ], - ), - pw.SizedBox(height: 8), - pw.Row( - children: [ - pw.Text('Service: ${task.title}'), - pw.SizedBox(width: 12), - pw.Text('Office: $officeName'), - ], - ), - pw.SizedBox(height: 8), - pw.Row( - children: [ - pw.Text('Type: ${task.requestType ?? ''}'), - pw.SizedBox(width: 12), - pw.Text('Category: ${task.requestCategory ?? ''}'), - ], - ), - pw.SizedBox(height: 12), - pw.Text('Description:'), - pw.SizedBox(height: 6), - pw.Container( - height: 80, - decoration: pw.BoxDecoration( - border: pw.Border( - bottom: pw.BorderSide( - width: 0.5, - color: pdf.PdfColors.grey, - ), - ), - ), - ), - pw.SizedBox(height: 12), - pw.Row( - children: [ - pw.Text('Requested By: ${task.requestedBy ?? ''}'), - pw.Spacer(), - pw.Text('Noted by Supervisor/Senior'), - ], - ), - pw.SizedBox(height: 12), - pw.Text('Action Taken:'), - pw.SizedBox(height: 6), - pw.Container( - height: 80, - decoration: pw.BoxDecoration( - border: pw.Border( - bottom: pw.BorderSide( - width: 0.5, - color: pdf.PdfColors.grey, - ), - ), - ), - ), - pw.SizedBox(height: 12), - pw.Row( - children: [ - pw.Text('Performed By:'), - pw.Spacer(), - pw.Text('Received By: ___________________________'), - ], - ), - ], - ); - }, - ), - ); - - return doc.save(); - } - - Future _showPdfPreview( - Task task, - Ticket? ticket, - String officeName, - ) async { - await showDialog( - context: context, - builder: (ctx) => AlertDialog( - contentPadding: const EdgeInsets.all(8), - content: SizedBox( - width: 700, - height: 900, - child: PdfPreview( - build: (format) => _buildTaskPdfBytes( - task, - ticket, - officeName, - format as pdf.PdfPageFormat, - ), - allowPrinting: true, - allowSharing: true, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(), - child: const Text('Close'), - ), - ], - ), - ); - } + // PDF preview/building moved to `task_pdf.dart`. } class _MetaBadge extends StatelessWidget { diff --git a/lib/screens/tasks/task_pdf.dart b/lib/screens/tasks/task_pdf.dart new file mode 100644 index 00000000..86899161 --- /dev/null +++ b/lib/screens/tasks/task_pdf.dart @@ -0,0 +1,316 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.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 '../../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 buildTaskPdfBytes( + Task task, + Ticket? ticket, + String officeName, + String serviceName, + List logs, + List assignments, + List profiles, + pdf.PdfPageFormat format, +) async { + final logoData = await rootBundle.load('crmc_logo.png'); + final logoImage = pw.MemoryImage(logoData.buffer.asUint8List()); + 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 latestAssignment = assignedForTask.isEmpty + ? null + : assignedForTask.last; + final performedBy = latestAssignment == null + ? '' + : (profileById[latestAssignment.userId]?.fullName ?? + latestAssignment.userId); + + doc.addPage( + pw.Page( + pageFormat: format, + margin: pw.EdgeInsets.all(28), + build: (pw.Context ctx) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Container(width: 64, height: 64, child: pw.Image(logoImage)), + pw.SizedBox(width: 12), + pw.Expanded( + child: 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( + children: [ + pw.Text('Task Number: ${task.taskNumber ?? task.id}'), + pw.Spacer(), + pw.Text('Filed At: $created'), + ], + ), + pw.SizedBox(height: 8), + pw.Row( + children: [ + pw.Text('Service: $serviceName'), + pw.SizedBox(width: 12), + pw.Text('Office: $officeName'), + ], + ), + pw.SizedBox(height: 8), + pw.Row( + children: [ + pw.Text('Type: ${task.requestType ?? ''}'), + pw.SizedBox(width: 12), + pw.Text('Category: ${task.requestCategory ?? ''}'), + ], + ), + pw.SizedBox(height: 12), + pw.Text('Task Title: ${task.title}'), + 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 + pw.Row( + children: [ + pw.Expanded( + child: pw.Column( + children: [ + pw.Container(height: 28), + pw.Container(height: 1, color: pdf.PdfColors.black), + pw.SizedBox(height: 6), + pw.Text(requestedBy), + pw.Text('Requested By'), + ], + ), + ), + pw.SizedBox(width: 12), + pw.Expanded( + child: pw.Column( + children: [ + pw.Container(height: 28), + pw.Container(height: 1, color: pdf.PdfColors.black), + pw.SizedBox(height: 6), + pw.Text(notedBy), + 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: 12), + pw.Text('History updates:'), + pw.SizedBox(height: 6), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + for (final log in logs) + pw.Row( + children: [ + pw.Text(AppTime.formatDate(log.createdAt)), + pw.SizedBox(width: 8), + pw.Text(AppTime.formatTime(log.createdAt)), + pw.SizedBox(width: 8), + pw.Text(log.actionType), + ], + ), + ], + ), + pw.SizedBox(height: 12), + // Performed/Received signature lines + pw.Row( + children: [ + pw.Expanded( + child: pw.Column( + children: [ + pw.Container(height: 28), + pw.Container(height: 1, color: pdf.PdfColors.black), + pw.SizedBox(height: 6), + pw.Text(performedBy), + pw.Text('Performed By'), + ], + ), + ), + pw.SizedBox(width: 12), + pw.Expanded( + child: pw.Column( + children: [ + pw.Container(height: 28), + pw.Container(height: 1, color: pdf.PdfColors.black), + pw.SizedBox(height: 6), + pw.Text(receivedBy), + pw.Text('Received By'), + ], + ), + ), + ], + ), + ], + ); + }, + ), + ); + + return doc.save(); +} + +Future showTaskPdfPreview( + BuildContext context, + Task task, + Ticket? ticket, + String officeName, + String serviceName, + List logs, + List assignments, + List profiles, +) async { + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + contentPadding: const EdgeInsets.all(8), + content: SizedBox( + width: 700, + height: 900, + child: PdfPreview( + build: (format) => buildTaskPdfBytes( + task, + ticket, + officeName, + serviceName, + logs, + assignments, + profiles, + format, + ), + allowPrinting: true, + allowSharing: true, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Close'), + ), + ], + ), + ); +}