import 'dart:async'; 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:cashumit/services/bluetooth_service.dart'; import 'package:cashumit/widgets/receipt_body.dart'; import 'package:cashumit/widgets/receipt_tear_effect.dart'; import 'package:cashumit/widgets/store_info_config_dialog.dart'; import 'package:cashumit/widgets/custom_text_config_dialog.dart'; import 'package:cashumit/widgets/receipt_speed_dial.dart'; import 'package:cashumit/widgets/printing_status_card.dart'; import 'package:cashumit/widgets/transaction_submission_dialog.dart'; 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'; import 'package:cashumit/services/esc_pos_print_service.dart'; import 'package:cashumit/services/receipt_service.dart'; import 'package:cashumit/services/invoice_image_service.dart'; import 'package:cashumit/widgets/invoice_preview_dialog.dart'; import 'package:provider/provider.dart'; import 'package:cashumit/providers/receipt_provider.dart'; import 'dart:io'; import 'package:flutter/services.dart'; class ReceiptScreen extends StatefulWidget { const ReceiptScreen({super.key}); @override State createState() => _ReceiptScreenState(); } class _ReceiptScreenState extends State { final BluetoothService _bluetoothService = BluetoothService(); bool _isPrinting = false; int _unsubmittedCount = 0; // Simpan subscription agar bisa dibatalkan (perbaikan memory leak). StreamSubscription? _bluetoothStateSubscription; @override void initState() { super.initState(); _initBluetooth(); _loadSavedBluetoothDevice(); _loadUnsubmittedCount(); WidgetsBinding.instance.addPostFrameCallback((_) { final receiptProvider = context.read(); receiptProvider.initialize(); }); } Future _loadUnsubmittedCount() async { try { final unsubmitted = await LocalReceiptService.getUnsubmittedReceipts(); if (mounted) { setState(() { _unsubmittedCount = unsubmitted.length; }); } } catch (_) {} } @override void dispose() { _bluetoothStateSubscription?.cancel(); super.dispose(); } Future _loadSavedBluetoothDevice() async { await _bluetoothService.loadSavedDevice(); } Future _checkBluetoothConnection() async { return await _bluetoothService.checkConnection(); } Future _initBluetooth() async { await _bluetoothService.initialize(); // Simpan subscription agar bisa dibatalkan di dispose. _bluetoothStateSubscription = _bluetoothService.state.listen((state) { if (!mounted) return; switch (state) { case BluetoothPrint.connected: ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Printer terhubung')), ); break; case BluetoothPrint.disconnected: ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Printer terputus')), ); break; default: break; } }); } Future _printToThermalPrinter() async { final receiptProvider = context.read(); final state = receiptProvider.state; try { final isConnected = await _bluetoothService.reconnectIfNeeded(); if (!isConnected) { if (!mounted) return; if (_bluetoothService.connectedDevice != null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Gagal menyambungkan ke printer')), ); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Harap hubungkan printer terlebih dahulu')), ); } return; } await EscPosPrintService.printToThermalPrinter( groups: state.groups, transactionDate: state.transactionDate, destinationAccountName: state.destinationAccountName, context: context, bluetoothService: _bluetoothService, paymentInfo: PaymentInfo( paymentAmount: state.paymentAmount, isTip: state.hasTip, ), tipSourceAccountName: state.tipSourceAccountName, ); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Perintah cetak dikirim ke printer')), ); } on SocketException catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Koneksi ke printer terputus: ${e.message}')), ); } on PlatformException catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error printer: ${e.message}')), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Gagal mencetak struk: $e')), ); } } void _startPrinting() { setState(() => _isPrinting = true); } void _endPrinting() { setState(() => _isPrinting = false); } // ============ GRUP & ITEM ============ void _addGroup() { context.read().addGroup(); } void _removeGroup(String groupId) { context.read().removeGroup(groupId); } void _addItem(String groupId) async { final newItem = await showDialog( context: context, builder: (context) => const AddItemScreen(), ); if (newItem != null) { context.read().addItem(groupId, newItem); } } void _editItem(String groupId, int index) async { final receiptProvider = context.read(); final group = receiptProvider.state.groups.firstWhere((g) => g.id == groupId); if (index < 0 || index >= group.items.length) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text("Gagal mengedit item: Index tidak valid.")), ); } return; } final originalItem = group.items[index]; final editedItem = await showDialog( context: context, builder: (context) => AddItemScreen.fromItem(originalItem), ); if (editedItem != null) { receiptProvider.editItem(groupId, index, editedItem); } } void _removeItem(String groupId, int index) { final receiptProvider = context.read(); final group = receiptProvider.state.groups.firstWhere((g) => g.id == groupId); if (index < 0 || index >= group.items.length) return; final deletedItem = group.items[index]; receiptProvider.removeItem(groupId, index); if (mounted) { ScaffoldMessenger.of(context).hideCurrentSnackBar(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Item "${deletedItem.description}" dihapus'), action: SnackBarAction( label: 'URUNGKAN', onPressed: () { receiptProvider.restoreItem(groupId, index, deletedItem); }, ), duration: const Duration(seconds: 4), ), ); } } Future _viewInvoice() async { final receiptProvider = context.read(); final state = receiptProvider.state; final groupsWithItems = state.groups.where((g) => g.items.isNotEmpty).toList(); if (groupsWithItems.isEmpty) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Tambahkan item terlebih dahulu')), ); return; } try { final store = await StoreConfig.fromSharedPreferences(); final invoiceNumber = await receiptProvider.ensureInvoiceNumber(); final customerName = state.customerName.isNotEmpty ? state.customerName : (groupsWithItems.first.sourceAccountName ?? state.destinationAccountName ?? ''); final bundle = await InvoiceImageService.prepare( storeName: store.name, storeAddress: store.address, adminName: store.adminName, adminPhone: store.adminPhone, logoPath: store.logoPath, qrisPath: store.qrisPath, disclaimer: store.disclaimer, thankYouText: store.thankYouText, pantunText: store.pantunText, customerName: customerName, date: state.transactionDate, items: groupsWithItems.expand((g) => g.items).toList(), tipAmount: state.hasTip ? state.tipAmount : 0.0, invoiceNumber: invoiceNumber, ); if (!mounted) return; // Render tanpa menyimpan file (preview dulu) final bytes = await InvoiceImageService.renderToPng(bundle.data); if (!mounted) return; await showDialog( context: context, builder: (ctx) => InvoicePreviewDialog( bytes: bytes, invoiceNumber: bundle.invoiceNumber, ), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Gagal membuat invoice: $e')), ); } } void _newTransaction() async { final receiptProvider = context.read(); if (!receiptProvider.state.hasItems) { receiptProvider.resetReceipt(); return; } final confirm = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Transaksi Baru'), content: const Text('Bersihkan semua item dan mulai transaksi baru?'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Batal'), ), ElevatedButton( onPressed: () => Navigator.pop(context, true), child: const Text('Mulai Baru'), ), ], ), ); if (confirm == true) { receiptProvider.resetReceipt(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Struk berhasil dibersihkan')), ); } } } Future _sendAndPrint() async { await _sendToFirefly(); await _loadUnsubmittedCount(); if (mounted) { await _printToThermalPrinter(); } } void _showBluetoothQuickMenu() { showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (sheetContext) { return StatefulBuilder( builder: (context, setSheetState) { final isConnected = _bluetoothService.isConnected; final device = _bluetoothService.connectedDevice; return SafeArea( child: Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 12.0), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( isConnected ? Icons.bluetooth_connected : Icons.bluetooth_disabled, color: isConnected ? Colors.green : Colors.grey, size: 24, ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( isConnected ? (device?.name ?? 'Printer Terhubung') : 'Printer Belum Terhubung', style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16), ), Text( isConnected ? (device?.address ?? '') : 'Ketuk opsi di bawah untuk menyambungkan', style: const TextStyle( color: Colors.grey, fontSize: 12), ), ], ), ), IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.pop(sheetContext), ), ], ), const Divider(height: 20), if (isConnected) ...[ ListTile( leading: const Icon(Icons.print, color: Colors.blue), title: const Text('Uji Cetak (Test Print)'), subtitle: const Text( 'Kirim teks uji coba ke printer thermal'), onTap: () async { Navigator.pop(sheetContext); await _printToThermalPrinter(); }, ), ListTile( leading: const Icon(Icons.link_off, color: Colors.red), title: const Text('Putuskan Koneksi'), onTap: () async { await _bluetoothService.disconnect(); if (mounted) setState(() {}); Navigator.pop(sheetContext); }, ), ] else ...[ ListTile( leading: const Icon(Icons.refresh, color: Colors.green), title: const Text('Sambungkan Ulang ke Printer'), subtitle: Text(device != null ? 'Ke perangkat: ${device.name ?? device.address}' : 'Coba sambungkan ke printer terakhir'), onTap: () async { final success = await _bluetoothService.reconnectIfNeeded(); if (mounted) setState(() {}); Navigator.pop(sheetContext); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(success ? 'Berhasil terhubung ke printer!' : 'Gagal menyambungkan ke printer'), ), ); } }, ), ], ListTile( leading: const Icon(Icons.bluetooth_searching, color: Colors.indigo), title: const Text('Pindai Printer Baru (Scan)'), subtitle: const Text( 'Cari dan pasangkan printer thermal Bluetooth'), onTap: () { Navigator.pop(sheetContext); _showBluetoothScanDialog(); }, ), ], ), ), ); }, ); }, ); } void _showBluetoothScanDialog() { showDialog( context: context, barrierDismissible: false, 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}'), ), ); } }, ), ); } void _showAccountQuickMenu() { final receiptProvider = context.read(); final accounts = receiptProvider.state.accounts; showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (sheetContext) { return SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.account_balance_wallet, color: Colors.blue, size: 24), const SizedBox(width: 10), const Expanded( child: Text( 'Pengaturan Akun Tujuan (Asset)', style: TextStyle( fontWeight: FontWeight.bold, fontSize: 16), ), ), IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.pop(sheetContext), ), ], ), const Divider(height: 20), ListTile( leading: const Icon(Icons.account_balance, color: Colors.teal), title: const Text('Pilih Akun Tujuan (Asset)'), subtitle: Text( receiptProvider.state.destinationAccountName != null ? 'Saat ini: ${receiptProvider.state.destinationAccountName}' : 'Belum dipilih', ), trailing: const Icon(Icons.chevron_right), onTap: () async { Navigator.pop(sheetContext); final selected = await AccountDialogService.showDestinationAccountDialog( context, accounts); if (selected != null) { receiptProvider.selectDestinationAccount( selected['id'].toString(), selected['name'].toString(), ); } }, ), ListTile( leading: const Icon(Icons.sync, color: Colors.blue), title: const Text('Sinkronkan Akun dari Firefly III'), subtitle: const Text('Muat ulang daftar akun terbaru dari server'), onTap: () async { Navigator.pop(sheetContext); await receiptProvider.loadAccounts(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Akun berhasil disinkronkan dari server')), ); } }, ), ], ), ), ); }, ); } void _openGroupTagsDialog(String groupId) async { final receiptProvider = context.read(); final group = receiptProvider.state.groups.firstWhere( (g) => g.id == groupId, orElse: () => ReceiptGroup(id: groupId), ); final result = await showDialog>( context: context, builder: (context) => GroupTagsDialog( initialTags: group.tags, availableTags: receiptProvider.state.availableTags, groupName: group.sourceAccountName, ), ); if (result != null) { receiptProvider.setGroupTags(groupId, result); } } // ============ AKUN ============ Future _selectGroupSource(String groupId) async { final receiptProvider = context.read(); final selectedAccount = await AccountDialogService.showSourceAccountDialog( context, receiptProvider.state.accounts); if (selectedAccount != null) { receiptProvider.selectGroupSource( groupId, selectedAccount['id'].toString(), selectedAccount['name'].toString(), ); } } // ============ TIP ============ Future _setTipSource() async { final receiptProvider = context.read(); final selectedAccount = await AccountDialogService.showSourceAccountDialog( context, receiptProvider.state.accounts); if (selectedAccount != null) { receiptProvider.setTipSourceAccount( selectedAccount['id'].toString(), selectedAccount['name'].toString(), ); } } void _clearTip() { context.read().clearTip(); } // ============ TANGGAL ============ Future _changeDate() async { final receiptProvider = context.read(); final currentDate = receiptProvider.state.transactionDate; final pickedDate = await showDatePicker( context: context, initialDate: currentDate, firstDate: DateTime(2000), lastDate: DateTime(2100), ); if (pickedDate == null) return; if (!mounted) return; final pickedTime = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(currentDate), ); if (pickedTime == null) return; final newDate = DateTime( pickedDate.year, pickedDate.month, pickedDate.day, pickedTime.hour, pickedTime.minute, ); receiptProvider.setTransactionDate(newDate); } // ============ FIREFLY ============ Future _sendToFirefly() async { final receiptProvider = context.read(); final state = receiptProvider.state; // Validasi awal final groupsWithItems = state.groups.where((g) => g.items.isNotEmpty).toList(); if (groupsWithItems.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Tidak ada item untuk dikirim')), ); return; } if (state.destinationAccountId == null || state.fireflyUrl == null || state.accessToken == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Akun tujuan / kredensial belum lengkap')), ); return; } // Hitung tipAmount jika tip aktif final hasTip = state.hasTip; final tipAmount = hasTip ? state.tipAmount : null; // Nomor invoice di-lock sekali per struk (dipakai preview & lampiran) final invoiceNumber = await receiptProvider.ensureInvoiceNumber(); if (!mounted) return; // Bangun daftar task dari state struk saat ini (satu task per struk) final tasks = ReceiptService.buildTasksFromReceipt( groups: groupsWithItems, destinationAccountId: state.destinationAccountId!, destinationAccountName: state.destinationAccountName ?? '', transactionDate: state.transactionDate, tipSourceAccountId: hasTip ? state.tipSourceAccountId : null, tipSourceAccountName: hasTip ? state.tipSourceAccountName : null, tipAmount: tipAmount, customerName: state.customerName, invoiceNumber: invoiceNumber, ); // Tampilkan popup pengiriman final results = await showDialog>( context: context, barrierDismissible: false, builder: (context) => TransactionSubmissionDialog( tasks: tasks, baseUrl: state.fireflyUrl!, accessToken: state.accessToken!, ), ); if (results == null) return; if (!mounted) return; final successes = results.where((r) => r.isSuccess && r.transactionId != null).toList(); final failures = results.where((r) => r.isFailed).toList(); // Link transaksi yang berhasil dapat di-copy/open langsung dari popup card. // Simpan struk yang gagal sebagai nota lokal + reset state. await receiptProvider.saveFailedGroupsLocally(results); if (!mounted) return; if (failures.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( 'Struk berhasil dikirim ke Firefly III sebagai ${tasks.isEmpty ? 0 : tasks.first.splitCount} pecahan'), ), ); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( '${successes.length} struk berhasil, ${failures.length} gagal disimpan sebagai nota lokal'), duration: const Duration(seconds: 4), ), ); } } // ============ CONFIG DIALOGS ============ Future _openStoreInfoConfig() async { await showDialog( context: context, builder: (context) => const StoreInfoConfigDialog(), ); // Refresh store info widget setelah dialog tutup if (mounted) { setState(() {}); } } Future _openCustomTextConfig() async { await showDialog( context: context, builder: (context) => const CustomTextConfigDialog(), ); if (mounted) { setState(() {}); } } void _openSettings() async { final result = await Navigator.pushNamed(context, '/config'); if (result == true) { context.read().loadCredentialsAndAccounts(); _loadUnsubmittedCount(); } } void _openLocalReceipts() async { await Navigator.pushNamed(context, '/local-receipts'); _loadUnsubmittedCount(); } @override Widget build(BuildContext context) { final receiptProvider = context.watch(); final state = receiptProvider.state; final screenWidth = MediaQuery.of(context).size.width; // Lebar tampilan struk yang lebih lebar dan responsif (margin 8px kiri-kanan pada ponsel, maksimal 560px di tablet) final receiptWidth = (screenWidth - 16.0).clamp(320.0, 560.0); return Scaffold( backgroundColor: Colors.grey[300], floatingActionButton: ReceiptSpeedDial( bluetoothService: _bluetoothService, onCheckConnection: _checkBluetoothConnection, onPrint: _printToThermalPrinter, onSettings: _openSettings, hasItems: state.hasItems, allGroupsHaveSource: state.allGroupsHaveSource, hasDestination: state.destinationAccountId != null, onSendToFirefly: _sendToFirefly, onViewInvoice: _viewInvoice, onPrintingStart: _startPrinting, onPrintingEnd: _endPrinting, onSendAndPrint: _sendAndPrint, ), body: Stack( children: [ Center( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 12.0), child: Container( width: receiptWidth, child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ // Tear effect atas Container( color: const Color(0xFFE0E0E0), child: Column( children: [ const SizedBox(height: 15), ReceiptTearTop(width: receiptWidth), ], ), ), // Status bar strip kasir di bawah potongan kertas (menyatu dengan kertas struk) CashierStatusBar( isPrinterConnected: _bluetoothService.isConnected, onPrinterTap: _showBluetoothQuickMenu, unsubmittedReceiptsCount: _unsubmittedCount, onOfflineQueueTap: _openLocalReceipts, destinationAccountName: state.destinationAccountName, onDestinationTap: _showAccountQuickMenu, onNewTransaction: _newTransaction, hasItems: state.hasItems, ), // Receipt body ReceiptBody( groups: state.groups, transactionDate: state.transactionDate, onOpenStoreInfoConfig: _openStoreInfoConfig, onChangeDate: _changeDate, onOpenCustomTextConfig: _openCustomTextConfig, total: state.total, onEditItem: (groupId, index) => _editItem(groupId, index), onRemoveItem: (groupId, index) => _removeItem(groupId, index), onAddItem: (groupId) => _addItem(groupId), onRemoveGroup: (groupId) => _removeGroup(groupId), onSelectGroupSource: (groupId) => _selectGroupSource(groupId), onAddGroup: _addGroup, onSetTip: _setTipSource, onClearTip: _clearTip, availableTags: state.availableTags, onEditGroupTags: (groupId) => _openGroupTagsDialog(groupId), customerName: state.customerName, onCustomerNameChanged: (v) => receiptProvider.setCustomerName(v), ), // Tear effect bawah Container( color: const Color(0xFFE0E0E0), child: Column( children: [ ReceiptTearBottom(width: receiptWidth), const SizedBox(height: 15), ], ), ), ], ), ), ), ), if (_isPrinting) PrintingStatusCard( isVisible: _isPrinting, onDismiss: () { setState(() => _isPrinting = false); }, ), ], ), ); } }