diff --git a/lib/screens/receipt_screen.dart b/lib/screens/receipt_screen.dart index ef60938..2ca3d3e 100644 --- a/lib/screens/receipt_screen.dart +++ b/lib/screens/receipt_screen.dart @@ -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 { showDialog( context: context, barrierDismissible: false, - builder: (dialogContext) { - bool isScanning = true; - List scannedDevices = []; - String? connectingAddress; - StreamSubscription>? 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'), - ), - ], ); - }, - ); - }, + } + }, + ), ); } diff --git a/lib/services/bluetooth_service.dart b/lib/services/bluetooth_service.dart index 46990fe..e1dc895 100644 --- a/lib/services/bluetooth_service.dart +++ b/lib/services/bluetooth_service.dart @@ -20,10 +20,29 @@ class BluetoothService { BluetoothDevice? get connectedDevice => _connectedDevice; BluetoothPrint get bluetoothPrint => _bluetoothPrint; Stream> get scanResults => _bluetoothPrint.scanResults; + Stream get isScanning => _bluetoothPrint.isScanning; + bool get isScanningNow => _bluetoothPrint.isScanningSync; - Future startScan({Duration timeout = const Duration(seconds: 4)}) => - _bluetoothPrint.startScan(timeout: timeout); - Future stopScan() => _bluetoothPrint.stopScan(); + Future 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 stopScan() async { + try { + await _bluetoothPrint.stopScan(); + } catch (_) {} + } /// Inisialisasi Bluetooth printer Future initialize() async { @@ -103,7 +122,7 @@ class BluetoothService { /// Mencetak struk ke printer thermal Future printReceipt(Uint8List data) async { if (!_isConnected) { - throw SocketException('Printer tidak terhubung'); + throw const SocketException('Printer tidak terhubung'); } _isPrinting = true; diff --git a/lib/widgets/bluetooth_scan_dialog.dart b/lib/widgets/bluetooth_scan_dialog.dart new file mode 100644 index 0000000..98ba7d8 --- /dev/null +++ b/lib/widgets/bluetooth_scan_dialog.dart @@ -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 createState() => _BluetoothScanDialogState(); +} + +class _BluetoothScanDialogState extends State { + bool _isScanning = false; + List _scannedDevices = []; + String? _connectingAddress; + + StreamSubscription>? _scanResultsSub; + StreamSubscription? _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 _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 _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'), + ), + ], + ), + ); + } +} diff --git a/plugins/lib/bluetooth_print.dart b/plugins/lib/bluetooth_print.dart index e7406f0..4a694c8 100644 --- a/plugins/lib/bluetooth_print.dart +++ b/plugins/lib/bluetooth_print.dart @@ -41,6 +41,7 @@ class BluetoothPrint { BehaviorSubject _isScanning = BehaviorSubject.seeded(false); Stream get isScanning => _isScanning.stream; + bool get isScanningSync => _isScanning.value; BehaviorSubject> _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 diff --git a/pubspec.yaml b/pubspec.yaml index 3fa6904..d293184 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/bluetooth_service_test.dart b/test/bluetooth_service_test.dart new file mode 100644 index 0000000..9853e7a --- /dev/null +++ b/test/bluetooth_service_test.dart @@ -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); + }); + }); +}