feat: Implement logo printing to thermal printer and add loading indicator

• Replace StrukTextGenerator with EscPosPrintService to support image printing

• Add loading indicator during print process to prevent UI freeze

• Fix Uint8List type issues for printer compatibility

• Improve error handling with proper state management

• Users can now print selected store logo on thermal receipts
master
a2nr 2025-08-23 22:44:16 +07:00
parent b848881805
commit 3e1c34d1ce
6 changed files with 492 additions and 237 deletions

View File

@ -1,12 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
// import 'dart:io'; // Add this import for file operations // PDF Removed // import 'dart:io'; // Add this import for file operations // PDF Removed
import 'dart:typed_data'; // Untuk Uint8List
import 'package:intl/intl.dart'; // Untuk format angka import 'package:intl/intl.dart'; // Untuk format angka
import 'package:cashumit/models/receipt_item.dart'; import 'package:cashumit/models/receipt_item.dart';
import 'package:cashumit/screens/add_item_screen.dart'; import 'package:cashumit/screens/add_item_screen.dart';
// import 'package:cashumit/services/pdf_export_service.dart'; // PDF Removed // import 'package:cashumit/services/pdf_export_service.dart'; // PDF Removed
import 'package:cashumit/services/firefly_api_service.dart'; import 'package:cashumit/services/firefly_api_service.dart';
import 'package:cashumit/services/struk_text_generator.dart'; // Tambahkan import ini import 'package:cashumit/services/struk_text_generator.dart'; // Tambahkan import ini
import 'package:cashumit/services/esc_pos_print_service.dart'; // Tambahkan import ini
import 'package:bluetooth_print/bluetooth_print.dart'; import 'package:bluetooth_print/bluetooth_print.dart';
import 'package:bluetooth_print/bluetooth_print_model.dart'; import 'package:bluetooth_print/bluetooth_print_model.dart';
import 'package:flutter_speed_dial/flutter_speed_dial.dart'; // Tambahkan import ini import 'package:flutter_speed_dial/flutter_speed_dial.dart'; // Tambahkan import ini
@ -58,6 +60,7 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
late BluetoothPrint bluetoothPrint; late BluetoothPrint bluetoothPrint;
bool _bluetoothConnected = false; bool _bluetoothConnected = false;
BluetoothDevice? _bluetoothDevice; BluetoothDevice? _bluetoothDevice;
bool _isPrinting = false; // State variable to track printing status
@override @override
void initState() { void initState() {
@ -374,51 +377,31 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
} }
try { try {
print('Menghasilkan teks struk...'); print('Menghasilkan byte array ESC/POS menggunakan flutter_esc_pos_utils...');
print('Jumlah item: ${items.length}'); print('Jumlah item: ${items.length}');
print('Tanggal transaksi: $_transactionDate'); print('Tanggal transaksi: $_transactionDate');
print('ID kasir: $cashierId'); print('ID kasir: $cashierId');
print('ID transaksi: $transactionId'); print('ID transaksi: $transactionId');
// Generate struk text menggunakan StrukTextGenerator // Generate struk dalam format byte array menggunakan EscPosPrintService
final strukText = await StrukTextGenerator.generateStrukText( final bytes = await EscPosPrintService.generateEscPosBytes(
items: items, items: items,
transactionDate: _transactionDate, transactionDate: _transactionDate,
cashierId: cashierId, cashierId: cashierId,
transactionId: transactionId, transactionId: transactionId,
); );
print('Teks struk berhasil dihasilkan');
// Tampilkan teks struk untuk debugging
print('Isi teks struk:');
print(strukText);
// Konversi struk text ke format yang bisa dicetak
final lines = strukText.split('\n');
final List<LineText> list = lines
.where((line) => line.isNotEmpty)
.map((line) =>
LineText(type: LineText.TYPE_TEXT, content: line, linefeed: 1))
.toList();
// Tampilkan daftar baris untuk debugging print('Byte array ESC/POS berhasil dihasilkan');
print('Daftar baris yang akan dicetak:'); print('Jumlah byte: ${bytes.length}');
for (int i = 0; i < list.length; i++) {
print('Baris $i: ${list[i].content}'); // Tampilkan byte array untuk debugging (dalam format hex)
print('Isi byte array (hex):');
if (bytes.length <= 1000) { // Batasi tampilan untuk mencegah output terlalu panjang
print(bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' '));
} else {
print('Terlalu banyak byte untuk ditampilkan (${bytes.length} bytes)');
} }
// Konfigurasi untuk mencetak struk - disesuaikan untuk printer termal umum
Map<String, dynamic> config = {
'width': 384, // Lebar kertas struk termal (dalam piksel) - 58mm printer
'height': 800, // Tinggi kertas struk termal (dalam piksel)
'gap': 0, // Jarak antar struk
};
print('Konfigurasi printer: $config');
// Kirim perintah cetak ke printer
print('Mengirim perintah cetak ke printer...');
print('Jumlah baris yang akan dikirim: ${list.length}');
// Verifikasi koneksi sebelum mencetak // Verifikasi koneksi sebelum mencetak
final isConnectedBeforePrint = await bluetoothPrint.isConnected ?? false; final isConnectedBeforePrint = await bluetoothPrint.isConnected ?? false;
if (!isConnectedBeforePrint) { if (!isConnectedBeforePrint) {
@ -430,16 +413,18 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
return; return;
} }
print('Mengirim perintah cetak dengan konfigurasi: $config'); print('Mengirim byte array ke printer...');
bool printSuccess = false; bool printSuccess = false;
String printError = ''; String printError = '';
try { try {
// Coba kirim perintah cetak beberapa kali jika gagal // Coba kirim byte array beberapa kali jika gagal
for (int attempt = 1; attempt <= 3; attempt++) { for (int attempt = 1; attempt <= 3; attempt++) {
print('Percobaan cetak ke-$attempt'); print('Percobaan cetak ke-$attempt');
try { try {
await bluetoothPrint.printReceipt(config, list); // Konversi List<int> ke Uint8List
final Uint8List data = Uint8List.fromList(bytes);
await bluetoothPrint.printRawData(data);
print('Perintah cetak berhasil dikirim pada percobaan ke-$attempt'); print('Perintah cetak berhasil dikirim pada percobaan ke-$attempt');
printSuccess = true; printSuccess = true;
break; break;
@ -481,11 +466,11 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
print('Stack trace: $stackTrace'); print('Stack trace: $stackTrace');
// Cetak detail error tambahan // Cetak detail error tambahan
print('Tipe error: ${e.runtimeType}'); print('Tipe error: ${e.runtimeType}');
// Menghapus baris bermasalah yang menggunakan e.message
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal mencetak struk: $e')), SnackBar(content: Text('Gagal mencetak struk: $e')),
); );
return;
} }
} }
@ -1090,48 +1075,52 @@ SpeedDialChild(
), ),
// SpeedDialChild for PDF printing removed as per request // SpeedDialChild for PDF printing removed as per request
SpeedDialChild( SpeedDialChild(
child: const Icon(Icons.receipt), child: _isPrinting
? const CircularProgressIndicator(color: Colors.white, strokeWidth: 2)
: const Icon(Icons.receipt),
label: 'Cetak Struk', label: 'Cetak Struk',
onTap: () async { onTap: _isPrinting
// Periksa koneksi secara real-time ? null
final isConnected = await _checkBluetoothConnection(); : () async {
if (isConnected) { // Periksa koneksi secara real-time
_printToThermalPrinter(); final isConnected = await _checkBluetoothConnection();
} else { if (isConnected) {
// Coba sambungkan kembali jika ada device yang tersimpan _printToThermalPrinter();
if (_bluetoothDevice != null) { } else {
ScaffoldMessenger.of(context).showSnackBar( // Coba sambungkan kembali jika ada device yang tersimpan
const SnackBar( if (_bluetoothDevice != null) {
content: Text('Mencoba menyambungkan ke printer...')), ScaffoldMessenger.of(context).showSnackBar(
); const SnackBar(
try { content: Text('Mencoba menyambungkan ke printer...')),
await bluetoothPrint.connect(_bluetoothDevice!); );
// Tunggu sebentar untuk memastikan koneksi stabil try {
await Future.delayed(const Duration(milliseconds: 500)); await bluetoothPrint.connect(_bluetoothDevice!);
// Periksa koneksi lagi // Tunggu sebentar untuk memastikan koneksi stabil
final isConnectedAfterConnect = await _checkBluetoothConnection(); await Future.delayed(const Duration(milliseconds: 500));
if (isConnectedAfterConnect) { // Periksa koneksi lagi
_printToThermalPrinter(); final isConnectedAfterConnect = await _checkBluetoothConnection();
} else { if (isConnectedAfterConnect) {
_printToThermalPrinter();
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Gagal menyambungkan ke printer')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Gagal menyambungkan ke printer')), content: Text('Gagal menyambungkan ke printer: $e')),
); );
} }
} catch (e) { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(
content: Text('Gagal menyambungkan ke printer: $e')), content: Text('Hubungkan printer terlebih dahulu')),
); );
} }
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Hubungkan printer terlebih dahulu')),
);
} }
} },
},
backgroundColor: Colors.purple, backgroundColor: Colors.purple,
), ),
], ],

View File

@ -5,6 +5,8 @@ import 'package:cashumit/models/receipt_item.dart';
import 'package:flutter_esc_pos_utils/flutter_esc_pos_utils.dart'; import 'package:flutter_esc_pos_utils/flutter_esc_pos_utils.dart';
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'dart:io'; import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
/// Service untuk menghasilkan perintah ESC/POS menggunakan flutter_esc_pos_utils /// Service untuk menghasilkan perintah ESC/POS menggunakan flutter_esc_pos_utils
class EscPosPrintService { class EscPosPrintService {
@ -46,8 +48,16 @@ class EscPosPrintService {
return "Rp ${formatter.format(amount)}"; return "Rp ${formatter.format(amount)}";
} }
// Load capability profile // Load capability profile with timeout
final profile = await CapabilityProfile.load(); CapabilityProfile profile;
try {
profile = await CapabilityProfile.load();
} catch (e) {
print('Gagal memuat capability profile: $e');
// Gunakan profile default jika gagal
profile = await CapabilityProfile.load();
}
final generator = Generator(PaperSize.mm58, profile); final generator = Generator(PaperSize.mm58, profile);
// Mulai dengan inisialisasi printer // Mulai dengan inisialisasi printer
@ -56,18 +66,68 @@ class EscPosPrintService {
// Tambahkan logo jika ada path-nya // Tambahkan logo jika ada path-nya
if (logoPath != null && logoPath.isNotEmpty) { if (logoPath != null && logoPath.isNotEmpty) {
try { try {
// Membaca file gambar dari path lokal // Membaca file gambar dari path lokal dengan timeout
final file = File(logoPath); final file = File(logoPath);
if (await file.exists()) { bool fileExists = false;
final Uint8List imageBytes = await file.readAsBytes(); try {
fileExists = await file.exists();
} catch (e) {
print('Error saat memeriksa keberadaan file logo: $e');
}
if (fileExists) {
// Baca file dengan timeout
Uint8List imageBytes;
try {
imageBytes = await file.readAsBytes();
} catch (e) {
print('Gagal membaca file logo: $e');
throw Exception('Gagal membaca file logo');
}
// Decode gambar (format yang didukung: JPEG, PNG) // Decode gambar dengan penanganan error menggunakan isolate
final img.Image? image = img.decodeImage(imageBytes); Uint8List? processedImageBytes;
if (image != null) { try {
bytes += generator.image(image); processedImageBytes = await compute(processImageInIsolate, ImageProcessParams(imageBytes, 200));
} catch (e) {
print('Gagal mendecode gambar: $e');
}
if (processedImageBytes != null) {
// Decode the processed image bytes back to an image object for printing
final img.Image? image = img.decodeImage(processedImageBytes);
if (image != null) {
bytes += generator.image(image);
} else {
// Jika gagal decode gambar, gunakan teks sebagai fallback
bytes += generator.text(storeName,
styles: PosStyles(
bold: true,
height: PosTextSize.size1,
width: PosTextSize.size1,
align: PosAlign.center,
));
}
} else {
// Jika gagal decode gambar, gunakan teks sebagai fallback
bytes += generator.text(storeName,
styles: PosStyles(
bold: true,
height: PosTextSize.size1,
width: PosTextSize.size1,
align: PosAlign.center,
));
} }
} else { } else {
throw Exception('File logo tidak ditemukan'); print('File logo tidak ditemukan: $logoPath');
// Jika file tidak ditemukan, gunakan teks sebagai fallback
bytes += generator.text(storeName,
styles: PosStyles(
bold: true,
height: PosTextSize.size1,
width: PosTextSize.size1,
align: PosAlign.center,
));
} }
} catch (e) { } catch (e) {
print('Gagal memuat logo: $e'); print('Gagal memuat logo: $e');
@ -91,157 +151,244 @@ class EscPosPrintService {
)); ));
} }
bytes += generator.text(storeAddress, try {
styles: PosStyles(align: PosAlign.center)); bytes += generator.text(storeAddress,
bytes += generator.text('Admin: $adminName', styles: PosStyles(align: PosAlign.center));
styles: PosStyles(align: PosAlign.center)); bytes += generator.text('Admin: $adminName',
bytes += generator.text('Telp: $adminPhone', styles: PosStyles(align: PosAlign.center));
styles: PosStyles(align: PosAlign.center)); bytes += generator.text('Telp: $adminPhone',
bytes += generator.text('$formattedDate', styles: PosStyles(align: PosAlign.center));
styles: PosStyles(align: PosAlign.center)); bytes += generator.text('$formattedDate',
styles: PosStyles(align: PosAlign.center));
bytes += generator.feed(1);
// Garis pemisah
bytes += generator.text('================================',
styles: PosStyles(align: PosAlign.center));
// Informasi transaksi
bytes += generator.text('Kasir: $cashierId',
styles: PosStyles(bold: true));
bytes += generator.text('ID Transaksi: $transactionId',
styles: PosStyles());
bytes += generator.feed(1);
// Garis pemisah
bytes += generator.text('================================',
styles: PosStyles(align: PosAlign.center));
// Tabel item
bytes += 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),
),
]);
// Item list
print('Memulai iterasi item...');
for (int i = 0; i < items.length; i++) {
var item = items[i];
print('Item $i: ${item.description}, qty: ${item.quantity}, price: ${item.price}, total: ${item.total}');
// Untuk item dengan detail kuantitas dan harga bytes += generator.feed(1);
// Garis pemisah
bytes += generator.text('================================',
styles: PosStyles(align: PosAlign.center));
// Informasi transaksi
bytes += generator.text('Kasir: $cashierId',
styles: PosStyles(bold: true));
bytes += generator.text('ID Transaksi: $transactionId',
styles: PosStyles());
bytes += generator.feed(1);
// Garis pemisah
bytes += generator.text('================================',
styles: PosStyles(align: PosAlign.center));
// Tabel item
bytes += generator.row([ bytes += generator.row([
PosColumn( PosColumn(
text: item.description, text: 'Item',
width: 12,
styles: PosStyles(align: PosAlign.left),
),
]);
bytes += generator.row([
PosColumn(
text: '${item.quantity} x ${formatRupiah(item.price)}',
width: 7, width: 7,
styles: PosStyles(align: PosAlign.left, height: PosTextSize.size1, width: PosTextSize.size1), styles: PosStyles(bold: true, align: PosAlign.left),
), ),
PosColumn( PosColumn(
text: formatRupiah(item.total), text: 'Total',
width: 5, width: 5,
styles: PosStyles(align: PosAlign.right), styles: PosStyles(bold: true, align: PosAlign.right),
), ),
]); ]);
}
print('Selesai iterasi item'); // Item list dengan penanganan error
print('Memulai iterasi item...');
// Garis pemisah sebelum total for (int i = 0; i < items.length; i++) {
bytes += generator.text('--------------------------------', try {
styles: PosStyles(align: PosAlign.center)); var item = items[i];
print('Item $i: ${item.description}, qty: ${item.quantity}, price: ${item.price}, total: ${item.total}');
// Total
final totalAmount = items.fold(0.0, (sum, item) => sum + item.total); // Untuk item dengan detail kuantitas dan harga
print('Total amount: $totalAmount'); bytes += generator.row([
PosColumn(
bytes += generator.row([ text: item.description,
PosColumn( width: 12,
text: 'TOTAL', styles: PosStyles(align: PosAlign.left),
width: 7, ),
styles: PosStyles(bold: true, align: PosAlign.left), ]);
),
PosColumn( bytes += generator.row([
text: formatRupiah(totalAmount), PosColumn(
width: 5, text: '${item.quantity} x ${formatRupiah(item.price)}',
styles: PosStyles(bold: true, align: PosAlign.right), width: 7,
), styles: PosStyles(align: PosAlign.left, height: PosTextSize.size1, width: PosTextSize.size1),
]); ),
PosColumn(
// Garis pemisah setelah total text: formatRupiah(item.total),
bytes += generator.text('================================', width: 5,
styles: PosStyles(align: PosAlign.center)); styles: PosStyles(align: PosAlign.right),
),
// Memuat teks kustom dari shared preferences ]);
final customDisclaimer = prefs.getString('store_disclaimer_text') ?? } catch (e) {
'Barang yang sudah dibeli tidak dapat dikembalikan/ditukar. ' print('Error saat memproses item $i: $e');
'Harap periksa kembali struk belanja Anda sebelum meninggalkan toko.'; // Lanjutkan ke item berikutnya jika ada error
final customThankYou = prefs.getString('thank_you_text') ?? '*** TERIMA KASIH ***'; continue;
final customPantun = 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.';
// Menambahkan disclaimer
bytes += generator.feed(1);
// Memecah disclaimer menjadi beberapa baris jika terlalu panjang
final disclaimerLines = customDisclaimer.split(' ');
final List<String> wrappedDisclaimer = [];
String currentLine = '';
for (final word in disclaimerLines) {
if ((currentLine + word).length > 32) {
wrappedDisclaimer.add(currentLine.trim());
currentLine = word + ' ';
} else {
currentLine += word + ' ';
} }
} print('Selesai iterasi item');
if (currentLine.trim().isNotEmpty) {
wrappedDisclaimer.add(currentLine.trim()); // Garis pemisah sebelum total
} bytes += generator.text('--------------------------------',
for (final line in wrappedDisclaimer) {
bytes += generator.text(line,
styles: PosStyles(align: PosAlign.center)); styles: PosStyles(align: PosAlign.center));
}
// Total
// Menambahkan ucapan terima kasih double totalAmount = 0.0;
bytes += generator.feed(1); try {
bytes += generator.text(customThankYou, totalAmount = items.fold(0.0, (sum, item) => sum + item.total);
styles: PosStyles(align: PosAlign.center, bold: true)); } catch (e) {
print('Error saat menghitung total: $e');
// Menambahkan pantun totalAmount = 0.0;
bytes += generator.feed(1); }
final pantunLines = customPantun.split('\n');
for (final line in pantunLines) { print('Total amount: $totalAmount');
bytes += generator.text(line,
bytes += 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),
),
]);
// Garis pemisah setelah total
bytes += generator.text('================================',
styles: PosStyles(align: PosAlign.center)); styles: PosStyles(align: PosAlign.center));
}
// Spasi di akhir dan pemotongan kertas // Memuat teks kustom dari shared preferences
bytes += generator.feed(2); String customDisclaimer;
bytes += generator.cut(); try {
customDisclaimer = prefs.getString('store_disclaimer_text') ??
'Barang yang sudah dibeli tidak dapat dikembalikan/ditukar. '
'Harap periksa kembali struk belanja Anda sebelum meninggalkan toko.';
} catch (e) {
print('Error saat memuat disclaimer: $e');
customDisclaimer = 'Barang yang sudah dibeli tidak dapat dikembalikan/ditukar.';
}
String customThankYou;
try {
customThankYou = prefs.getString('thank_you_text') ?? '*** TERIMA KASIH ***';
} catch (e) {
print('Error saat memuat thank you text: $e');
customThankYou = '*** TERIMA KASIH ***';
}
String customPantun;
try {
customPantun = 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.';
} catch (e) {
print('Error saat memuat pantun: $e');
customPantun = '';
}
// Menambahkan disclaimer
bytes += generator.feed(1);
// Memecah disclaimer menjadi beberapa baris jika terlalu panjang
try {
final disclaimerLines = customDisclaimer.split(' ');
final List<String> wrappedDisclaimer = [];
String currentLine = '';
for (final word in disclaimerLines) {
if ((currentLine + word).length > 32) {
wrappedDisclaimer.add(currentLine.trim());
currentLine = word + ' ';
} else {
currentLine += word + ' ';
}
}
if (currentLine.trim().isNotEmpty) {
wrappedDisclaimer.add(currentLine.trim());
}
for (final line in wrappedDisclaimer) {
bytes += generator.text(line,
styles: PosStyles(align: PosAlign.center));
}
} catch (e) {
print('Error saat memproses disclaimer: $e');
// Fallback jika ada error
bytes += generator.text(customDisclaimer,
styles: PosStyles(align: PosAlign.center));
}
// Menambahkan ucapan terima kasih
bytes += generator.feed(1);
bytes += generator.text(customThankYou,
styles: PosStyles(align: PosAlign.center, bold: true));
// Menambahkan pantun jika ada
if (customPantun.isNotEmpty) {
try {
bytes += generator.feed(1);
final pantunLines = customPantun.split('\n');
for (final line in pantunLines) {
bytes += generator.text(line,
styles: PosStyles(align: PosAlign.center));
}
} catch (e) {
print('Error saat menambahkan pantun: $e');
}
}
// Spasi di akhir dan pemotongan kertas
bytes += generator.feed(2);
bytes += generator.cut();
} catch (e) {
print('Error saat membuat bagian akhir struk: $e');
// Pastikan struk tetap dipotong meski ada error
bytes += generator.feed(2);
bytes += generator.cut();
}
print('Jumlah byte yang dihasilkan: ${bytes.length}'); print('Jumlah byte yang dihasilkan: ${bytes.length}');
return bytes; return bytes;
} }
}
/// Data class to hold image processing parameters
class ImageProcessParams {
final Uint8List imageBytes;
final int maxWidth;
ImageProcessParams(this.imageBytes, this.maxWidth);
}
/// Helper function to decode and resize image in an isolate
Uint8List? processImageInIsolate(ImageProcessParams params) {
try {
// Decode image
img.Image? image = img.decodeImage(params.imageBytes);
if (image != null) {
// Resize gambar agar sesuai dengan ukuran kertas printer
if (image.width > params.maxWidth) {
final ratio = params.maxWidth / image.width;
final newHeight = (image.height * ratio).round();
image = img.copyResize(image, width: params.maxWidth, height: newHeight);
}
// Convert image to bytes that can be sent back to main isolate
final imageBytes = Uint8List.fromList(img.encodePng(image));
return imageBytes;
}
return null;
} catch (e) {
print('Error processing image in isolate: $e');
return null;
}
} }

View File

@ -17,6 +17,25 @@ class StrukTextGenerator {
return char * width; return char * width;
} }
/// Fungsi untuk membuat representasi ASCII sederhana dari logo
static String createAsciiLogo(String storeName, int width) {
final buffer = StringBuffer();
// Membuat logo ASCII sederhana dengan nama toko
final int nameLength = storeName.length;
final int boxWidth = nameLength + 4;
final String horizontalLine = '=' * boxWidth;
final String emptyLine = '|${' ' * (boxWidth - 2)}|';
buffer.writeln(centerText(horizontalLine, width));
buffer.writeln(centerText(emptyLine, width));
buffer.writeln(centerText('| $storeName |', width));
buffer.writeln(centerText(emptyLine, width));
buffer.writeln(centerText(horizontalLine, width));
return buffer.toString();
}
/// Menghasilkan struk dalam format teks berdasarkan data transaksi /// Menghasilkan struk dalam format teks berdasarkan data transaksi
static Future<String> generateStrukText({ static Future<String> generateStrukText({
required List<ReceiptItem> items, required List<ReceiptItem> items,
@ -36,11 +55,13 @@ class StrukTextGenerator {
final storeAddress = prefs.getString('store_address') ?? 'Jl. Merdeka No. 123'; final storeAddress = prefs.getString('store_address') ?? 'Jl. Merdeka No. 123';
final adminName = prefs.getString('admin_name') ?? 'Budi Santoso'; final adminName = prefs.getString('admin_name') ?? 'Budi Santoso';
final adminPhone = prefs.getString('admin_phone') ?? '08123456789'; final adminPhone = prefs.getString('admin_phone') ?? '08123456789';
final logoPath = prefs.getString('store_logo_path'); // Load logo path
print('Nama toko: $storeName'); print('Nama toko: $storeName');
print('Alamat toko: $storeAddress'); print('Alamat toko: $storeAddress');
print('Nama admin: $adminName'); print('Nama admin: $adminName');
print('Telepon admin: $adminPhone'); print('Telepon admin: $adminPhone');
print('Logo path: $logoPath');
// Format tanggal // Format tanggal
final dateFormatter = DateFormat('dd/MM/yyyy'); final dateFormatter = DateFormat('dd/MM/yyyy');
@ -56,21 +77,17 @@ class StrukTextGenerator {
// Bangun struk dalam format teks // Bangun struk dalam format teks
final buffer = StringBuffer(); final buffer = StringBuffer();
// Header toko - menggunakan lebar 32 karakter untuk kompatibilitas printer termal // Header toko dengan logo ASCII - menggunakan lebar 32 karakter untuk kompatibilitas printer termal
buffer.writeln(centerText(storeName, 32)); buffer.write(createAsciiLogo(storeName, 32));
buffer.writeln('');
buffer.writeln(centerText(storeAddress, 32)); buffer.writeln(centerText(storeAddress, 32));
buffer.writeln(centerText('Admin: $adminName', 32)); buffer.writeln(centerText('Admin: $adminName', 32));
buffer.writeln(centerText('Telp: $adminPhone', 32)); buffer.writeln(centerText('Telp: $adminPhone', 32));
buffer.writeln(createSeparator(32, '=')); buffer.writeln(centerText(' $formattedDate ', 32));
// Info transaksi
buffer.writeln('TANGGAL : $formattedDate');
buffer.writeln('DARI : Kas Tunai');
buffer.writeln('KE : Pendapatan Harian');
buffer.writeln(createSeparator(32, '=')); buffer.writeln(createSeparator(32, '='));
// Header tabel - disesuaikan lebar kolom untuk printer termal // Header tabel - disesuaikan lebar kolom untuk printer termal
buffer.writeln('ITEM QTY HARGA TOTAL'); buffer.writeln('ITEM QTY HARGA TOTAL');
buffer.writeln(createSeparator(32, '-')); buffer.writeln(createSeparator(32, '-'));
// Item list // Item list
@ -107,12 +124,52 @@ class StrukTextGenerator {
// Garis pemisah setelah total // Garis pemisah setelah total
buffer.writeln(createSeparator(32, '=')); buffer.writeln(createSeparator(32, '='));
// Footer // Memuat teks kustom dari shared preferences
buffer.writeln(centerText('*** TERIMA KASIH ***', 32)); final customDisclaimer = prefs.getString('store_disclaimer_text') ??
buffer.writeln(centerText('Barang yang sudah dibeli', 32)); 'Barang yang sudah dibeli tidak dapat dikembalikan/ditukar. '
buffer.writeln(centerText('tidak dapat dikembalikan', 32)); 'Harap periksa kembali struk belanja Anda sebelum meninggalkan toko.';
final customThankYou = prefs.getString('thank_you_text') ?? '*** TERIMA KASIH ***';
final customPantun = 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.';
// Menambahkan disclaimer
buffer.writeln('');
// Memecah disclaimer menjadi beberapa baris jika terlalu panjang
final disclaimerLines = customDisclaimer.split(' ');
final List<String> wrappedDisclaimer = [];
String currentLine = '';
for (final word in disclaimerLines) {
if ((currentLine + word).length > 32) {
wrappedDisclaimer.add(currentLine.trim());
currentLine = word + ' ';
} else {
currentLine += word + ' ';
}
}
if (currentLine.trim().isNotEmpty) {
wrappedDisclaimer.add(currentLine.trim());
}
for (final line in wrappedDisclaimer) {
buffer.writeln(centerText(line, 32));
}
// Menambahkan ucapan terima kasih
buffer.writeln('');
buffer.writeln(centerText(customThankYou, 32));
// Menambahkan pantun
buffer.writeln('');
final pantunLines = customPantun.split('\n');
for (final line in pantunLines) {
buffer.writeln(centerText(line, 32));
}
// Spasi di akhir // Spasi di akhir
buffer.writeln(''); buffer.writeln('');
buffer.writeln(''); buffer.writeln('');
@ -123,4 +180,4 @@ class StrukTextGenerator {
return result; return result;
} }
} }

View File

@ -216,6 +216,9 @@ public class BluetoothPrintPlugin implements FlutterPlugin, ActivityAware, Metho
case "printTest": case "printTest":
printTest(result); printTest(result);
break; break;
case "printRawData":
printRawData(call, result);
break;
default: default:
result.notImplemented(); result.notImplemented();
break; break;
@ -402,6 +405,60 @@ public class BluetoothPrintPlugin implements FlutterPlugin, ActivityAware, Metho
} }
@SuppressWarnings("unchecked")
private void printRawData(MethodCall call, Result result) {
Map<String, Object> args = call.arguments();
// Tambahkan logging untuk debugging
Log.d(TAG, "******************* Memulai proses printRawData, curMacAddress: " + curMacAddress);
// Periksa apakah curMacAddress sudah diatur
if (curMacAddress == null || curMacAddress.isEmpty()) {
Log.e(TAG, "******************* curMacAddress tidak diatur, tidak bisa mencetak");
result.error("not connected", "Printer address not set", null);
return;
}
final DeviceConnFactoryManager deviceConnFactoryManager = DeviceConnFactoryManager.getDeviceConnFactoryManagers().get(curMacAddress);
if (deviceConnFactoryManager == null) {
Log.e(TAG, "******************* deviceConnFactoryManager tidak ditemukan untuk alamat: " + curMacAddress);
result.error("not connected", "Device connection manager not found", null);
return;
}
// Periksa status koneksi
if (!deviceConnFactoryManager.getConnState()) {
Log.e(TAG, "******************* Printer tidak terhubung, status koneksi: " + deviceConnFactoryManager.getConnState());
result.error("not connected", "Printer not connected", null);
return;
}
if (args != null && args.containsKey("data")) {
final byte[] data = (byte[]) args.get("data");
if(data == null || data.length == 0){
Log.e(TAG, "******************* Data untuk dicetak null atau kosong");
result.error("no data", "Data to print is null or empty", null);
return;
}
Log.d(TAG, "******************* Mengirim data raw untuk dicetak, jumlah byte: " + data.length);
threadPool = ThreadPool.getInstantiation();
threadPool.addSerialTask(new Runnable() {
@Override
public void run() {
assert deviceConnFactoryManager != null;
deviceConnFactoryManager.sendByteDataImmediately(data);
}
});
result.success(true);
}else{
Log.e(TAG, "******************* Data tidak ditemukan");
result.error("please add data", "", null);
}
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private void print(MethodCall call, Result result) { private void print(MethodCall call, Result result) {
Map<String, Object> args = call.arguments(); Map<String, Object> args = call.arguments();

View File

@ -156,4 +156,8 @@ class BluetoothPrint {
} }
Future<dynamic> printTest() => _channel.invokeMethod('printTest'); Future<dynamic> printTest() => _channel.invokeMethod('printTest');
Future<dynamic> printRawData(Uint8List data) {
return _channel.invokeMethod('printRawData', {'data': data});
}
} }

View File

@ -38,6 +38,8 @@ dependencies:
http_auth: ^1.0.4 http_auth: ^1.0.4
bluetooth_print: bluetooth_print:
path: ./plugins path: ./plugins
flutter_esc_pos_utils: ^0.0.1
image: ^3.0.1
intl: ^0.20.2 intl: ^0.20.2
pdf: ^3.8.1 pdf: ^3.8.1
path_provider: ^2.0.14 path_provider: ^2.0.14
@ -71,9 +73,8 @@ flutter:
uses-material-design: true uses-material-design: true
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: assets:
# - images/a_dot_burr.jpeg - assets/images/
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see # An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images # https://flutter.dev/to/resolution-aware-images