cashumit/lib/screens/receipt_screen.dart

474 lines
15 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/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;
// Simpan subscription agar bisa dibatalkan (perbaikan memory leak).
StreamSubscription? _bluetoothStateSubscription;
@override
void initState() {
super.initState();
_initBluetooth();
_loadSavedBluetoothDevice();
WidgetsBinding.instance.addPostFrameCallback((_) {
final receiptProvider = context.read<ReceiptProvider>();
receiptProvider.initialize();
});
}
@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;
receiptProvider.removeItem(groupId, index);
}
// ============ 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();
}
}
void _openLocalReceipts() async {
await Navigator.pushNamed(context, '/local-receipts');
}
@override
Widget build(BuildContext context) {
final receiptProvider = context.watch<ReceiptProvider>();
final state = receiptProvider.state;
return Scaffold(
backgroundColor: Colors.grey[300],
floatingActionButton: ReceiptSpeedDial(
bluetoothService: _bluetoothService,
onCheckConnection: _checkBluetoothConnection,
onPrint: _printToThermalPrinter,
onSettings: _openSettings,
onReloadAccounts: receiptProvider.loadAccounts,
hasItems: state.hasItems,
allGroupsHaveSource: state.allGroupsHaveSource,
hasDestination: state.destinationAccountId != null,
onSendToFirefly: _sendToFirefly,
onPrintingStart: _startPrinting,
onPrintingEnd: _endPrinting,
onOpenLocalReceipts: _openLocalReceipts,
),
body: Stack(
children: [
Center(
child: SingleChildScrollView(
child: Container(
width: 360,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Tear effect atas
Container(
color: const Color(0xFFE0E0E0),
child: const Column(
children: [
SizedBox(height: 15),
ReceiptTearTop(),
],
),
),
// 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,
),
// Tear effect bawah
Container(
color: const Color(0xFFE0E0E0),
child: const Column(
children: [
ReceiptTearBottom(),
SizedBox(height: 15),
],
),
),
],
),
),
),
),
if (_isPrinting)
PrintingStatusCard(
isVisible: _isPrinting,
onDismiss: () {
setState(() => _isPrinting = false);
},
),
],
),
);
}
}