fix: pulihkan koneksi Bluetooth dan deteksi kegagalan printer jaringan

Bluetooth: percobaan ulang connect sebelumnya tidak menutup socket lebih dulu, padahal 'read failed, socket might closed or timeout' justru berarti socket RFCOMM masih setengah terbuka — ketiga percobaan gagal identik dan retry-nya sia-sia. Tiap percobaan ulang kini didahului disconnect paksa.

connectBluetooth mengembalikan sukses begitu ADA koneksi tanpa memeriksa apakah itu printer yang diminta, dan tidak pernah mencatat MAC yang aktif. Kini memakai jalur yang sama dengan mencetak.

Izin runtime Bluetooth sebelumnya hanya diminta dari dialog pilih perangkat. Karena reinstall mencabut izin, kasir yang tidak pernah membuka dialog itu gagal cetak tanpa penjelasan. Status adapter, izin, dan pairing kini ikut di log, dan printer yang tidak ter-pairing digagalkan langsung dengan pesan yang menyebut tindakannya.

Jaringan: printTicket hanya menaruh byte di socket lalu menutup koneksi, tidak pernah membaca balik, sehingga kertas habis atau cover terbuka terlihat sama dengan sukses — itu sebabnya kegagalan kitchen tidak pernah muncul di Crashlytics. NetworkPrinterClient baru menanyakan kondisi printer lewat DLE EOT sebelum dan sesudah mencetak, dengan degradasi anggun untuk printer yang tidak mendukungnya.

Panggilan disconnect() ganda dihapus; printTicket sudah flush dan close sendiri, dan panggilan kedua tanpa await membuat error-nya tercatat sebagai crash fatal palsu. Ditambah jejak per station ke Crashlytics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-05 14:06:22 +07:00
co-authored by Claude Opus 5
parent cb7139fc3e
commit 32f64a197a
2 changed files with 347 additions and 58 deletions
@@ -3,7 +3,6 @@ import 'dart:developer';
import 'package:dartz/dartz.dart' hide Order;
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter_esc_pos_network/flutter_esc_pos_network.dart';
import 'package:injectable/injectable.dart' hide Order;
import 'package:permission_handler/permission_handler.dart';
import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart';
@@ -17,6 +16,7 @@ import '../../../presentation/components/print/print_ui.dart';
import '../../auth/datasources/local_data_provider.dart';
import '../../outlet/datasources/local_data_provider.dart';
import '../datasource/local_data_provider.dart';
import '../datasource/network_printer_client.dart';
import '../printer_dtos.dart';
@LazySingleton(as: IPrinterRepository)
@@ -40,6 +40,11 @@ class PrinterRepository implements IPrinterRepository {
/// tiket dua order tidak saling menyelip.
final Lock _jobLock = Lock();
/// Menggantikan `flutter_esc_pos_network` yang hanya mengirim byte lalu
/// menutup koneksi. Klien ini menanyakan kondisi printer dan membaca
/// jawabannya, sehingga kertas habis tidak lagi terlihat sebagai sukses.
final NetworkPrinterClient _networkClient = NetworkPrinterClient();
String? _connectedMac;
@override
@@ -484,31 +489,45 @@ class PrinterRepository implements IPrinterRepository {
List<int> printData,
) async {
try {
final printResult = await _printNetwork(printer.address, printData);
final result = await _networkClient.printTicket(
host: printer.address,
data: printData,
);
if (!printResult) {
FirebaseCrashlytics.instance.recordError(
'Failed to print to Network Printer',
null,
reason: 'Failed to print to Network Printer',
information: [
'function: _printNetwork(printer.address, printData)',
'Printer: ${printer.name}',
'ipAddress: ${printer.address}',
'bytes: ${printData.length}',
'in: $_logName',
],
);
// Dulu ini `right(false)`, yang lolos sebagai sukses ke pemanggil —
// kasir melihat "berhasil dicetak" padahal tidak ada kertas keluar.
return left(
PrinterFailure.dynamicErrorMessage(
'Tidak bisa mencetak ke ${printer.name} (${printer.address})',
),
if (result.isSuccess) {
if (result.paperNearEnd) {
log('Kertas ${_printerLabel(printer)} menipis', name: _logName);
}
log(
'Cetak ${_printerLabel(printer)} selesai — kondisi printer '
'${result.statusSupported ? "dikonfirmasi" : "tidak bisa ditanyakan"}',
name: _logName,
);
return right(true);
}
return right(true);
final failure = result.failure!;
FirebaseCrashlytics.instance.recordError(
'Network print failed: ${failure.name}',
null,
reason: 'Network print failed: ${failure.name}',
information: [
'Printer: ${printer.name}',
'ipAddress: ${printer.address}',
'bytes: ${printData.length}',
'statusSupported: ${result.statusSupported}',
'in: $_logName',
],
);
return left(
PrinterFailure.dynamicErrorMessage(
_networkFailureMessage(printer, failure),
),
);
} catch (e, stackTrace) {
FirebaseCrashlytics.instance.recordError(
e,
@@ -523,45 +542,34 @@ class PrinterRepository implements IPrinterRepository {
}
}
Future<bool> _printNetwork(String ipAddress, List<int> printData) async {
try {
final printer = PrinterNetworkManager(
ipAddress,
port: 9100,
timeout: const Duration(seconds: 15),
);
/// Pesan yang menyebut tindakan yang harus dilakukan kasir, bukan istilah
/// teknis — orang di depan mesin perlu tahu harus mengganti kertas atau
/// menutup cover, bukan membaca kode error.
String _networkFailureMessage(Printer printer, NetworkPrintFailure failure) {
final label = _printerLabel(printer);
final PosPrintResult connect = await printer.connect();
if (connect != PosPrintResult.success) {
log('Failed to connect to Network printer: ${connect.msg}');
return false;
}
final PosPrintResult printing = await printer.printTicket(printData);
printer.disconnect();
if (printing != PosPrintResult.success) {
log('Failed to print via Network printer: ${printing.msg}');
return false;
}
log('Successfully printed via Network printer: $ipAddress');
return true;
} catch (e, stackTrace) {
FirebaseCrashlytics.instance.recordError(
e,
stackTrace,
reason: 'Error printing via Network',
information: [
'function: _printNetwork(String ipAddress, List<int> printData)',
'ipAddress: $ipAddress',
],
);
log('Error printing via Network: $e');
return false;
}
return switch (failure) {
NetworkPrintFailure.unreachable =>
'Printer $label tidak terjangkau. Pastikan printer menyala dan berada di jaringan yang sama dengan tablet.',
NetworkPrintFailure.paperOut =>
'Kertas di printer $label habis. Ganti kertasnya, lalu cetak ulang.',
NetworkPrintFailure.coverOpen =>
'Cover printer $label terbuka. Tutup covernya, lalu cetak ulang.',
NetworkPrintFailure.offline =>
'Printer $label sedang offline atau dalam kondisi error. Periksa printernya, lalu cetak ulang.',
NetworkPrintFailure.writeFailed =>
'Pengiriman data ke printer $label terputus di tengah jalan. Cetak ulang untuk memastikan strukmya utuh.',
};
}
/// Nama printer di database sering kosong, jadi alamatnya selalu ikut
/// disebut supaya pesan errornya tetap bisa ditindaklanjuti.
String _printerLabel(Printer printer) {
final name = printer.name.trim();
return name.isEmpty ? printer.address : '$name (${printer.address})';
}
Future<bool> _writeBytesChunked(List<int> data) async {
for (int i = 0; i < data.length; i += _kChunkSize) {
final end = i + _kChunkSize > data.length ? data.length : i + _kChunkSize;
@@ -683,6 +691,16 @@ class PrinterRepository implements IPrinterRepository {
final result = await _printStation(plan, outlet, user.name);
stopwatch.stop();
// Jejak per station ikut di setiap laporan Crashlytics berikutnya,
// jadi saat ada keluhan "tiket tidak keluar" kita punya urutan
// kejadiannya, bukan cuma satu error tanpa konteks.
FirebaseCrashlytics.instance.log(
'print ${plan.station.name} '
'via ${plan.printer.type}(${plan.printer.address}) '
'${result.isLeft() ? "GAGAL" : "terkirim"} '
'dalam ${stopwatch.elapsedMilliseconds}ms',
);
mark(
plan.station,
result.fold(