OTMovilGasfiter/lib/services/pdf_service.dart
2026-08-02 15:50:25 -04:00

794 lines
22 KiB
Dart

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<String> 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<Uint8List> 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 = <String, _TaskImages>{};
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<pw.Widget> _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<pw.Widget> _partsWidgets(
List<TaskPart> parts,
int subtotal, {
required PdfColor primary,
required PdfColor dark,
required PdfColor muted,
required PdfColor pale,
required NumberFormat priceFormat,
}) {
final widgets = <pw.Widget>[
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<pw.Widget> _photoSection(
String title,
List<pw.ImageProvider> images, {
required PdfColor primary,
required PdfColor muted,
}) {
final widgets = <pw.Widget>[
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 = <pw.ImageProvider>[];
final after = <pw.ImageProvider>[];
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<pw.ImageProvider> before;
final List<pw.ImageProvider> after;
}