fix: atasi glitch exception another scan pada dialog bluetooth printer

This commit is contained in:
a2nr 2026-09-04 13:05:16 +07:00
parent 59ddce0c37
commit bbcb925463
6 changed files with 310 additions and 172 deletions

View File

@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:cashumit/models/receipt_item.dart';
import 'package:cashumit/screens/add_item_screen.dart';
import 'package:bluetooth_print/bluetooth_print.dart';
import 'package:bluetooth_print/bluetooth_print_model.dart';
import 'package:cashumit/services/bluetooth_service.dart';
import 'package:cashumit/widgets/receipt_body.dart';
import 'package:cashumit/widgets/receipt_tear_effect.dart';
@ -16,6 +15,7 @@ import 'package:cashumit/models/group_submission_result.dart';
import 'package:cashumit/models/receipt_group.dart';
import 'package:cashumit/widgets/group_tags_dialog.dart';
import 'package:cashumit/widgets/cashier_status_bar.dart';
import 'package:cashumit/widgets/bluetooth_scan_dialog.dart';
import 'package:cashumit/services/local_receipt_service.dart';
import 'package:cashumit/services/account_dialog_service.dart';
@ -408,173 +408,21 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
bool isScanning = true;
List<BluetoothDevice> scannedDevices = [];
String? connectingAddress;
StreamSubscription<List<BluetoothDevice>>? scanSub;
return StatefulBuilder(
builder: (context, setDialogState) {
void startScan() async {
setDialogState(() {
isScanning = true;
scannedDevices.clear();
});
try {
await _bluetoothService.startScan(
timeout: const Duration(seconds: 5));
scanSub?.cancel();
scanSub = _bluetoothService.scanResults.listen((devices) {
if (context.mounted) {
setDialogState(() {
scannedDevices = devices;
});
}
});
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal memindai: $e')),
);
}
}
Future.delayed(const Duration(seconds: 5), () {
if (context.mounted) {
setDialogState(() {
isScanning = false;
});
}
});
}
// Mulai scan otomatis saat dialog pertama kali dibuka
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scannedDevices.isEmpty && isScanning && scanSub == null) {
startScan();
}
});
return AlertDialog(
title: Row(
children: [
const Icon(Icons.bluetooth_searching, color: Colors.blue),
const SizedBox(width: 8),
const Expanded(
child: Text('Pindai Printer Bluetooth',
style: TextStyle(fontSize: 16)),
),
if (isScanning)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
content: SizedBox(
width: MediaQuery.of(context).size.width,
height: 320,
child: Column(
children: [
if (isScanning)
const Padding(
padding: EdgeInsets.symmetric(vertical: 6.0),
child: Text(
'Mencari printer thermal di sekitar...',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
),
Expanded(
child: scannedDevices.isEmpty
? Center(
child: isScanning
? const Text('Sedang memindai perangkat...')
: const Text(
'Tidak ada perangkat Bluetooth ditemukan.\nPastikan Bluetooth & printer aktif.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
)
: ListView.builder(
itemCount: scannedDevices.length,
itemBuilder: (context, index) {
final dev = scannedDevices[index];
final isConnectingThis =
connectingAddress == dev.address;
return Card(
margin:
const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: const Icon(Icons.print,
color: Colors.blue),
title: Text(dev.name ?? 'Perangkat Bluetooth',
style: const TextStyle(
fontWeight: FontWeight.w600)),
subtitle: Text(dev.address ?? ''),
trailing: ElevatedButton(
onPressed: isConnectingThis
? null
: () async {
setDialogState(() {
connectingAddress = dev.address;
});
final ok = await _bluetoothService
.connectToDevice(dev);
if (context.mounted) {
scanSub?.cancel();
Navigator.pop(dialogContext);
}
if (mounted) setState(() {});
builder: (dialogContext) => BluetoothScanDialog(
bluetoothService: _bluetoothService,
onDeviceConnected: (device, ok) {
if (mounted) {
ScaffoldMessenger.of(
this.context)
.showSnackBar(
setState(() {});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(ok
? 'Terhubung ke ${dev.name ?? dev.address}'
: 'Gagal terhubung ke ${dev.name ?? dev.address}'),
? 'Terhubung ke ${device.name ?? device.address}'
: 'Gagal terhubung ke ${device.name ?? device.address}'),
),
);
}
},
child: isConnectingThis
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Text('Hubungkan'),
),
),
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: isScanning ? null : () => startScan(),
child: const Text('Pindai Ulang'),
),
TextButton(
onPressed: () {
scanSub?.cancel();
_bluetoothService.stopScan();
Navigator.pop(dialogContext);
},
child: const Text('Tutup'),
),
],
);
},
);
},
);
}

View File

@ -20,10 +20,29 @@ class BluetoothService {
BluetoothDevice? get connectedDevice => _connectedDevice;
BluetoothPrint get bluetoothPrint => _bluetoothPrint;
Stream<List<BluetoothDevice>> get scanResults => _bluetoothPrint.scanResults;
Stream<bool> get isScanning => _bluetoothPrint.isScanning;
bool get isScanningNow => _bluetoothPrint.isScanningSync;
Future<void> startScan({Duration timeout = const Duration(seconds: 4)}) =>
_bluetoothPrint.startScan(timeout: timeout);
Future<void> stopScan() => _bluetoothPrint.stopScan();
Future<void> startScan({Duration timeout = const Duration(seconds: 4)}) async {
try {
if (_bluetoothPrint.isScanningSync) {
await _bluetoothPrint.stopScan();
await Future.delayed(const Duration(milliseconds: 150));
}
await _bluetoothPrint.startScan(timeout: timeout);
} catch (e) {
try {
await _bluetoothPrint.stopScan();
} catch (_) {}
rethrow;
}
}
Future<void> stopScan() async {
try {
await _bluetoothPrint.stopScan();
} catch (_) {}
}
/// Inisialisasi Bluetooth printer
Future<void> initialize() async {
@ -103,7 +122,7 @@ class BluetoothService {
/// Mencetak struk ke printer thermal
Future<void> printReceipt(Uint8List data) async {
if (!_isConnected) {
throw SocketException('Printer tidak terhubung');
throw const SocketException('Printer tidak terhubung');
}
_isPrinting = true;

View File

@ -0,0 +1,209 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:bluetooth_print/bluetooth_print_model.dart';
import '../services/bluetooth_service.dart';
class BluetoothScanDialog extends StatefulWidget {
final BluetoothService bluetoothService;
final void Function(BluetoothDevice device, bool success)? onDeviceConnected;
const BluetoothScanDialog({
super.key,
required this.bluetoothService,
this.onDeviceConnected,
});
@override
State<BluetoothScanDialog> createState() => _BluetoothScanDialogState();
}
class _BluetoothScanDialogState extends State<BluetoothScanDialog> {
bool _isScanning = false;
List<BluetoothDevice> _scannedDevices = [];
String? _connectingAddress;
StreamSubscription<List<BluetoothDevice>>? _scanResultsSub;
StreamSubscription<bool>? _isScanningSub;
@override
void initState() {
super.initState();
// 1. Langsung berlangganan ke stream hasil scan agar perangkat yang ditemukan seketika tampil di UI
_scanResultsSub = widget.bluetoothService.scanResults.listen((devices) {
if (mounted) {
setState(() {
_scannedDevices = devices;
});
}
});
// 2. Berlangganan ke stream status scan untuk sinkronisasi indikator loading
_isScanningSub = widget.bluetoothService.isScanning.listen((scanning) {
if (mounted) {
setState(() {
_isScanning = scanning;
});
}
});
// 3. Mulai pemindaian perdana tepat 1 kali saat dialog dibuka
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_startScan();
}
});
}
@override
void dispose() {
_scanResultsSub?.cancel();
_isScanningSub?.cancel();
widget.bluetoothService.stopScan();
super.dispose();
}
Future<void> _startScan() async {
if (_connectingAddress != null) return;
if (mounted) {
setState(() {
_isScanning = true;
_scannedDevices.clear();
});
}
try {
await widget.bluetoothService.startScan(timeout: const Duration(seconds: 5));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal memindai: $e')),
);
}
}
}
Future<void> _connect(BluetoothDevice device) async {
if (_connectingAddress != null) return;
setState(() {
_connectingAddress = device.address;
});
// Hentikan pemindaian terlebih dahulu sebelum menyambungkan ke BLE device
await widget.bluetoothService.stopScan();
final ok = await widget.bluetoothService.connectToDevice(device);
if (mounted) {
widget.onDeviceConnected?.call(device, ok);
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, _) {
if (didPop) {
widget.bluetoothService.stopScan();
}
},
child: AlertDialog(
title: Row(
children: [
const Icon(Icons.bluetooth_searching, color: Colors.blue),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Pindai Printer Bluetooth',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
if (_isScanning)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
content: SizedBox(
width: MediaQuery.of(context).size.width,
height: 320,
child: Column(
children: [
if (_isScanning)
const Padding(
padding: EdgeInsets.symmetric(vertical: 6.0),
child: Text(
'Mencari printer thermal di sekitar...',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
),
Expanded(
child: _scannedDevices.isEmpty
? Center(
child: _isScanning
? const Text('Sedang memindai perangkat...')
: const Text(
'Tidak ada perangkat Bluetooth ditemukan.\nPastikan Bluetooth & printer aktif.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
)
: ListView.builder(
itemCount: _scannedDevices.length,
itemBuilder: (context, index) {
final dev = _scannedDevices[index];
final isConnectingThis = _connectingAddress == dev.address;
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: const Icon(Icons.print, color: Colors.blue),
title: Text(
dev.name ?? 'Perangkat Bluetooth',
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(dev.address ?? ''),
trailing: ElevatedButton(
onPressed: (_connectingAddress != null)
? null
: () => _connect(dev),
child: isConnectingThis
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Hubungkan'),
),
),
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: (_isScanning || _connectingAddress != null)
? null
: () => _startScan(),
child: const Text('Pindai Ulang'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Tutup'),
),
],
),
);
}
}

View File

@ -41,6 +41,7 @@ class BluetoothPrint {
BehaviorSubject<bool> _isScanning = BehaviorSubject.seeded(false);
Stream<bool> get isScanning => _isScanning.stream;
bool get isScanningSync => _isScanning.value;
BehaviorSubject<List<BluetoothDevice>> _scanResults =
BehaviorSubject.seeded([]);
@ -62,7 +63,7 @@ class BluetoothPrint {
Duration? timeout,
}) async* {
if (_isScanning.value == true) {
throw Exception('Another scan is already in progress.');
await stopScan();
}
// Emit to isScanning

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.6.0+6
version: 1.6.1+7
environment:
sdk: ^3.5.3

View File

@ -0,0 +1,61 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:cashumit/services/bluetooth_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('BluetoothService Tests', () {
late BluetoothService bluetoothService;
setUp(() {
SharedPreferences.setMockInitialValues({});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('bluetooth_print/methods'),
(MethodCall methodCall) async {
switch (methodCall.method) {
case 'state':
return 12; // STATE_ON
case 'startScan':
return null;
case 'stopScan':
return null;
case 'isConnected':
return false;
default:
return null;
}
},
);
bluetoothService = BluetoothService();
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('bluetooth_print/methods'),
null,
);
});
test('initial values and getters work properly', () {
expect(bluetoothService.isConnected, false);
expect(bluetoothService.isPrinting, false);
expect(bluetoothService.connectedDevice, isNull);
expect(bluetoothService.isScanningNow, false);
});
test('stopScan executes gracefully', () async {
await bluetoothService.stopScan();
expect(bluetoothService.isScanningNow, false);
});
test('reconnectIfNeeded returns false when no saved device', () async {
final result = await bluetoothService.reconnectIfNeeded();
expect(result, false);
});
});
}