893 lines
30 KiB
Dart
893 lines
30 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: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<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),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _viewInvoice() async {
|
|
final receiptProvider = context.read<ReceiptProvider>();
|
|
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<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;
|
|
|
|
// 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<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 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<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,
|
|
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);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|