508 lines
16 KiB
Dart
508 lines
16 KiB
Dart
import 'package:flutter/services.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:cashumit/models/receipt_item.dart';
|
|
import 'package:flutter_esc_pos_utils/flutter_esc_pos_utils.dart';
|
|
import 'package:image/image.dart' as img;
|
|
import 'dart:io';
|
|
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,
|
|
});
|
|
}
|
|
|
|
/// 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<ReceiptItem> items,
|
|
required DateTime transactionDate,
|
|
PaymentInfo? paymentInfo,
|
|
}) async {
|
|
print('Memulai generateEscPosBytes...');
|
|
print('Jumlah item: ${items.length}');
|
|
print('Tanggal transaksi: $transactionDate');
|
|
|
|
// 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);
|
|
print('Tanggal yang diformat: $formattedDate');
|
|
|
|
// Load capability profile with timeout
|
|
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 = [];
|
|
|
|
// Add store logo if available
|
|
bytes.addAll(await _addStoreLogo(generator, storeConfig.logoPath));
|
|
|
|
// Add store header
|
|
bytes.addAll(_addStoreHeader(generator, storeConfig, formattedDate));
|
|
|
|
// Add item list
|
|
bytes.addAll(_addItemList(generator, items));
|
|
|
|
// Add totals
|
|
bytes.addAll(_addTotals(generator, items, paymentInfo));
|
|
|
|
// Add footer information
|
|
bytes.addAll(_addFooter(generator, storeConfig));
|
|
|
|
// Add cut command
|
|
bytes.addAll(generator.feed(2));
|
|
bytes.addAll(generator.cut());
|
|
|
|
print('Jumlah byte yang dihasilkan: ${bytes.length}');
|
|
return bytes;
|
|
}
|
|
|
|
/// Load capability profile safely
|
|
static Future<CapabilityProfile> _loadCapabilityProfile() async {
|
|
try {
|
|
return await CapabilityProfile.load();
|
|
} catch (e) {
|
|
print('Gagal memuat capability profile: $e');
|
|
// Gunakan profile default jika gagal
|
|
return await CapabilityProfile.load();
|
|
}
|
|
}
|
|
|
|
/// Add store logo to receipt
|
|
static Future<List<int>> _addStoreLogo(
|
|
Generator generator, String? logoPath) async {
|
|
List<int> bytes = [];
|
|
if (logoPath != null && logoPath.isNotEmpty) {
|
|
try {
|
|
final img.Image? logo = await _getImageFromFilePath(logoPath);
|
|
if (logo != null) {
|
|
// Mengubah ukuran logo agar pas di struk (lebar maks 300px)
|
|
final resizedLogo = img.copyResize(logo, width: 300);
|
|
bytes.addAll(generator.image(resizedLogo, align: PosAlign.center));
|
|
bytes.addAll(generator.feed(1)); // Beri sedikit spasi setelah logo
|
|
}
|
|
} 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));
|
|
|
|
// Separator line
|
|
bytes.addAll(generator.text('================================',
|
|
styles: PosStyles(align: PosAlign.center)));
|
|
|
|
// Item table header
|
|
bytes.addAll(generator.row([
|
|
PosColumn(
|
|
text: 'Item',
|
|
width: 7,
|
|
styles: PosStyles(bold: true, align: PosAlign.left),
|
|
),
|
|
PosColumn(
|
|
text: 'Total',
|
|
width: 5,
|
|
styles: PosStyles(bold: true, align: PosAlign.right),
|
|
),
|
|
]));
|
|
|
|
return bytes;
|
|
}
|
|
|
|
/// Add item list to receipt
|
|
static List<int> _addItemList(Generator generator, List<ReceiptItem> items) {
|
|
List<int> bytes = [];
|
|
|
|
print('Memulai iterasi item...');
|
|
for (int i = 0; i < items.length; i++) {
|
|
try {
|
|
var item = items[i];
|
|
print(
|
|
'Item $i: ${item.description}, qty: ${item.quantity}, price: ${item.price}, total: ${item.total}');
|
|
|
|
// Item description row
|
|
bytes.addAll(generator.row([
|
|
PosColumn(
|
|
text: item.description,
|
|
width: 12,
|
|
styles: PosStyles(align: PosAlign.left),
|
|
),
|
|
]));
|
|
|
|
// Quantity and price row
|
|
bytes.addAll(generator.row([
|
|
PosColumn(
|
|
text: '${item.quantity} x ${_formatRupiah(item.price)}',
|
|
width: 7,
|
|
styles: PosStyles(
|
|
align: PosAlign.left,
|
|
height: PosTextSize.size1,
|
|
width: PosTextSize.size1),
|
|
),
|
|
PosColumn(
|
|
text: _formatRupiah(item.total),
|
|
width: 5,
|
|
styles: PosStyles(align: PosAlign.right),
|
|
),
|
|
]));
|
|
} catch (e) {
|
|
print('Error saat memproses item $i: $e');
|
|
// Lanjutkan ke item berikutnya jika ada error
|
|
continue;
|
|
}
|
|
}
|
|
print('Selesai iterasi item');
|
|
|
|
return bytes;
|
|
}
|
|
|
|
/// Add totals and payment information
|
|
static List<int> _addTotals(
|
|
Generator generator, List<ReceiptItem> items, PaymentInfo? paymentInfo) {
|
|
List<int> bytes = [];
|
|
|
|
// Separator before total
|
|
bytes.addAll(generator.text('--------------------------------',
|
|
styles: PosStyles(align: PosAlign.center)));
|
|
|
|
// Calculate total
|
|
double totalAmount = items.fold(0.0, (sum, item) => sum + item.total);
|
|
print('Total amount: $totalAmount');
|
|
|
|
// Add total row
|
|
bytes.addAll(generator.row([
|
|
PosColumn(
|
|
text: 'TOTAL',
|
|
width: 7,
|
|
styles: PosStyles(bold: true, align: PosAlign.left),
|
|
),
|
|
PosColumn(
|
|
text: _formatRupiah(totalAmount),
|
|
width: 5,
|
|
styles: PosStyles(bold: true, align: PosAlign.right),
|
|
),
|
|
]));
|
|
|
|
// Add payment information if provided
|
|
if (paymentInfo != null && paymentInfo.paymentAmount > 0) {
|
|
bytes.addAll(_addPaymentInfo(generator, totalAmount, paymentInfo));
|
|
}
|
|
|
|
// Final separator
|
|
bytes.addAll(generator.text('================================',
|
|
styles: PosStyles(align: PosAlign.center)));
|
|
|
|
return bytes;
|
|
}
|
|
|
|
/// Add payment information including change and tip
|
|
static List<int> _addPaymentInfo(
|
|
Generator generator, double totalAmount, PaymentInfo paymentInfo) {
|
|
List<int> bytes = [];
|
|
final changeAmount = paymentInfo.paymentAmount - totalAmount;
|
|
|
|
// Payment amount row - total width should be 12
|
|
bytes.addAll(generator.row([
|
|
PosColumn(
|
|
text: 'BAYAR',
|
|
width: 7,
|
|
styles: PosStyles(align: PosAlign.left),
|
|
),
|
|
PosColumn(
|
|
text: _formatRupiah(paymentInfo.paymentAmount),
|
|
width: 5,
|
|
styles: PosStyles(align: PosAlign.right),
|
|
),
|
|
]));
|
|
|
|
if (changeAmount >= 0) {
|
|
// Change amount row - total width should be 12
|
|
bytes.addAll(generator.row([
|
|
PosColumn(
|
|
text: 'KEMBALI',
|
|
width: 7,
|
|
styles: PosStyles(align: PosAlign.left),
|
|
),
|
|
PosColumn(
|
|
text: _formatRupiah(changeAmount),
|
|
width: 5,
|
|
styles: PosStyles(align: PosAlign.right),
|
|
),
|
|
]));
|
|
|
|
// Tip information
|
|
if (paymentInfo.isTip && changeAmount > 0) {
|
|
bytes.addAll(generator.text('SEBAGAI TIP: YA',
|
|
styles: PosStyles(align: PosAlign.center, bold: true)));
|
|
bytes.addAll(generator.text('(Uang kembalian sebagai tip)',
|
|
styles: PosStyles(align: PosAlign.center)));
|
|
} else if (changeAmount > 0) {
|
|
bytes.addAll(generator.text('SEBAGAI TIP: TIDAK',
|
|
styles: PosStyles(align: PosAlign.center)));
|
|
}
|
|
} else {
|
|
// Amount still needed row - total width should be 12
|
|
bytes.addAll(generator.row([
|
|
PosColumn(
|
|
text: 'KURANG',
|
|
width: 7,
|
|
styles: PosStyles(align: PosAlign.left),
|
|
),
|
|
PosColumn(
|
|
text: _formatRupiah(changeAmount.abs()),
|
|
width: 5,
|
|
styles: PosStyles(align: PosAlign.right),
|
|
),
|
|
]));
|
|
}
|
|
|
|
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)}";
|
|
}
|
|
|
|
/// Mencetak struk ke printer thermal
|
|
static Future<void> printToThermalPrinter({
|
|
required List<ReceiptItem> items,
|
|
required DateTime transactionDate,
|
|
required BuildContext context,
|
|
required dynamic bluetoothService,
|
|
PaymentInfo? paymentInfo,
|
|
}) async {
|
|
print('=== FUNGSI printToThermalPrinter DIPANGGIL ===');
|
|
print('Memulai proses pencetakan struk...');
|
|
print('Jumlah item: ${items.length}');
|
|
print('Tanggal transaksi: $transactionDate');
|
|
|
|
try {
|
|
print(
|
|
'Menghasilkan byte array ESC/POS menggunakan flutter_esc_pos_utils...');
|
|
|
|
// Generate struk dalam format byte array
|
|
final bytes = await generateEscPosBytes(
|
|
items: items,
|
|
transactionDate: transactionDate,
|
|
paymentInfo: paymentInfo,
|
|
);
|
|
|
|
print('Byte array ESC/POS berhasil dihasilkan');
|
|
print('Jumlah byte: ${bytes.length}');
|
|
|
|
// Verifikasi koneksi sebelum mencetak
|
|
final isConnectedBeforePrint = await bluetoothService.checkConnection();
|
|
if (!isConnectedBeforePrint) {
|
|
print('Printer tidak terhubung saat akan mencetak');
|
|
throw SocketException('Printer tidak terhubung saat akan mencetak');
|
|
}
|
|
|
|
print('Mengirim byte array ke printer...');
|
|
|
|
try {
|
|
// Konversi List<int> ke Uint8List
|
|
final Uint8List data = Uint8List.fromList(bytes);
|
|
await bluetoothService.printReceipt(data);
|
|
print('Perintah cetak berhasil dikirim');
|
|
} 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; // Lempar ulang error koneksi
|
|
} on PlatformException {
|
|
rethrow; // Lempar ulang error platform
|
|
} catch (e, stackTrace) {
|
|
PrintErrorHandler.logPrintError(
|
|
'PRINT_THERMAL_PRINTER', e as Exception, stackTrace);
|
|
throw Exception('Gagal mencetak struk: $e');
|
|
}
|
|
}
|
|
|
|
/// Helper function to get image from file path
|
|
static Future<img.Image?> _getImageFromFilePath(String path) async {
|
|
try {
|
|
final file = File(path);
|
|
if (await file.exists()) {
|
|
final bytes = await file.readAsBytes();
|
|
return img.decodeImage(bytes);
|
|
}
|
|
} catch (e) {
|
|
print('Error getting image from file path: $e');
|
|
}
|
|
return null;
|
|
}
|
|
}
|