import 'dart:io'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; import '../models/work_order.dart'; enum PdfReportType { client( label: 'Cliente', description: 'Incluye mano de obra, repuestos y valores.', fileSegment: 'cliente', ), certifier( label: 'Empresa certificadora', description: 'Omite repuestos y toda la información económica.', fileSegment: 'certificadora', ); const PdfReportType({ required this.label, required this.description, required this.fileSegment, }); final String label; final String description; final String fileSegment; } class PdfService { static const _reportFilePrefix = 'OT_v12_'; bool isCurrentReport(String filePath, {PdfReportType? type}) { final expectedPrefix = type == null ? _reportFilePrefix : '$_reportFilePrefix${type.fileSegment}_'; return p.basename(filePath).startsWith(expectedPrefix); } Future generate( WorkOrder order, { required PdfReportType type, }) async { final reportBytes = await build(order, type: type); final documents = await getApplicationDocumentsDirectory(); final reportDirectory = Directory( p.join(documents.path, 'ot_movil', 'reports'), ); await reportDirectory.create(recursive: true); final shortId = order.id.replaceAll('-', '').substring(0, 8); final reportPath = p.join( reportDirectory.path, '$_reportFilePrefix${type.fileSegment}_' '${DateFormat('yyyyMMdd_HHmm').format(DateTime.now())}_$shortId.pdf', ); await File(reportPath).writeAsBytes(reportBytes, flush: true); return reportPath; } Future build( WorkOrder order, { required PdfReportType type, }) async { final document = pw.Document( title: 'Orden de trabajo - ${order.title}', author: 'SMoya Gasfiter', subject: 'Informe de mantenimiento para ${type.label.toLowerCase()}', ); final logoData = await rootBundle.load( 'assets/pdf/simon_moya_report_logo.png', ); final logo = pw.MemoryImage( logoData.buffer.asUint8List( logoData.offsetInBytes, logoData.lengthInBytes, ), ); final signatureData = await rootBundle.load( 'assets/pdf/simon_moya_signature.png', ); final signature = pw.MemoryImage( signatureData.buffer.asUint8List( signatureData.offsetInBytes, signatureData.lengthInBytes, ), ); final taskPhotos = {}; for (final task in order.tasks) { taskPhotos[task.id] = await _loadTaskImages(task); } const primary = PdfColor.fromInt(0xFF0B5C5E); const dark = PdfColor.fromInt(0xFF163334); const muted = PdfColor.fromInt(0xFF5F6F70); const pale = PdfColor.fromInt(0xFFE7F1F0); final dateFormat = DateFormat('dd/MM/yyyy HH:mm'); final priceFormat = NumberFormat.decimalPattern('es_CL'); document.addPage( pw.MultiPage( pageTheme: pw.PageTheme( pageFormat: PdfPageFormat.a4, margin: const pw.EdgeInsets.fromLTRB(38, 42, 38, 42), ), header: (context) => _reportHeader( order, logo, type: type, primary: primary, dark: dark, muted: muted, ), footer: (context) => _reportFooter(context, muted), build: (context) => [ pw.SizedBox(height: 12), pw.Text( order.title, style: pw.TextStyle( color: dark, fontSize: 21, fontWeight: pw.FontWeight.bold, ), ), pw.SizedBox(height: 6), pw.Text( 'Orden de trabajo finalizada', style: const pw.TextStyle(color: primary, fontSize: 9), ), pw.SizedBox(height: 15), pw.Container( padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 9), decoration: pw.BoxDecoration( color: pale, borderRadius: pw.BorderRadius.circular(7), ), child: pw.Row( children: [ _summaryItem( 'Creada', dateFormat.format(order.createdAt.toLocal()), dark, muted, ), _summaryDivider(), _summaryItem( 'Finalizada', dateFormat.format( (order.completedAt ?? order.updatedAt).toLocal(), ), dark, muted, ), _summaryDivider(), _summaryItem( 'Contenido', '${order.tasks.length} tareas - ${order.totalPhotos} fotos', dark, muted, ), if (type == PdfReportType.client) ...[ _summaryDivider(), _summaryItem( 'Total OT', '\$${priceFormat.format(order.totalCost)}', dark, muted, ), ], ], ), ), pw.SizedBox(height: 20), ...order.tasks.expand( (task) => _taskWidgets( task, taskPhotos[task.id]!, primary: primary, dark: dark, muted: muted, pale: pale, priceFormat: priceFormat, includeParts: type == PdfReportType.client, ), ), if (type == PdfReportType.client) ...[ pw.SizedBox(height: 2), _costSummary( order, primary: primary, dark: dark, muted: muted, pale: pale, priceFormat: priceFormat, ), ], pw.SizedBox(height: 8), _signatureBlock(signature, dark: dark, muted: muted), ], ), ); return document.save(); } pw.Widget _reportHeader( WorkOrder order, pw.ImageProvider logo, { required PdfReportType type, required PdfColor primary, required PdfColor dark, required PdfColor muted, }) { return pw.Container( height: 78, padding: const pw.EdgeInsets.only(bottom: 10), decoration: pw.BoxDecoration( border: pw.Border(bottom: pw.BorderSide(color: primary, width: 1.5)), ), child: pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.SizedBox( width: 175, height: 67, child: pw.Image( logo, fit: pw.BoxFit.cover, alignment: pw.Alignment.center, ), ), pw.SizedBox( width: 245, child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.end, children: [ pw.Text( 'Informe para ${type.label.toLowerCase()}', style: pw.TextStyle(color: muted, fontSize: 9), ), pw.SizedBox(height: 7), _headerDetail( 'Cliente', order.details.client.isEmpty ? 'No informado' : order.details.client, dark, muted, ), pw.SizedBox(height: 3), _headerDetail( 'Dirección', order.details.address.isEmpty ? 'No informada' : order.details.address, dark, muted, ), ], ), ), ], ), ); } pw.Widget _reportFooter(pw.Context context, PdfColor muted) { return pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text( 'Generado por SMoya Gasfiter', style: pw.TextStyle(color: muted, fontSize: 8), ), pw.Text( 'Página ${context.pageNumber} de ${context.pagesCount}', style: pw.TextStyle(color: muted, fontSize: 8), ), ], ); } pw.Widget _headerDetail( String label, String value, PdfColor dark, PdfColor muted, ) { return pw.RichText( textAlign: pw.TextAlign.right, text: pw.TextSpan( children: [ pw.TextSpan( text: '${label.toUpperCase()}: ', style: pw.TextStyle( color: muted, fontSize: 6.5, fontWeight: pw.FontWeight.bold, letterSpacing: .35, ), ), pw.TextSpan( text: value, style: pw.TextStyle( color: dark, fontSize: 7.5, fontWeight: pw.FontWeight.bold, ), ), ], ), ); } pw.Widget _summaryItem( String label, String value, PdfColor dark, PdfColor muted, ) { return pw.Expanded( child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Text( label.toUpperCase(), style: pw.TextStyle( color: muted, fontSize: 6.5, fontWeight: pw.FontWeight.bold, letterSpacing: .55, ), ), pw.SizedBox(height: 3), pw.Text( value, style: pw.TextStyle( color: dark, fontSize: 8, fontWeight: pw.FontWeight.bold, ), ), ], ), ); } pw.Widget _summaryDivider() { return pw.Container( height: 24, margin: const pw.EdgeInsets.symmetric(horizontal: 8), decoration: const pw.BoxDecoration( border: pw.Border( left: pw.BorderSide(color: PdfColor.fromInt(0xFFB8CFCD)), ), ), ); } List _taskWidgets( WorkTask task, _TaskImages images, { required PdfColor primary, required PdfColor dark, required PdfColor muted, required PdfColor pale, required NumberFormat priceFormat, required bool includeParts, }) { return [ pw.NewPage(freeSpace: includeParts && task.parts.isNotEmpty ? 220 : 145), pw.Container( padding: const pw.EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: pw.BoxDecoration( color: dark, borderRadius: pw.BorderRadius.circular(5), ), child: pw.Row( children: [ pw.Container( width: 23, height: 23, alignment: pw.Alignment.center, decoration: pw.BoxDecoration( color: primary, shape: pw.BoxShape.circle, ), child: pw.Text( '${task.position + 1}', style: pw.TextStyle( color: PdfColors.white, fontSize: 10, fontWeight: pw.FontWeight.bold, ), ), ), pw.SizedBox(width: 9), pw.Expanded( child: pw.Text( task.title, style: pw.TextStyle( color: PdfColors.white, fontSize: 13, fontWeight: pw.FontWeight.bold, ), ), ), ], ), ), pw.SizedBox(height: 10), pw.Text( 'OBSERVACIÓN', style: pw.TextStyle( color: primary, fontSize: 8, fontWeight: pw.FontWeight.bold, letterSpacing: .8, ), ), pw.SizedBox(height: 4), pw.Container( width: double.infinity, padding: const pw.EdgeInsets.all(11), decoration: pw.BoxDecoration( color: pale, borderRadius: pw.BorderRadius.circular(4), ), child: pw.Text( task.observation, style: pw.TextStyle(color: dark, fontSize: 10, lineSpacing: 3), ), ), if (includeParts && task.parts.isNotEmpty) ...[ pw.SizedBox(height: 13), ..._partsWidgets( task.parts, task.partsTotal, primary: primary, dark: dark, muted: muted, pale: pale, priceFormat: priceFormat, ), ], pw.SizedBox(height: 15), ..._photoSection( 'ANTES DE LA MANTENCIÓN', images.before, primary: primary, muted: muted, ), pw.SizedBox(height: 14), ..._photoSection( 'DESPUÉS DE LA MANTENCIÓN', images.after, primary: primary, muted: muted, ), pw.SizedBox(height: 28), ]; } List _partsWidgets( List parts, int subtotal, { required PdfColor primary, required PdfColor dark, required PdfColor muted, required PdfColor pale, required NumberFormat priceFormat, }) { final widgets = [ pw.Row( children: [ pw.Text( 'REPUESTOS UTILIZADOS', style: pw.TextStyle( color: primary, fontSize: 8, fontWeight: pw.FontWeight.bold, letterSpacing: .7, ), ), pw.Spacer(), pw.Text( '${parts.length} ${parts.length == 1 ? 'repuesto' : 'repuestos'}', style: pw.TextStyle(color: muted, fontSize: 8), ), ], ), pw.SizedBox(height: 6), pw.Container( padding: const pw.EdgeInsets.symmetric(horizontal: 9, vertical: 6), decoration: pw.BoxDecoration( color: pale, borderRadius: pw.BorderRadius.circular(3), ), child: pw.Row( children: [ pw.Expanded( child: pw.Text( 'DESCRIPCIÓN', style: pw.TextStyle( color: muted, fontSize: 6.5, fontWeight: pw.FontWeight.bold, ), ), ), pw.SizedBox( width: 90, child: pw.Text( 'PRECIO', textAlign: pw.TextAlign.right, style: pw.TextStyle( color: muted, fontSize: 6.5, fontWeight: pw.FontWeight.bold, ), ), ), ], ), ), for (final part in parts) pw.Container( padding: const pw.EdgeInsets.symmetric(horizontal: 9, vertical: 6), decoration: const pw.BoxDecoration( border: pw.Border( bottom: pw.BorderSide( color: PdfColor.fromInt(0xFFD6E0DF), width: .6, ), ), ), child: pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Expanded( child: pw.Text( part.name, style: pw.TextStyle(color: dark, fontSize: 8), ), ), pw.SizedBox( width: 90, child: pw.Text( '\$${priceFormat.format(part.price)}', textAlign: pw.TextAlign.right, style: pw.TextStyle( color: dark, fontSize: 8, fontWeight: pw.FontWeight.bold, ), ), ), ], ), ), pw.Container( padding: const pw.EdgeInsets.only(top: 7), child: pw.Row( mainAxisAlignment: pw.MainAxisAlignment.end, children: [ pw.Text( 'Subtotal: ', style: pw.TextStyle(color: muted, fontSize: 8), ), pw.Text( '\$${priceFormat.format(subtotal)}', style: pw.TextStyle( color: primary, fontSize: 9, fontWeight: pw.FontWeight.bold, ), ), ], ), ), ]; if (parts.length <= 8) { return [ pw.Inseparable( child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.stretch, children: widgets, ), ), ]; } return widgets; } List _photoSection( String title, List images, { required PdfColor primary, required PdfColor muted, }) { final widgets = [ pw.Row( children: [ pw.Text( title, style: pw.TextStyle( color: primary, fontSize: 8, fontWeight: pw.FontWeight.bold, letterSpacing: .7, ), ), pw.SizedBox(width: 8), pw.Text( '${images.length} ${images.length == 1 ? 'foto' : 'fotos'}', style: pw.TextStyle(color: muted, fontSize: 8), ), ], ), pw.SizedBox(height: 7), ]; for (var index = 0; index < images.length; index += 3) { widgets.add( pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ for (var offset = 0; offset < 3; offset++) ...[ pw.Expanded( child: index + offset < images.length ? _pdfImage(images[index + offset]) : pw.SizedBox(), ), if (offset < 2) pw.SizedBox(width: 8), ], ], ), ); widgets.add(pw.SizedBox(height: 8)); } return widgets; } pw.Widget _costSummary( WorkOrder order, { required PdfColor primary, required PdfColor dark, required PdfColor muted, required PdfColor pale, required NumberFormat priceFormat, }) { pw.Widget costRow(String label, int value, {bool emphasize = false}) { return pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text( label, style: pw.TextStyle( color: emphasize ? dark : muted, fontSize: emphasize ? 10 : 9, fontWeight: emphasize ? pw.FontWeight.bold : pw.FontWeight.normal, ), ), pw.Text( '\$${priceFormat.format(value)}', style: pw.TextStyle( color: emphasize ? primary : dark, fontSize: emphasize ? 11 : 9, fontWeight: pw.FontWeight.bold, ), ), ], ); } return pw.Inseparable( child: pw.Container( width: double.infinity, padding: const pw.EdgeInsets.all(12), decoration: pw.BoxDecoration( color: pale, borderRadius: pw.BorderRadius.circular(6), ), child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.stretch, children: [ pw.Text( 'RESUMEN DE COSTOS', style: pw.TextStyle( color: primary, fontSize: 8, fontWeight: pw.FontWeight.bold, letterSpacing: .7, ), ), pw.SizedBox(height: 9), costRow('Costo de mano de obra', order.laborCost), pw.SizedBox(height: 6), costRow('Total de repuestos', order.totalPartsCost), pw.SizedBox(height: 8), pw.Divider(color: muted, thickness: .5), pw.SizedBox(height: 6), costRow('Valor total de la OT', order.totalCost, emphasize: true), ], ), ), ); } pw.Widget _signatureBlock( pw.ImageProvider signature, { required PdfColor dark, required PdfColor muted, }) { return pw.Inseparable( child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.center, children: [ pw.SizedBox(width: double.infinity, height: 8), pw.Text( 'Atentamente', style: pw.TextStyle(color: muted, fontSize: 8), ), pw.SizedBox(height: 2), pw.SizedBox( width: 105, height: 78, child: pw.Image(signature, fit: pw.BoxFit.contain), ), pw.Text( 'Simón Moya Salinas', style: pw.TextStyle( color: dark, fontSize: 10, fontWeight: pw.FontWeight.bold, ), ), pw.SizedBox(height: 2), pw.Text( 'C.I.: 9.990.302-3 - Instalador de gas autorizado SEC', style: pw.TextStyle(color: dark, fontSize: 8), ), pw.SizedBox(height: 2), pw.Text( 'WhatsApp: 933922780', style: pw.TextStyle(color: muted, fontSize: 8), ), ], ), ); } pw.Widget _pdfImage(pw.ImageProvider image) { return pw.Container( height: 115, decoration: pw.BoxDecoration( borderRadius: pw.BorderRadius.circular(4), border: pw.Border.all( color: const PdfColor.fromInt(0xFFD6E0DF), width: .7, ), ), child: pw.Center(child: pw.Image(image, fit: pw.BoxFit.contain)), ); } Future<_TaskImages> _loadTaskImages(WorkTask task) async { final before = []; final after = []; for (final photo in task.photos) { final file = File(photo.path); if (!await file.exists()) continue; final Uint8List bytes = await file.readAsBytes(); final provider = pw.MemoryImage(bytes); if (photo.kind == PhotoKind.before) { before.add(provider); } else { after.add(provider); } } return _TaskImages(before: before, after: after); } } class _TaskImages { const _TaskImages({required this.before, required this.after}); final List before; final List after; }