cashumit/lib/screens/receipt_screen.dart

822 lines
28 KiB
Dart

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: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<ReceiptScreen> createState() => _ReceiptScreenState();
}
class _ReceiptScreenState extends State<ReceiptScreen> {
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>();
receiptProvider.initialize();
});
}
Future<void> _loadUnsubmittedCount() async {
try {
final unsubmitted = await LocalReceiptService.getUnsubmittedReceipts();
if (mounted) {
setState(() {
_unsubmittedCount = unsubmitted.length;
});
}
} catch (_) {}
}
@override
void dispose() {
_bluetoothStateSubscription?.cancel();
super.dispose();
}
Future<void> _loadSavedBluetoothDevice() async {
await _bluetoothService.loadSavedDevice();
}
Future<bool> _checkBluetoothConnection() async {
return await _bluetoothService.checkConnection();
}
Future<void> _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<void> _printToThermalPrinter() async {
final receiptProvider = context.read<ReceiptProvider>();
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<ReceiptProvider>().addGroup();
}
void _removeGroup(String groupId) {
context.read<ReceiptProvider>().removeGroup(groupId);
}
void _addItem(String groupId) async {
final newItem = await showDialog<ReceiptItem>(
context: context,
builder: (context) => const AddItemScreen(),
);
if (newItem != null) {
context.read<ReceiptProvider>().addItem(groupId, newItem);
}
}
void _editItem(String groupId, int index) async {
final receiptProvider = context.read<ReceiptProvider>();
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<ReceiptItem>(
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<ReceiptProvider>();
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),
),
);
}
}
void _newTransaction() async {
final receiptProvider = context.read<ReceiptProvider>();
if (!receiptProvider.state.hasItems) {
receiptProvider.resetReceipt();
return;
}
final confirm = await showDialog<bool>(
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<void> _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<ReceiptProvider>();
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<ReceiptProvider>();
final group = receiptProvider.state.groups.firstWhere(
(g) => g.id == groupId,
orElse: () => ReceiptGroup(id: groupId),
);
final result = await showDialog<List<String>>(
context: context,
builder: (context) => GroupTagsDialog(
initialTags: group.tags,
availableTags: receiptProvider.state.availableTags,
groupName: group.sourceAccountName,
),
);
if (result != null) {
receiptProvider.setGroupTags(groupId, result);
}
}
// ============ AKUN ============
Future<void> _selectGroupSource(String groupId) async {
final receiptProvider = context.read<ReceiptProvider>();
final selectedAccount = await AccountDialogService.showSourceAccountDialog(
context, receiptProvider.state.accounts);
if (selectedAccount != null) {
receiptProvider.selectGroupSource(
groupId,
selectedAccount['id'].toString(),
selectedAccount['name'].toString(),
);
}
}
// ============ TIP ============
Future<void> _setTipSource() async {
final receiptProvider = context.read<ReceiptProvider>();
final selectedAccount = await AccountDialogService.showSourceAccountDialog(
context, receiptProvider.state.accounts);
if (selectedAccount != null) {
receiptProvider.setTipSourceAccount(
selectedAccount['id'].toString(),
selectedAccount['name'].toString(),
);
}
}
void _clearTip() {
context.read<ReceiptProvider>().clearTip();
}
// ============ TANGGAL ============
Future<void> _changeDate() async {
final receiptProvider = context.read<ReceiptProvider>();
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<void> _sendToFirefly() async {
final receiptProvider = context.read<ReceiptProvider>();
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;
// Bangun daftar task dari state struk saat ini
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,
);
// Tampilkan popup pengiriman
final results = await showDialog<List<GroupSubmissionResult>>(
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 grup yang gagal sebagai nota lokal + reset state.
await receiptProvider.saveFailedGroupsLocally(results);
if (!mounted) return;
if (failures.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${successes.length} transaksi berhasil dikirim ke Firefly III'),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${successes.length} berhasil, ${failures.length} gagal disimpan sebagai nota lokal'),
duration: const Duration(seconds: 4),
),
);
}
}
// ============ CONFIG DIALOGS ============
Future<void> _openStoreInfoConfig() async {
await showDialog<bool>(
context: context,
builder: (context) => const StoreInfoConfigDialog(),
);
// Refresh store info widget setelah dialog tutup
if (mounted) {
setState(() {});
}
}
Future<void> _openCustomTextConfig() async {
await showDialog<bool>(
context: context,
builder: (context) => const CustomTextConfigDialog(),
);
if (mounted) {
setState(() {});
}
}
void _openSettings() async {
final result = await Navigator.pushNamed(context, '/config');
if (result == true) {
context.read<ReceiptProvider>().loadCredentialsAndAccounts();
_loadUnsubmittedCount();
}
}
void _openLocalReceipts() async {
await Navigator.pushNamed(context, '/local-receipts');
_loadUnsubmittedCount();
}
@override
Widget build(BuildContext context) {
final receiptProvider = context.watch<ReceiptProvider>();
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,
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),
),
// 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);
},
),
],
),
);
}
}