3 changed files with 478 additions and 196 deletions
@ -1,188 +1,290 @@ |
|||
// services/pdf_service.dart |
|||
// services/platform_print_service.dart |
|||
import 'dart:io'; |
|||
import 'dart:typed_data'; |
|||
import 'package:flutter/foundation.dart'; |
|||
import 'package:flutter/services.dart'; |
|||
import 'package:itrimobe/models/command_detail.dart'; |
|||
import 'package:pdf/pdf.dart'; |
|||
import 'package:pdf/widgets.dart' as pw; |
|||
import 'package:printing/printing.dart'; |
|||
import 'package:path_provider/path_provider.dart'; |
|||
import 'package:share_plus/share_plus.dart'; |
|||
import 'package:permission_handler/permission_handler.dart'; |
|||
import '../models/command_detail.dart'; |
|||
|
|||
class PdfService { |
|||
static Future<Uint8List> generateFacturePdf({ |
|||
class PlatformPrintService { |
|||
// Format spécifique 58mm pour petites imprimantes |
|||
static const PdfPageFormat ticket58mmFormat = PdfPageFormat( |
|||
58 * PdfPageFormat.mm, // Largeur exacte 58mm |
|||
double.infinity, // Hauteur automatique |
|||
marginLeft: 1 * PdfPageFormat.mm, |
|||
marginRight: 1 * PdfPageFormat.mm, |
|||
marginTop: 2 * PdfPageFormat.mm, |
|||
marginBottom: 2 * PdfPageFormat.mm, |
|||
); |
|||
|
|||
// Vérifier les permissions |
|||
static Future<bool> _checkPermissions() async { |
|||
if (!Platform.isAndroid) return true; |
|||
|
|||
final storagePermission = await Permission.storage.request(); |
|||
return storagePermission == PermissionStatus.granted; |
|||
} |
|||
|
|||
// Vérifier si l'impression est possible |
|||
static Future<bool> canPrint() async { |
|||
try { |
|||
return await Printing.info().then((info) => info.canPrint); |
|||
} catch (e) { |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
// Générer PDF optimisé pour 58mm |
|||
static Future<Uint8List> _generate58mmTicketPdf({ |
|||
required CommandeDetail commande, |
|||
required String paymentMethod, |
|||
}) async { |
|||
final pdf = pw.Document(); |
|||
|
|||
// Informations du restaurant |
|||
// Configuration pour 58mm (très petit) |
|||
const double titleSize = 9; |
|||
const double headerSize = 8; |
|||
const double bodySize = 7; |
|||
const double smallSize = 6; |
|||
const double lineHeight = 1.2; |
|||
|
|||
final restaurantInfo = { |
|||
'nom': 'RESTAURANT', |
|||
'adresse': 'Moramanga, Antananarivo', |
|||
'contact': '+261 34 12 34 56', |
|||
'adresse': '123 Rue de la Paix', |
|||
'ville': '75000 PARIS', |
|||
'contact': '01.23.45.67.89', |
|||
'email': '[email protected]', |
|||
}; |
|||
|
|||
// Générer numéro de facture |
|||
final factureNumber = |
|||
'F${DateTime.now().millisecondsSinceEpoch.toString().substring(7)}'; |
|||
'T${DateTime.now().millisecondsSinceEpoch.toString().substring(8)}'; |
|||
final dateTime = DateTime.now(); |
|||
|
|||
pdf.addPage( |
|||
pw.Page( |
|||
pageFormat: PdfPageFormat.a4, |
|||
margin: const pw.EdgeInsets.all(32), |
|||
pageFormat: ticket58mmFormat, |
|||
build: (pw.Context context) { |
|||
return pw.Column( |
|||
crossAxisAlignment: pw.CrossAxisAlignment.start, |
|||
crossAxisAlignment: pw.CrossAxisAlignment.center, |
|||
children: [ |
|||
// En-tête Restaurant |
|||
pw.Center( |
|||
child: pw.Column( |
|||
children: [ |
|||
pw.Text( |
|||
restaurantInfo['nom']!, |
|||
style: pw.TextStyle( |
|||
fontSize: 24, |
|||
fontWeight: pw.FontWeight.bold, |
|||
), |
|||
), |
|||
pw.SizedBox(height: 8), |
|||
pw.Text( |
|||
'Adresse: ${restaurantInfo['adresse']}', |
|||
style: const pw.TextStyle(fontSize: 12), |
|||
), |
|||
pw.Text( |
|||
'Contact: ${restaurantInfo['contact']}', |
|||
style: const pw.TextStyle(fontSize: 12), |
|||
), |
|||
], |
|||
// En-tête Restaurant (centré et compact) |
|||
pw.Text( |
|||
restaurantInfo['nom']!, |
|||
style: pw.TextStyle( |
|||
fontSize: titleSize, |
|||
fontWeight: pw.FontWeight.bold, |
|||
), |
|||
textAlign: pw.TextAlign.center, |
|||
), |
|||
|
|||
pw.SizedBox(height: 30), |
|||
|
|||
// Informations facture |
|||
pw.Center( |
|||
child: pw.Column( |
|||
children: [ |
|||
pw.Text( |
|||
'Facture n° $factureNumber', |
|||
style: pw.TextStyle( |
|||
fontSize: 14, |
|||
fontWeight: pw.FontWeight.bold, |
|||
), |
|||
), |
|||
pw.SizedBox(height: 4), |
|||
pw.Text( |
|||
'Date: ${_formatDateTime(dateTime)}', |
|||
style: const pw.TextStyle(fontSize: 12), |
|||
), |
|||
pw.SizedBox(height: 4), |
|||
pw.Text( |
|||
'Table: ${commande.numeroCommande}', |
|||
style: const pw.TextStyle(fontSize: 12), |
|||
), |
|||
pw.SizedBox(height: 4), |
|||
pw.Text( |
|||
'Paiement: ${_getPaymentMethodText(paymentMethod)}', |
|||
style: const pw.TextStyle(fontSize: 12), |
|||
), |
|||
], |
|||
), |
|||
pw.SizedBox(height: 1), |
|||
|
|||
pw.Text( |
|||
restaurantInfo['adresse']!, |
|||
style: pw.TextStyle(fontSize: smallSize), |
|||
textAlign: pw.TextAlign.center, |
|||
), |
|||
|
|||
pw.Text( |
|||
restaurantInfo['ville']!, |
|||
style: pw.TextStyle(fontSize: smallSize), |
|||
textAlign: pw.TextAlign.center, |
|||
), |
|||
|
|||
pw.Text( |
|||
'Tel: ${restaurantInfo['contact']!}', |
|||
style: pw.TextStyle(fontSize: smallSize), |
|||
textAlign: pw.TextAlign.center, |
|||
), |
|||
|
|||
pw.SizedBox(height: 3), |
|||
|
|||
// Ligne de séparation |
|||
pw.Container( |
|||
width: double.infinity, |
|||
height: 0.5, |
|||
color: PdfColors.black, |
|||
), |
|||
|
|||
pw.SizedBox(height: 30), |
|||
pw.SizedBox(height: 2), |
|||
|
|||
// Tableau des articles |
|||
pw.Table( |
|||
border: pw.TableBorder.all(color: PdfColors.grey300), |
|||
columnWidths: { |
|||
0: const pw.FlexColumnWidth(3), |
|||
1: const pw.FlexColumnWidth(1), |
|||
2: const pw.FlexColumnWidth(1), |
|||
}, |
|||
// Informations ticket |
|||
pw.Row( |
|||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
// En-tête du tableau |
|||
pw.TableRow( |
|||
decoration: const pw.BoxDecoration( |
|||
color: PdfColors.grey100, |
|||
pw.Text( |
|||
'Ticket: $factureNumber', |
|||
style: pw.TextStyle( |
|||
fontSize: bodySize, |
|||
fontWeight: pw.FontWeight.bold, |
|||
), |
|||
children: [ |
|||
pw.Padding( |
|||
padding: const pw.EdgeInsets.all(8), |
|||
child: pw.Text( |
|||
'Qté Désignation', |
|||
style: pw.TextStyle(fontWeight: pw.FontWeight.bold), |
|||
), |
|||
), |
|||
pw.Padding( |
|||
padding: const pw.EdgeInsets.all(8), |
|||
child: pw.Text( |
|||
'Prix', |
|||
style: pw.TextStyle(fontWeight: pw.FontWeight.bold), |
|||
textAlign: pw.TextAlign.right, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
// Lignes des articles |
|||
...commande.items |
|||
.map( |
|||
(item) => pw.TableRow( |
|||
children: [ |
|||
pw.Padding( |
|||
padding: const pw.EdgeInsets.all(8), |
|||
child: pw.Text( |
|||
'${item.quantite} TESTNOMCOMMANDE', |
|||
// pw.Text( |
|||
// 'Table: ${commande.tableName}', |
|||
// style: pw.TextStyle(fontSize: bodySize), |
|||
// ), |
|||
], |
|||
), |
|||
|
|||
pw.SizedBox(height: 1), |
|||
|
|||
pw.Row( |
|||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
pw.Text( |
|||
_formatDate(dateTime), |
|||
style: pw.TextStyle(fontSize: smallSize), |
|||
), |
|||
pw.Text( |
|||
_formatTime(dateTime), |
|||
style: pw.TextStyle(fontSize: smallSize), |
|||
), |
|||
], |
|||
), |
|||
|
|||
pw.SizedBox(height: 2), |
|||
|
|||
// Ligne de séparation |
|||
pw.Container( |
|||
width: double.infinity, |
|||
height: 0.5, |
|||
color: PdfColors.black, |
|||
), |
|||
|
|||
pw.SizedBox(height: 2), |
|||
|
|||
// Articles (format très compact) |
|||
...commande.items |
|||
.map( |
|||
(item) => pw.Container( |
|||
margin: const pw.EdgeInsets.only(bottom: 1), |
|||
child: pw.Column( |
|||
crossAxisAlignment: pw.CrossAxisAlignment.start, |
|||
children: [ |
|||
// Nom du plat |
|||
pw.Text( |
|||
"NOMPLAT", |
|||
style: pw.TextStyle(fontSize: bodySize), |
|||
maxLines: 2, |
|||
), |
|||
|
|||
// Quantité, prix unitaire et total sur une ligne |
|||
pw.Row( |
|||
mainAxisAlignment: |
|||
pw.MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
pw.Text( |
|||
'${item.quantite}x ${item.prixUnitaire.toStringAsFixed(2)}€', |
|||
style: pw.TextStyle(fontSize: smallSize), |
|||
), |
|||
), |
|||
pw.Padding( |
|||
padding: const pw.EdgeInsets.all(8), |
|||
child: pw.Text( |
|||
'${item.prixUnitaire.toStringAsFixed(2)} €', |
|||
textAlign: pw.TextAlign.right, |
|||
pw.Text( |
|||
'${(item.prixUnitaire * item.quantite).toStringAsFixed(2)}€', |
|||
style: pw.TextStyle( |
|||
fontSize: bodySize, |
|||
fontWeight: pw.FontWeight.bold, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
) |
|||
.toList(), |
|||
], |
|||
], |
|||
), |
|||
], |
|||
), |
|||
), |
|||
) |
|||
.toList(), |
|||
|
|||
pw.SizedBox(height: 2), |
|||
|
|||
// Ligne de séparation |
|||
pw.Container( |
|||
width: double.infinity, |
|||
height: 0.5, |
|||
color: PdfColors.black, |
|||
), |
|||
|
|||
pw.SizedBox(height: 20), |
|||
pw.SizedBox(height: 2), |
|||
|
|||
// Total |
|||
pw.Container( |
|||
alignment: pw.Alignment.centerRight, |
|||
child: pw.Container( |
|||
padding: const pw.EdgeInsets.all(12), |
|||
decoration: pw.BoxDecoration( |
|||
border: pw.Border.all(color: PdfColors.grey400), |
|||
color: PdfColors.grey50, |
|||
pw.Row( |
|||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
pw.Text( |
|||
'TOTAL', |
|||
style: pw.TextStyle( |
|||
fontSize: titleSize, |
|||
fontWeight: pw.FontWeight.bold, |
|||
), |
|||
), |
|||
child: pw.Text( |
|||
'Total: ${commande.totalTtc.toStringAsFixed(2)} €', |
|||
pw.Text( |
|||
'${commande.totalTtc.toStringAsFixed(2)}€', |
|||
style: pw.TextStyle( |
|||
fontSize: 16, |
|||
fontSize: titleSize, |
|||
fontWeight: pw.FontWeight.bold, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
|
|||
pw.Spacer(), |
|||
pw.SizedBox(height: 3), |
|||
|
|||
// Mode de paiement |
|||
pw.Text( |
|||
'Paiement: ${_getPaymentMethodText(paymentMethod)}', |
|||
style: pw.TextStyle(fontSize: bodySize), |
|||
textAlign: pw.TextAlign.center, |
|||
), |
|||
|
|||
pw.SizedBox(height: 3), |
|||
|
|||
// Ligne de séparation |
|||
pw.Container( |
|||
width: double.infinity, |
|||
height: 0.5, |
|||
color: PdfColors.black, |
|||
), |
|||
|
|||
pw.SizedBox(height: 2), |
|||
|
|||
// Message de remerciement |
|||
pw.Center( |
|||
child: pw.Text( |
|||
'Merci et à bientôt !', |
|||
style: pw.TextStyle( |
|||
fontSize: 12, |
|||
fontStyle: pw.FontStyle.italic, |
|||
), |
|||
pw.Text( |
|||
'Merci de votre visite !', |
|||
style: pw.TextStyle( |
|||
fontSize: bodySize, |
|||
fontStyle: pw.FontStyle.italic, |
|||
), |
|||
textAlign: pw.TextAlign.center, |
|||
), |
|||
|
|||
pw.Text( |
|||
'A bientôt !', |
|||
style: pw.TextStyle(fontSize: smallSize), |
|||
textAlign: pw.TextAlign.center, |
|||
), |
|||
|
|||
pw.SizedBox(height: 3), |
|||
|
|||
// Code de suivi (optionnel) |
|||
pw.Text( |
|||
'Code: ${factureNumber}', |
|||
style: pw.TextStyle(fontSize: smallSize), |
|||
textAlign: pw.TextAlign.center, |
|||
), |
|||
|
|||
pw.SizedBox(height: 4), |
|||
|
|||
// Ligne de découpe |
|||
pw.Text( |
|||
'- - - - - - - - - - - - - - - -', |
|||
style: pw.TextStyle(fontSize: smallSize), |
|||
textAlign: pw.TextAlign.center, |
|||
), |
|||
|
|||
pw.SizedBox(height: 2), |
|||
], |
|||
); |
|||
}, |
|||
@ -192,75 +294,120 @@ class PdfService { |
|||
return pdf.save(); |
|||
} |
|||
|
|||
static String _formatDateTime(DateTime dateTime) { |
|||
return '${dateTime.day.toString().padLeft(2, '0')}/${dateTime.month.toString().padLeft(2, '0')}/${dateTime.year} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; |
|||
} |
|||
|
|||
static String _getPaymentMethodText(String method) { |
|||
switch (method) { |
|||
case 'mvola': |
|||
return 'MVola'; |
|||
case 'carte': |
|||
return 'CB'; |
|||
case 'especes': |
|||
return 'Espèces'; |
|||
default: |
|||
return 'CB'; |
|||
} |
|||
} |
|||
|
|||
// Imprimer directement |
|||
static Future<bool> printFacture({ |
|||
// Imprimer ticket 58mm |
|||
static Future<bool> printTicket({ |
|||
required CommandeDetail commande, |
|||
required String paymentMethod, |
|||
}) async { |
|||
try { |
|||
final pdfData = await generateFacturePdf( |
|||
final hasPermission = await _checkPermissions(); |
|||
if (!hasPermission) { |
|||
throw Exception('Permissions requises pour l\'impression'); |
|||
} |
|||
|
|||
final pdfData = await _generate58mmTicketPdf( |
|||
commande: commande, |
|||
paymentMethod: paymentMethod, |
|||
); |
|||
|
|||
final fileName = |
|||
'Ticket_${commande.numeroCommande}_${DateTime.now().millisecondsSinceEpoch}'; |
|||
|
|||
await Printing.layoutPdf( |
|||
onLayout: (PdfPageFormat format) async => pdfData, |
|||
name: |
|||
'Facture_${commande.numeroCommande}_${DateTime.now().millisecondsSinceEpoch}', |
|||
name: fileName, |
|||
format: ticket58mmFormat, |
|||
); |
|||
|
|||
return true; |
|||
} catch (e) { |
|||
print('Erreur impression: $e'); |
|||
print('Erreur impression 58mm: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
// Sauvegarder et partager le PDF |
|||
static Future<bool> saveAndShareFacture({ |
|||
// Sauvegarder ticket 58mm |
|||
static Future<bool> saveTicketPdf({ |
|||
required CommandeDetail commande, |
|||
required String paymentMethod, |
|||
}) async { |
|||
try { |
|||
final pdfData = await generateFacturePdf( |
|||
final hasPermission = await _checkPermissions(); |
|||
if (!hasPermission) return false; |
|||
|
|||
final pdfData = await _generate58mmTicketPdf( |
|||
commande: commande, |
|||
paymentMethod: paymentMethod, |
|||
); |
|||
|
|||
final directory = await getApplicationDocumentsDirectory(); |
|||
Directory directory; |
|||
if (Platform.isAndroid) { |
|||
directory = Directory('/storage/emulated/0/Download'); |
|||
if (!directory.existsSync()) { |
|||
directory = |
|||
await getExternalStorageDirectory() ?? |
|||
await getApplicationDocumentsDirectory(); |
|||
} |
|||
} else { |
|||
directory = await getApplicationDocumentsDirectory(); |
|||
} |
|||
|
|||
final fileName = |
|||
'Facture_${commande.numeroCommande}_${DateTime.now().millisecondsSinceEpoch}.pdf'; |
|||
'Ticket_58mm_${commande.numeroCommande}_${DateTime.now().millisecondsSinceEpoch}.pdf'; |
|||
final file = File('${directory.path}/$fileName'); |
|||
|
|||
await file.writeAsBytes(pdfData); |
|||
|
|||
await Share.shareXFiles( |
|||
[XFile(file.path)], |
|||
subject: 'Facture ${commande.numeroCommande}', |
|||
text: 'Facture de votre commande au restaurant', |
|||
subject: 'Ticket ${commande.numeroCommande}', |
|||
text: 'Ticket de caisse 58mm', |
|||
); |
|||
|
|||
return true; |
|||
} catch (e) { |
|||
print('Erreur sauvegarde/partage: $e'); |
|||
print('Erreur sauvegarde 58mm: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
// Méthodes pour compatibilité |
|||
static Future<bool> saveFacturePdf({ |
|||
required CommandeDetail commande, |
|||
required String paymentMethod, |
|||
}) async { |
|||
return await saveTicketPdf( |
|||
commande: commande, |
|||
paymentMethod: paymentMethod, |
|||
); |
|||
} |
|||
|
|||
static Future<bool> printFacture({ |
|||
required CommandeDetail commande, |
|||
required String paymentMethod, |
|||
}) async { |
|||
return await printTicket(commande: commande, paymentMethod: paymentMethod); |
|||
} |
|||
|
|||
// Utilitaires de formatage |
|||
static String _formatDate(DateTime dateTime) { |
|||
return '${dateTime.day.toString().padLeft(2, '0')}/${dateTime.month.toString().padLeft(2, '0')}/${dateTime.year}'; |
|||
} |
|||
|
|||
static String _formatTime(DateTime dateTime) { |
|||
return '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; |
|||
} |
|||
|
|||
static String _getPaymentMethodText(String method) { |
|||
switch (method) { |
|||
case 'cash': |
|||
return 'Espèces'; |
|||
case 'card': |
|||
return 'Carte bancaire'; |
|||
case 'mobile': |
|||
return 'Paiement mobile'; |
|||
default: |
|||
return 'Non spécifié'; |
|||
} |
|||
} |
|||
} |
|||
|
|||
Loading…
Reference in new issue