474 lines
15 KiB
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);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|