cashumit/lib/services/esc_pos_print_service.dart

462 lines
15 KiB
Dart

import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:intl/intl.dart';
import 'package:cashumit/models/receipt_item.dart';
import 'package:cashumit/models/receipt_group.dart';
import 'package:flutter_esc_pos_utils/flutter_esc_pos_utils.dart';
import 'package:image/image.dart' as img;
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:cashumit/services/print_config.dart';
/// Data class untuk konfigurasi toko
class StoreConfig {
final String name;
final String address;
final String adminName;
final String adminPhone;
final String? logoPath;
final String disclaimer;
final String thankYouText;
final String pantunText;
StoreConfig({
required this.name,
required this.address,
required this.adminName,
required this.adminPhone,
this.logoPath,
required this.disclaimer,
required this.thankYouText,
required this.pantunText,
});
static Future<StoreConfig> fromSharedPreferences() async {
final prefs = await SharedPreferences.getInstance();
// Baca semua nilai sekaligus untuk menghindari multiple disk reads
final storeName = prefs.getString('store_name') ?? 'TOKO SEMBAKO MURAH';
final storeAddress =
prefs.getString('store_address') ?? 'Jl. Merdeka No. 123';
final adminName = prefs.getString('admin_name') ?? 'Budi Santoso';
final adminPhone = prefs.getString('admin_phone') ?? '08123456789';
final logoPath = prefs.getString('store_logo_path');
final disclaimer = prefs.getString('store_disclaimer_text') ??
'Barang yang sudah dibeli tidak dapat dikembalikan/ditukar. '
'Harap periksa kembali struk belanja Anda sebelum meninggalkan toko.';
final thankYouText =
prefs.getString('thank_you_text') ?? '*** TERIMA KASIH ***';
final pantunText = prefs.getString('pantun_text') ??
'Belanja di toko kami, hemat dan nyaman\n'
'Dengan penuh semangat, kami siap melayani\n'
'Harapan kami, Anda selalu puas\n'
'Sampai jumpa lagi, selamat tinggal.';
return StoreConfig(
name: storeName,
address: storeAddress,
adminName: adminName,
adminPhone: adminPhone,
logoPath: logoPath,
disclaimer: disclaimer,
thankYouText: thankYouText,
pantunText: pantunText,
);
}
}
/// Data class untuk informasi pembayaran
class PaymentInfo {
final double paymentAmount;
final bool isTip;
PaymentInfo({
this.paymentAmount = 0.0,
this.isTip = false,
});
}
/// Fungsi top-level untuk decode + resize logo di isolate terpisah.
/// Mengembalikan byte PNG hasil resize (isolate-safe, tidak mengirim objek Image).
Future<Uint8List?> _decodeAndResizeLogo(String path) async {
final file = File(path);
if (!await file.exists()) return null;
final bytes = await file.readAsBytes();
final decoded = img.decodeImage(bytes);
if (decoded == null) return null;
final resized = img.copyResize(decoded, width: 300);
// Encode ke PNG agar bisa diteruskan keluar isolate
return Uint8List.fromList(img.encodePng(resized));
}
/// Service untuk menghasilkan perintah ESC/POS menggunakan flutter_esc_pos_utils
class EscPosPrintService {
/// Menghasilkan struk dalam format byte array berdasarkan data transaksi
static Future<List<int>> generateEscPosBytes({
required List<ReceiptGroup> groups,
required DateTime transactionDate,
String? destinationAccountName,
PaymentInfo? paymentInfo,
String? tipSourceAccountName,
}) async {
// Load store info from shared preferences once
final storeConfig = await StoreConfig.fromSharedPreferences();
// Format tanggal
final dateFormatter = DateFormat('dd/MM/yyyy HH:mm');
final formattedDate = dateFormatter.format(transactionDate);
// Load capability profile
final profile = await _loadCapabilityProfile();
// Get printer configuration
final config = await PrintConfig.loadPrinterConfig();
final paperSize = PrintConfig.getPaperSizeEnum(config['paperSize']);
// Use configured paper size or default
final generator = Generator(
paperSize == 'mm80' ? PaperSize.mm80 : PaperSize.mm58, profile);
// Generate all receipt sections
List<int> bytes = [];
bytes.addAll(generator.reset());
bytes.addAll([0x1D, 0x21, 0x00]); // GS ! 0 = paksa ukuran karakter normal
// Add store logo if available (diproses di isolate terpisah)
bytes.addAll(await _addStoreLogo(generator, storeConfig.logoPath));
// Add store header
bytes.addAll(_addStoreHeader(generator, storeConfig, formattedDate));
final groupsWithItems = groups.where((g) => g.items.isNotEmpty).toList();
// Cetak setiap grup
for (int gi = 0; gi < groupsWithItems.length; gi++) {
final group = groupsWithItems[gi];
// Header grup: satu garis + nama sumber (tanpa "Dari:")
bytes.addAll(generator.text('--------------------------------',
styles: PosStyles(align: PosAlign.center)));
bytes.addAll(generator.text(group.sourceAccountName ?? '(belum dipilih)',
styles: PosStyles(align: PosAlign.center, bold: true)));
// Item list grup
bytes.addAll(_addItemList(generator, group.items));
// Subtotal grup
bytes.addAll(generator.text(
_padRow('Subtotal', _formatRupiah(group.total)),
styles: PosStyles(align: PosAlign.left, bold: true),
));
if (gi < groupsWithItems.length - 1) {
bytes.addAll(generator.feed(1));
}
}
// Add grand totals + payment
bytes.addAll(_addTotals(generator, groupsWithItems, paymentInfo,
tipSourceAccountName: tipSourceAccountName));
// Add footer information
bytes.addAll(_addFooter(generator, storeConfig));
// Add cut command
bytes.addAll(generator.feed(2));
bytes.addAll(generator.cut());
return bytes;
}
/// Load capability profile safely
static Future<CapabilityProfile> _loadCapabilityProfile() async {
try {
return await CapabilityProfile.load();
} catch (e) {
// Fallback: profile default pakai nama generic
try {
return await CapabilityProfile.load();
} catch (_) {
// Jika masih gagal, lempar agar caller tahu
rethrow;
}
}
}
/// Add store logo to receipt (image processing di isolate terpisah)
static Future<List<int>> _addStoreLogo(
Generator generator, String? logoPath) async {
List<int> bytes = [];
if (logoPath != null && logoPath.isNotEmpty) {
try {
// Decode + resize di isolate terpisah agar tidak memblok UI thread
final resizedBytes = await compute(_decodeAndResizeLogo, logoPath);
if (resizedBytes != null) {
final logo = img.decodeImage(resizedBytes);
if (logo != null) {
bytes.addAll(generator.image(logo, align: PosAlign.center));
bytes.addAll(generator.feed(1));
}
}
} catch (e) {
print('Error loading or processing store logo: $e');
}
}
return bytes;
}
/// Add store header information
static List<int> _addStoreHeader(
Generator generator, StoreConfig config, String formattedDate) {
List<int> bytes = [];
// Add store name as header
bytes.addAll(generator.text(config.name,
styles: PosStyles(
bold: true,
height: PosTextSize.size1,
width: PosTextSize.size1,
align: PosAlign.center,
)));
bytes.addAll(generator.text(config.address,
styles: PosStyles(align: PosAlign.center)));
bytes.addAll(generator.text('Admin: ${config.adminName}',
styles: PosStyles(align: PosAlign.center)));
bytes.addAll(generator.text('Telp: ${config.adminPhone}',
styles: PosStyles(align: PosAlign.center)));
bytes.addAll(generator.text(formattedDate,
styles: PosStyles(align: PosAlign.center)));
bytes.addAll(generator.feed(1));
return bytes;
}
/// Add item list to receipt
static List<int> _addItemList(Generator generator, List<ReceiptItem> items) {
List<int> bytes = [];
for (int i = 0; i < items.length; i++) {
try {
var item = items[i];
bytes.addAll(generator.row([
PosColumn(
text: item.description,
width: 12,
styles: PosStyles(align: PosAlign.left),
),
]));
bytes.addAll(generator.text(
_padRow(_compactQtyPrice(item.quantity, item.price), _formatRupiahCompact(item.total)),
styles: PosStyles(align: PosAlign.left),
));
} catch (e) {
continue;
}
}
return bytes;
}
/// Add totals and payment information
static List<int> _addTotals(
Generator generator, List<ReceiptGroup> groups, PaymentInfo? paymentInfo,
{String? tipSourceAccountName}) {
List<int> bytes = [];
// Separator before total
bytes.addAll(generator.text('================================',
styles: PosStyles(align: PosAlign.center)));
// Calculate grand total
double totalAmount = groups.fold(0.0, (sum, group) => sum + group.total);
// Add total row
bytes.addAll(generator.text(
_padRow('TOTAL', _formatRupiah(totalAmount)),
styles: PosStyles(bold: true, align: PosAlign.left),
));
// Add payment information if provided
if (paymentInfo != null && paymentInfo.paymentAmount > 0) {
bytes.addAll(_addPaymentInfo(generator, totalAmount, paymentInfo,
tipSourceAccountName: tipSourceAccountName));
}
return bytes;
}
/// Add payment information including change and tip
static List<int> _addPaymentInfo(
Generator generator, double totalAmount, PaymentInfo paymentInfo,
{String? tipSourceAccountName}) {
List<int> bytes = [];
final changeAmount = paymentInfo.paymentAmount - totalAmount;
// Payment amount row
bytes.addAll(generator.text(
_padRow('BAYAR', _formatRupiah(paymentInfo.paymentAmount)),
styles: PosStyles(align: PosAlign.left),
));
if (changeAmount >= 0) {
bytes.addAll(generator.text(
_padRow(paymentInfo.isTip ? 'TIP' : 'KEMBALI', _formatRupiah(changeAmount)),
styles: PosStyles(align: PosAlign.left),
));
} else {
// Amount still needed row
bytes.addAll(generator.text(
_padRow('KURANG', _formatRupiah(changeAmount.abs())),
styles: PosStyles(align: PosAlign.left),
));
}
bytes.addAll(generator.text('--------------------------------',
styles: PosStyles(align: PosAlign.center)));
return bytes;
}
/// Add footer information including disclaimer and thanks
static List<int> _addFooter(Generator generator, StoreConfig config) {
List<int> bytes = [];
// Add disclaimer
bytes.addAll(generator.feed(1));
bytes.addAll(_wrapAndAddText(generator, config.disclaimer));
// Add thank you message
bytes.addAll(generator.feed(1));
bytes.addAll(generator.text(config.thankYouText,
styles: PosStyles(align: PosAlign.center, bold: true)));
// Add pantun if exists
if (config.pantunText.isNotEmpty) {
bytes.addAll(generator.feed(1));
final pantunLines = config.pantunText.split('\n');
for (final line in pantunLines) {
bytes.addAll(
generator.text(line, styles: PosStyles(align: PosAlign.center)));
}
}
return bytes;
}
/// Wrap long text into multiple lines
static List<int> _wrapAndAddText(Generator generator, String text) {
List<int> bytes = [];
try {
final words = text.split(' ');
final List<String> wrappedLines = [];
String currentLine = '';
for (final word in words) {
if ((currentLine + word).length > 32) {
wrappedLines.add(currentLine.trim());
currentLine = word + ' ';
} else {
currentLine += word + ' ';
}
}
if (currentLine.trim().isNotEmpty) {
wrappedLines.add(currentLine.trim());
}
for (final line in wrappedLines) {
bytes.addAll(
generator.text(line, styles: PosStyles(align: PosAlign.center)));
}
} catch (e) {
print('Error saat memproses teks: $e');
// Fallback jika ada error
bytes.addAll(
generator.text(text, styles: PosStyles(align: PosAlign.center)));
}
return bytes;
}
/// Format angka ke rupiah
static String _formatRupiah(double amount) {
final formatter = NumberFormat("#,##0", "id_ID");
return "Rp ${formatter.format(amount)}";
}
/// Format angka compact tanpa Rp dan separator
static String _formatRupiahCompact(double amount) {
return NumberFormat("0", "id_ID").format(amount);
}
/// Format qty x price compact: "2x65000"
static String _compactQtyPrice(double quantity, double price) {
final qtyInt = quantity.toInt();
final priceStr = NumberFormat("0", "id_ID").format(price);
return "${qtyInt}x$priceStr";
}
/// Pad left/right text into one line to avoid ESC a alignment issues in row()
static String _padRow(String left, String right, {int maxChars = 32}) {
final padding = maxChars - left.length - right.length;
return left + ' ' * (padding > 0 ? padding : 1) + right;
}
/// Mencetak struk ke printer thermal
static Future<void> printToThermalPrinter({
required List<ReceiptGroup> groups,
required DateTime transactionDate,
String? destinationAccountName,
required BuildContext context,
required dynamic bluetoothService,
PaymentInfo? paymentInfo,
String? tipSourceAccountName,
}) async {
try {
// Generate struk dalam format byte array
final bytes = await generateEscPosBytes(
groups: groups,
transactionDate: transactionDate,
destinationAccountName: destinationAccountName,
paymentInfo: paymentInfo,
tipSourceAccountName: tipSourceAccountName,
);
// Verifikasi koneksi sebelum mencetak
final isConnectedBeforePrint = await bluetoothService.checkConnection();
if (!isConnectedBeforePrint) {
throw SocketException('Printer tidak terhubung saat akan mencetak');
}
try {
// Konversi List<int> ke Uint8List
final Uint8List data = Uint8List.fromList(bytes);
await bluetoothService.printReceipt(data);
} on SocketException catch (e) {
PrintErrorHandler.logPrintError('SEND_TO_PRINTER', e);
throw SocketException('Koneksi ke printer terputus: ${e.message}');
} on PlatformException catch (e) {
PrintErrorHandler.logPrintError('SEND_TO_PRINTER', e);
throw PlatformException(
code: e.code, message: 'Error printer: ${e.message}');
} catch (printError) {
PrintErrorHandler.logPrintError(
'SEND_TO_PRINTER', printError as Exception);
throw Exception('Gagal mengirim perintah cetak: $printError');
}
} on SocketException {
rethrow;
} on PlatformException {
rethrow;
} catch (e, stackTrace) {
PrintErrorHandler.logPrintError(
'PRINT_THERMAL_PRINTER', e as Exception, stackTrace);
throw Exception('Gagal mencetak struk: $e');
}
}
}