fix: atasi glitch exception another scan pada dialog bluetooth printer
This commit is contained in:
parent
59ddce0c37
commit
bbcb925463
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
builder: (dialogContext) => BluetoothScanDialog(
|
||||
bluetoothService: _bluetoothService,
|
||||
onDeviceConnected: (device, ok) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(ok
|
||||
? 'Terhubung ke ${device.name ?? device.address}'
|
||||
: 'Gagal terhubung ke ${device.name ?? device.address}'),
|
||||
),
|
||||
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(() {});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
this.context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(ok
|
||||
? 'Terhubung ke ${dev.name ?? dev.address}'
|
||||
: 'Gagal terhubung ke ${dev.name ?? dev.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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue