tambah fitur cache nota dan submit ketika server sudah aktif

master
a2nr 2025-11-08 12:15:38 +07:00
parent 77b3f5bb79
commit eece292526
14 changed files with 2259 additions and 108 deletions

65
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,65 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "cashumit",
"request": "launch",
"type": "dart"
},
{
"name": "cashumit (profile mode)",
"request": "launch",
"type": "dart",
"flutterMode": "profile"
},
{
"name": "cashumit (release mode)",
"request": "launch",
"type": "dart",
"flutterMode": "release"
},
{
"name": "plugins",
"cwd": "plugins",
"request": "launch",
"type": "dart"
},
{
"name": "plugins (profile mode)",
"cwd": "plugins",
"request": "launch",
"type": "dart",
"flutterMode": "profile"
},
{
"name": "plugins (release mode)",
"cwd": "plugins",
"request": "launch",
"type": "dart",
"flutterMode": "release"
},
{
"name": "example",
"cwd": "plugins/example",
"request": "launch",
"type": "dart"
},
{
"name": "example (profile mode)",
"cwd": "plugins/example",
"request": "launch",
"type": "dart",
"flutterMode": "profile"
},
{
"name": "example (release mode)",
"cwd": "plugins/example",
"request": "launch",
"type": "dart",
"flutterMode": "release"
}
]
}

3
devtools_options.yaml Normal file
View File

@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

BIN
key/upload-keystore.jks Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -2,13 +2,14 @@ import 'package:cashumit/screens/config_screen.dart';
import 'package:cashumit/screens/transaction_screen.dart';
import 'package:flutter/material.dart';
import 'package:cashumit/screens/receipt_screen.dart';
import 'package:cashumit/screens/local_receipts_screen.dart';
import 'package:provider/provider.dart';
import 'package:cashumit/providers/receipt_provider.dart';
void main() async {
// Ensure WidgetsFlutterBinding is initialized for async operations
WidgetsFlutterBinding.ensureInitialized();
runApp(
MultiProvider(
providers: [
@ -35,7 +36,8 @@ class MyApp extends StatelessWidget {
'/': (context) => const ReceiptScreen(),
'/transaction': (context) => const TransactionScreen(),
'/config': (context) => const ConfigScreen(),
'/local-receipts': (context) => const LocalReceiptsScreen(),
},
);
}
}
}

View File

@ -0,0 +1,104 @@
import 'package:cashumit/models/receipt_item.dart';
class LocalReceipt {
final String id;
final List<ReceiptItem> items;
final String? sourceAccountId;
final String? sourceAccountName;
final String? destinationAccountId;
final String? destinationAccountName;
final DateTime transactionDate;
final String?
transactionDescription; // Deskripsi transaksi untuk tampilan di daftar
final bool isSubmitted;
final String? submissionError;
final DateTime? submittedAt;
final DateTime createdAt;
LocalReceipt({
required this.id,
required this.items,
this.sourceAccountId,
this.sourceAccountName,
this.destinationAccountId,
this.destinationAccountName,
required this.transactionDate,
this.transactionDescription,
this.isSubmitted = false,
this.submissionError,
this.submittedAt,
required this.createdAt,
});
double get total => items.fold(0.0, (sum, item) => sum + item.total);
Map<String, dynamic> toJson() {
return {
'id': id,
'items': items.map((item) => item.toJson()).toList(),
'sourceAccountId': sourceAccountId,
'sourceAccountName': sourceAccountName,
'destinationAccountId': destinationAccountId,
'destinationAccountName': destinationAccountName,
'transactionDate': transactionDate.toIso8601String(),
'transactionDescription': transactionDescription,
'isSubmitted': isSubmitted,
'submissionError': submissionError,
'submittedAt': submittedAt?.toIso8601String(),
'createdAt': createdAt.toIso8601String(),
};
}
factory LocalReceipt.fromJson(Map<String, dynamic> json) {
return LocalReceipt(
id: json['id'],
items: (json['items'] as List)
.map((item) => ReceiptItem.fromJson(item))
.toList(),
sourceAccountId: json['sourceAccountId'],
sourceAccountName: json['sourceAccountName'],
destinationAccountId: json['destinationAccountId'],
destinationAccountName: json['destinationAccountName'],
transactionDate: DateTime.parse(json['transactionDate']),
transactionDescription: json['transactionDescription'],
isSubmitted: json['isSubmitted'] ?? false,
submissionError: json['submissionError'],
submittedAt: json['submittedAt'] != null
? DateTime.parse(json['submittedAt'])
: null,
createdAt: DateTime.parse(json['createdAt']),
);
}
LocalReceipt copyWith({
String? id,
List<ReceiptItem>? items,
String? sourceAccountId,
String? sourceAccountName,
String? destinationAccountId,
String? destinationAccountName,
DateTime? transactionDate,
String? transactionDescription,
bool? isSubmitted,
String? submissionError,
DateTime? submittedAt,
DateTime? createdAt,
}) {
return LocalReceipt(
id: id ?? this.id,
items: items ?? this.items,
sourceAccountId: sourceAccountId ?? this.sourceAccountId,
sourceAccountName: sourceAccountName ?? this.sourceAccountName,
destinationAccountId: destinationAccountId ?? this.destinationAccountId,
destinationAccountName:
destinationAccountName ?? this.destinationAccountName,
transactionDate: transactionDate ?? this.transactionDate,
transactionDescription:
transactionDescription ?? this.transactionDescription,
isSubmitted: isSubmitted ?? this.isSubmitted,
submissionError: submissionError ?? this.submissionError,
submittedAt: submittedAt ?? this.submittedAt,
createdAt: createdAt ?? this.createdAt,
);
}
}

View File

@ -11,9 +11,24 @@ class ReceiptItem {
double get total => quantity * price;
Map<String, dynamic> toJson() {
return {
'description': description,
'quantity': quantity,
'price': price,
};
}
factory ReceiptItem.fromJson(Map<String, dynamic> json) {
return ReceiptItem(
description: json['description'],
quantity: json['quantity'].toDouble(),
price: json['price'].toDouble(),
);
}
@override
String toString() {
return 'ReceiptItem(description: $description, quantity: $quantity, price: $price)';
}
}

View File

@ -5,6 +5,10 @@ import 'package:cashumit/providers/receipt_state.dart';
import 'package:cashumit/models/receipt_item.dart';
import 'package:cashumit/services/receipt_service.dart'; // Pastikan ReceiptService sudah dibuat
import 'package:cashumit/models/firefly_account.dart'; // Untuk tipe akun
import 'package:cashumit/models/local_receipt.dart';
import 'package:cashumit/services/local_receipt_service.dart';
import 'package:cashumit/services/account_cache_service.dart';
import 'dart:math';
class ReceiptProvider with ChangeNotifier {
late ReceiptState _state;
@ -24,7 +28,7 @@ class ReceiptProvider with ChangeNotifier {
/// Memuat kredensial dan akun
Future<void> loadCredentialsAndAccounts() async {
final credentials = await ReceiptService.loadCredentials();
if (credentials == null) {
// Jika tidak ada kredensial, kita tetap perlu memberitahu listener bahwa state berubah
// (misalnya untuk menampilkan pesan error di UI)
@ -38,7 +42,8 @@ class ReceiptProvider with ChangeNotifier {
}
// Periksa apakah kredensial berubah
final credentialsChanged = _state.fireflyUrl != credentials['url'] || _state.accessToken != credentials['token'];
final credentialsChanged = _state.fireflyUrl != credentials['url'] ||
_state.accessToken != credentials['token'];
_state = _state.copyWith(
fireflyUrl: credentials['url'],
@ -49,32 +54,47 @@ class ReceiptProvider with ChangeNotifier {
// Jika kredensial ada dan berubah, lanjutkan untuk memuat akun
if (credentialsChanged) {
await loadAccounts();
// Juga perbarui cache akun dari server
try {
await ReceiptService.updateAccountCache(
baseUrl: _state.fireflyUrl!,
accessToken: _state.accessToken!,
);
} catch (e) {
print('Gagal memperbarui cache akun: $e');
}
} else if (_state.accounts.isEmpty) {
// Jika akun belum pernah dimuat, muat sekarang
await loadAccounts();
}
}
/// Memuat daftar akun
/// Memuat daftar akun dengan prioritas: server > cache > fallback
Future<void> loadAccounts() async {
if (_state.fireflyUrl == null || _state.accessToken == null) {
// Jika kredensial tidak ada, coba ambil dari cache
final cachedAccounts = await AccountCacheService.getCachedAccounts();
if (cachedAccounts.isNotEmpty) {
_state = _state.copyWith(accounts: cachedAccounts);
notifyListeners();
}
return;
}
try {
final allAccounts = await ReceiptService.loadAccounts(
// Gunakan cache service untuk mendapatkan akun dengan prioritas
final allAccounts =
await AccountCacheService.getAccountsWithFallback(() async {
final accounts = await ReceiptService.loadAccounts(
baseUrl: _state.fireflyUrl!,
accessToken: _state.accessToken!,
);
// Perbarui cache dengan data terbaru dari server
await ReceiptService.saveAccountsToCache(accounts);
return accounts;
});
_state = _state.copyWith(accounts: allAccounts);
notifyListeners();
} catch (error) {
// Error handling bisa dilakukan di sini atau dibiarkan untuk ditangani oleh UI
print('Error in ReceiptProvider.loadAccounts: $error');
// Bisa memicu state error jika diperlukan
notifyListeners(); // Tetap notify untuk memperbarui UI (misalnya menampilkan pesan error)
}
_state = _state.copyWith(accounts: allAccounts);
notifyListeners();
}
/// Menambahkan item ke receipt
@ -88,10 +108,10 @@ class ReceiptProvider with ChangeNotifier {
/// Mengedit item di receipt
void editItem(int index, ReceiptItem newItem) {
if (index < 0 || index >= _state.items.length) return;
final updatedItems = List<ReceiptItem>.from(_state.items);
updatedItems[index] = newItem;
_state = _state.copyWith(
items: updatedItems,
);
@ -101,9 +121,9 @@ class ReceiptProvider with ChangeNotifier {
/// Menghapus item dari receipt
void removeItem(int index) {
if (index < 0 || index >= _state.items.length) return;
final updatedItems = List<ReceiptItem>.from(_state.items)..removeAt(index);
_state = _state.copyWith(
items: updatedItems,
);
@ -150,7 +170,7 @@ class ReceiptProvider with ChangeNotifier {
// Pastikan akun sumber dan tujuan dipilih
// Logika untuk mencari ID berdasarkan nama jika diperlukan bisa ditambahkan di sini
// atau diharapkan UI sudah menangani ini sebelum memanggil submitTransaction
if (_state.sourceAccountId == null || _state.destinationAccountId == null) {
throw Exception('Akun sumber dan tujuan harus dipilih');
}
@ -167,6 +187,87 @@ class ReceiptProvider with ChangeNotifier {
return transactionId;
}
/// Simpan transaksi ke penyimpanan lokal (ini akan menyimpan transaksi lokal bukan langsung submit ke server)
Future<String?> saveTransactionLocally() async {
if (_state.items.isEmpty) {
throw Exception('Tidak ada item untuk disimpan');
}
if (_state.sourceAccountId == null || _state.destinationAccountId == null) {
throw Exception('Akun sumber dan tujuan harus dipilih');
}
// Buat ID unik untuk receipt lokal
final receiptId =
'receipt_${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(1000)}';
// Generate transaction description
final transactionDescription =
_generateTransactionDescription(_state.items);
// Buat objek LocalReceipt
final localReceipt = LocalReceipt(
id: receiptId,
items: _state.items,
sourceAccountId: _state.sourceAccountId,
sourceAccountName: _state.sourceAccountName,
destinationAccountId: _state.destinationAccountId,
destinationAccountName: _state.destinationAccountName,
transactionDate: _state.transactionDate,
transactionDescription: transactionDescription,
createdAt: DateTime.now(),
);
// Simpan ke penyimpanan lokal
await LocalReceiptService.saveReceipt(localReceipt);
// Kosongkan items di state setelah disimpan
_state = _state.copyWith(
items: [],
sourceAccountId: null,
sourceAccountName: null,
destinationAccountId: null,
destinationAccountName: null,
);
notifyListeners();
return receiptId;
}
/// Submit semua receipt lokal yang belum terkirim ke server
Future<Map<String, dynamic>> submitAllLocalReceipts() async {
if (_state.fireflyUrl == null || _state.accessToken == null) {
throw Exception('Kredensial Firefly III tidak ditemukan');
}
// Submit semua receipt lokal yang belum terkirim
final result = await LocalReceiptService.submitAllUnsubmittedReceipts(
_state.fireflyUrl!,
_state.accessToken!,
);
notifyListeners(); // Update UI jika ada perubahan
return result;
}
/// Generate transaction description from items
String _generateTransactionDescription(List<ReceiptItem> items) {
if (items.isEmpty) {
return 'Transaksi Struk Belanja';
}
// Take the first 5 item descriptions
final itemNames = items.take(5).map((item) => item.description).toList();
// If there are more than 5 items, append ', dll' to the last item
if (items.length > 5) {
itemNames[4] += ', dll';
}
// Join the item names with ', '
return itemNames.join(', ');
}
// Tambahkan metode lain sesuai kebutuhan, seperti untuk memperbarui transactionDate jika diperlukan
}
}

View File

@ -0,0 +1,402 @@
import 'package:flutter/material.dart';
import 'package:cashumit/models/local_receipt.dart';
import 'package:cashumit/services/local_receipt_service.dart';
import 'package:cashumit/services/receipt_service.dart';
import 'package:intl/intl.dart';
class LocalReceiptsScreen extends StatefulWidget {
const LocalReceiptsScreen({super.key});
@override
State<LocalReceiptsScreen> createState() => _LocalReceiptsScreenState();
}
class _LocalReceiptsScreenState extends State<LocalReceiptsScreen> {
List<LocalReceipt> receipts = [];
bool isLoading = true;
bool isSubmitting = false;
@override
void initState() {
super.initState();
_loadReceipts();
}
Future<void> _loadReceipts() async {
setState(() {
isLoading = true;
});
try {
final loadedReceipts = await LocalReceiptService.getReceipts();
setState(() {
receipts = loadedReceipts;
isLoading = false;
});
} catch (e) {
setState(() {
isLoading = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal memuat daftar nota: $e')),
);
}
}
}
Future<void> _submitAllReceipts() async {
final credentials = await ReceiptService.loadCredentials();
if (credentials == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Silakan konfigurasi kredensial FireFly III terlebih dahulu'),
),
);
}
return;
}
setState(() {
isSubmitting = true;
});
try {
final result = await LocalReceiptService.submitAllUnsubmittedReceipts(
credentials['url']!,
credentials['token']!,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Berhasil: ${result['successCount']} berhasil, ${result['failureCount']} gagal dari ${result['totalCount']} total nota',
),
),
);
}
await _loadReceipts(); // Refresh daftar setelah submit
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal mengirim nota: $e')),
);
}
} finally {
setState(() {
isSubmitting = false;
});
}
}
Future<void> _deleteReceipt(String receiptId) async {
try {
await LocalReceiptService.removeReceipt(receiptId);
await _loadReceipts();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Nota berhasil dihapus')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal menghapus nota: $e')),
);
}
}
}
String _formatCurrency(double amount) {
final formatter = NumberFormat.currency(
locale: 'id_ID',
symbol: 'Rp ',
decimalDigits: 0,
);
return formatter.format(amount).replaceAll('.00', '');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Nota Tersimpan'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: isLoading ? null : _loadReceipts,
),
],
),
body: Column(
children: [
// Summary card
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.3),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Column(
children: [
Text(
receipts
.where((r) => !r.isSubmitted)
.length
.toString(),
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.orange,
),
),
const Text(
'Menunggu',
style: TextStyle(color: Colors.grey),
),
],
),
Column(
children: [
Text(
receipts
.where((r) => r.isSubmitted)
.length
.toString(),
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
const Text(
'Terkirim',
style: TextStyle(color: Colors.grey),
),
],
),
Column(
children: [
Text(
receipts.length.toString(),
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
const Text(
'Total',
style: TextStyle(color: Colors.grey),
),
],
),
],
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: isSubmitting ? null : _submitAllReceipts,
icon: isSubmitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Icon(Icons.sync),
label:
Text(isSubmitting ? 'Mengirim...' : 'Kirim Semua Nota'),
),
],
),
),
// Receipts list
Expanded(
child: isLoading
? const Center(child: CircularProgressIndicator())
: receipts.isEmpty
? const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.receipt_outlined,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'Belum ada nota tersimpan',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
],
),
)
: RefreshIndicator(
onRefresh: _loadReceipts,
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: receipts.length,
itemBuilder: (context, index) {
final receipt = receipts[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
contentPadding: const EdgeInsets.all(16),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: receipt.isSubmitted
? Colors.green.shade100
: Colors.orange.shade100,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
receipt.isSubmitted
? Icons.check_circle
: Icons.access_time,
color: receipt.isSubmitted
? Colors.green
: Colors.orange,
size: 20,
),
),
title: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
// Transaction description di kiri
Expanded(
flex: 2,
child: Text(
receipt.transactionDescription ??
'Transaksi Struk Belanja',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
// Total di kanan
Expanded(
child: Text(
_formatCurrency(receipt.total),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: Colors.blue,
),
textAlign: TextAlign.right,
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
'Dibuat: ${DateFormat('dd/MM/yyyy HH:mm').format(receipt.createdAt)}',
style: const TextStyle(fontSize: 12),
),
if (receipt.isSubmitted)
Text(
'Dikirim: ${DateFormat('dd/MM/yyyy HH:mm').format(receipt.submittedAt ?? receipt.createdAt)}',
style: const TextStyle(
fontSize: 12,
color: Colors.green,
),
),
if (receipt.submissionError != null)
Text(
'Error: ${receipt.submissionError}',
style: const TextStyle(
fontSize: 12,
color: Colors.red,
),
),
],
),
trailing: PopupMenuButton<String>(
onSelected: (String action) {
if (action == 'delete') {
_showDeleteConfirmation(receipt.id);
}
},
itemBuilder: (BuildContext context) {
return [
const PopupMenuItem<String>(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, size: 18),
SizedBox(width: 8),
Text('Hapus'),
],
),
),
];
},
),
),
);
},
),
),
),
],
),
);
}
Future<void> _showDeleteConfirmation(String receiptId) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Konfirmasi Hapus'),
content: const Text('Apakah Anda yakin ingin menghapus nota ini?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Batal'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Hapus'),
),
],
),
);
if (result == true) {
_deleteReceipt(receiptId);
}
}
}

View File

@ -4,6 +4,7 @@ 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/screens/local_receipts_screen.dart';
// Import widget komponen struk baru
import 'package:cashumit/widgets/receipt_tear_effect.dart';
@ -35,7 +36,7 @@ class ReceiptScreen extends StatefulWidget {
class _ReceiptScreenState extends State<ReceiptScreen> {
// Bluetooth service
final BluetoothService _bluetoothService = BluetoothService();
// Printing status
bool _isPrinting = false;
@ -46,7 +47,7 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
_initBluetooth();
_loadSavedBluetoothDevice();
print('Selesai inisialisasi ReceiptScreen');
// Panggil inisialisasi provider setelah widget dibuat
WidgetsBinding.instance.addPostFrameCallback((_) {
final receiptProvider = context.read<ReceiptProvider>();
@ -114,7 +115,7 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
Future<void> _printToThermalPrinter() async {
final receiptProvider = context.read<ReceiptProvider>();
final state = receiptProvider.state;
try {
// Cek dan reconnect jika perlu
final isConnected = await _bluetoothService.reconnectIfNeeded();
@ -122,25 +123,24 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
if (!mounted) return;
if (_bluetoothService.connectedDevice != null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Gagal menyambungkan ke printer')),
const SnackBar(content: Text('Gagal menyambungkan ke printer')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Harap hubungkan printer terlebih dahulu')),
content: Text('Harap hubungkan printer terlebih dahulu')),
);
}
return;
}
await EscPosPrintService.printToThermalPrinter(
items: state.items,
transactionDate: state.transactionDate,
context: context,
bluetoothService: _bluetoothService,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Perintah cetak dikirim ke printer')),
@ -165,14 +165,14 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
);
}
}
/// Methods to control printing status
void _startPrinting() {
setState(() {
_isPrinting = true;
});
}
void _endPrinting() {
setState(() {
_isPrinting = false;
@ -195,13 +195,13 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
void _editItem(int index) async {
final receiptProvider = context.read<ReceiptProvider>();
final state = receiptProvider.state;
// Pastikan index valid sebelum mengakses item
if (index < 0 || index >= state.items.length) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Gagal mengedit item: Index tidak valid.")),
content: Text("Gagal mengedit item: Index tidak valid.")),
);
}
return;
@ -228,18 +228,14 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
Future<void> _selectSourceAccount() async {
final receiptProvider = context.read<ReceiptProvider>();
final state = receiptProvider.state;
final selectedAccount = await AccountDialogService.showSourceAccountDialog(
context,
state.accounts
);
context, state.accounts);
if (selectedAccount != null) {
// Memperbarui state melalui provider
receiptProvider.selectSourceAccount(
selectedAccount['id'].toString(),
selectedAccount['name'].toString()
);
selectedAccount['id'].toString(), selectedAccount['name'].toString());
}
}
@ -247,18 +243,15 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
Future<void> _selectDestinationAccount() async {
final receiptProvider = context.read<ReceiptProvider>();
final state = receiptProvider.state;
final selectedAccount = await AccountDialogService.showDestinationAccountDialog(
context,
state.accounts
);
final selectedAccount =
await AccountDialogService.showDestinationAccountDialog(
context, state.accounts);
if (selectedAccount != null) {
// Memperbarui state melalui provider
receiptProvider.selectDestinationAccount(
selectedAccount['id'].toString(),
selectedAccount['name'].toString()
);
selectedAccount['id'].toString(), selectedAccount['name'].toString());
}
}
@ -266,20 +259,21 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
Future<void> _sendToFirefly() async {
final receiptProvider = context.read<ReceiptProvider>();
final state = receiptProvider.state;
try {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Mengirim transaksi ke Firefly III...')),
);
final transactionId = await receiptProvider.submitTransaction();
if (!mounted) return;
if (transactionId != null && transactionId != "success") {
// Navigasi ke WebViewScreen untuk menampilkan transaksi
if (state.fireflyUrl != null) {
final transactionUrl = '${state.fireflyUrl}/transactions/show/$transactionId';
final transactionUrl =
'${state.fireflyUrl}/transactions/show/$transactionId';
if (mounted) {
Navigator.push(
context,
@ -296,15 +290,15 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Transaksi berhasil dikirim ke Firefly III (tanpa ID)')),
content:
Text('Transaksi berhasil dikirim ke Firefly III (tanpa ID)')),
);
} else {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Gagal mengirim transaksi ke Firefly III. Periksa log untuk detail kesalahan.')),
content: Text(
'Gagal mengirim transaksi ke Firefly III. Periksa log untuk detail kesalahan.')),
);
}
} catch (e) {
@ -355,6 +349,12 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
}
}
/// Membuka layar daftar receipt lokal.
void _openLocalReceipts() async {
// Gunakan route yang sudah didefinisikan di main.dart
await Navigator.pushNamed(context, '/local-receipts');
}
final TextStyle baseTextStyle = const TextStyle(
fontFamily: 'Courier', // Gunakan font courier jika tersedia
fontSize: 14,
@ -369,23 +369,23 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
backgroundColor:
Colors.grey[300], // Latar belakang abu-abu untuk efek struk
floatingActionButton: Consumer<ReceiptProvider>(
builder: (context, receiptProvider, child) {
final state = receiptProvider.state;
return ReceiptSpeedDial(
bluetoothService: _bluetoothService,
onCheckConnection: _checkBluetoothConnection,
onPrint: _printToThermalPrinter,
onSettings: _openSettings,
onReloadAccounts: receiptProvider.loadAccounts,
hasItems: state.items.isNotEmpty,
hasSourceAccount: state.sourceAccountId != null,
hasDestinationAccount: state.destinationAccountId != null,
onSendToFirefly: _sendToFirefly,
onPrintingStart: _startPrinting,
onPrintingEnd: _endPrinting,
);
}
),
builder: (context, receiptProvider, child) {
final state = receiptProvider.state;
return ReceiptSpeedDial(
bluetoothService: _bluetoothService,
onCheckConnection: _checkBluetoothConnection,
onPrint: _printToThermalPrinter,
onSettings: _openSettings,
onReloadAccounts: receiptProvider.loadAccounts,
hasItems: state.items.isNotEmpty,
hasSourceAccount: state.sourceAccountId != null,
hasDestinationAccount: state.destinationAccountId != null,
onSendToFirefly: _sendToFirefly,
onPrintingStart: _startPrinting,
onPrintingEnd: _endPrinting,
onOpenLocalReceipts: _openLocalReceipts,
);
}),
body: SafeArea(
child: Center(
// Membungkus dengan widget Center untuk memastikan struk berada di tengah
@ -397,7 +397,8 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
// Background untuk efek kertas struk tersobek di bagian atas
Container(
width: 360,
color: const Color(0xFFE0E0E0), // Warna latar belakang sesuai dengan latar belakang layar
color: const Color(
0xFFE0E0E0), // Warna latar belakang sesuai dengan latar belakang layar
child: const Column(
children: [
SizedBox(height: 15), // Jarak atas yang lebih besar
@ -408,42 +409,46 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
// Konten struk
Consumer<ReceiptProvider>(
builder: (context, receiptProvider, child) {
final state = receiptProvider.state;
return ReceiptBody(
items: state.items,
sourceAccountName: state.sourceAccountName,
destinationAccountName: state.destinationAccountName,
onOpenStoreInfoConfig: _openStoreInfoConfig,
onSelectSourceAccount: () => _selectSourceAccount(), // Memanggil fungsi langsung
onSelectDestinationAccount: () => _selectDestinationAccount(), // Memanggil fungsi langsung
onOpenCustomTextConfig: _openCustomTextConfig,
total: state.total,
onEditItem: (index) => _editItem(index), // Memanggil fungsi langsung
onRemoveItem: (index) {
// Validasi index untuk mencegah error
if (index >= 0 && index < state.items.length) {
// Hapus item dari daftar melalui provider
receiptProvider.removeItem(index);
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
builder: (context, receiptProvider, child) {
final state = receiptProvider.state;
return ReceiptBody(
items: state.items,
sourceAccountName: state.sourceAccountName,
destinationAccountName: state.destinationAccountName,
onOpenStoreInfoConfig: _openStoreInfoConfig,
onSelectSourceAccount: () =>
_selectSourceAccount(), // Memanggil fungsi langsung
onSelectDestinationAccount: () =>
_selectDestinationAccount(), // Memanggil fungsi langsung
onOpenCustomTextConfig: _openCustomTextConfig,
total: state.total,
onEditItem: (index) =>
_editItem(index), // Memanggil fungsi langsung
onRemoveItem: (index) {
// Validasi index untuk mencegah error
if (index >= 0 && index < state.items.length) {
// Hapus item dari daftar melalui provider
receiptProvider.removeItem(index);
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"Gagal menghapus item: Index tidak valid.")),
);
}
);
}
},
onAddItem: () => _addItem(), // Memanggil fungsi langsung
);
}
),
}
},
onAddItem: () =>
_addItem(), // Memanggil fungsi langsung
);
}),
// Background untuk efek kertas struk tersobek di bagian bawah
Container(
width: 360,
color: const Color(0xFFE0E0E0), // Warna latar belakang sesuai dengan latar belakang layar
color: const Color(
0xFFE0E0E0), // Warna latar belakang sesuai dengan latar belakang layar
child: const Column(
children: [
ReceiptTearBottom(), // Efek kertas struk tersobek di bagian bawah
@ -468,4 +473,4 @@ class _ReceiptScreenState extends State<ReceiptScreen> {
],
);
}
}
}

View File

@ -0,0 +1,85 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
class AccountCacheService {
static const String _accountsKey = 'cached_accounts';
static const String _lastUpdatedKey = 'accounts_last_updated';
/// Simpan akun ke cache lokal
static Future<void> saveAccountsLocally(
List<Map<String, dynamic>> accounts) async {
final prefs = await SharedPreferences.getInstance();
// Konversi akun ke JSON string
final accountsJson =
accounts.map((account) => json.encode(account)).toList();
await prefs.setStringList(_accountsKey, accountsJson);
await prefs.setInt(_lastUpdatedKey, DateTime.now().millisecondsSinceEpoch);
}
/// Ambil akun dari cache lokal
static Future<List<Map<String, dynamic>>> getCachedAccounts() async {
final prefs = await SharedPreferences.getInstance();
final accountsJsonList = prefs.getStringList(_accountsKey) ?? [];
if (accountsJsonList.isEmpty) {
return [];
}
return accountsJsonList
.map((jsonString) => json.decode(jsonString) as Map<String, dynamic>)
.toList();
}
/// Periksa apakah cache akun masih valid (kurang dari 1 jam)
static Future<bool> isCacheValid() async {
final prefs = await SharedPreferences.getInstance();
final lastUpdated = prefs.getInt(_lastUpdatedKey) ?? 0;
final now = DateTime.now().millisecondsSinceEpoch;
// Cache valid selama 1 jam (360000 ms)
return (now - lastUpdated) < 360000;
}
/// Hapus cache akun
static Future<void> clearCache() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_accountsKey);
await prefs.remove(_lastUpdatedKey);
}
/// Update cache akun dari server
static Future<void> updateAccountsFromServer(
List<Map<String, dynamic>> accounts) async {
await saveAccountsLocally(accounts);
}
/// Dapatkan akun dengan prioritas: cache valid > data server > fallback kosong
static Future<List<Map<String, dynamic>>> getAccountsWithFallback(
Future<List<Map<String, dynamic>>> Function() serverFetchFunction,
) async {
// Coba ambil dari cache dulu
if (await isCacheValid()) {
final cachedAccounts = await getCachedAccounts();
if (cachedAccounts.isNotEmpty) {
return cachedAccounts;
}
}
// Jika cache tidak valid atau kosong, coba ambil dari server
try {
final serverAccounts = await serverFetchFunction();
if (serverAccounts.isNotEmpty) {
// Simpan ke cache jika berhasil ambil dari server
await updateAccountsFromServer(serverAccounts);
return serverAccounts;
}
} catch (e) {
print('Gagal mengambil akun dari server: $e');
}
// Jika semua gagal, kembalikan cache terakhir (meskipun mungkin expired)
return await getCachedAccounts();
}
}

View File

@ -0,0 +1,227 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
import 'package:cashumit/models/local_receipt.dart';
import 'package:cashumit/models/receipt_item.dart';
import 'package:cashumit/services/firefly_api_service.dart';
class LocalReceiptService {
static const String _receiptsKey = 'local_receipts';
/// Save a receipt to local storage
static Future<void> saveReceipt(LocalReceipt receipt) async {
final prefs = await SharedPreferences.getInstance();
final receipts = await getReceipts();
// Check if receipt with the same ID already exists
final existingIndex = receipts.indexWhere((r) => r.id == receipt.id);
if (existingIndex != -1) {
// Replace existing receipt
receipts[existingIndex] = receipt;
} else {
// Add new receipt
receipts.add(receipt);
}
// Convert receipts to JSON strings
final receiptJsonList =
receipts.map((receipt) => json.encode(receipt.toJson())).toList();
await prefs.setStringList(_receiptsKey, receiptJsonList);
}
/// Get all receipts from local storage
static Future<List<LocalReceipt>> getReceipts() async {
final prefs = await SharedPreferences.getInstance();
final receiptJsonList = prefs.getStringList(_receiptsKey) ?? [];
return receiptJsonList
.map((jsonString) => LocalReceipt.fromJson(json.decode(jsonString)))
.toList();
}
/// Get unsubmitted receipts from local storage
static Future<List<LocalReceipt>> getUnsubmittedReceipts() async {
final allReceipts = await getReceipts();
return allReceipts.where((receipt) => !receipt.isSubmitted).toList();
}
/// Get submitted receipts from local storage
static Future<List<LocalReceipt>> getSubmittedReceipts() async {
final allReceipts = await getReceipts();
return allReceipts.where((receipt) => receipt.isSubmitted).toList();
}
/// Update a receipt's submission status
static Future<void> updateReceiptSubmissionStatus(String receiptId,
{bool? isSubmitted, String? submissionError}) async {
final prefs = await SharedPreferences.getInstance();
final receipts = await getReceipts();
final receiptIndex = receipts.indexWhere((r) => r.id == receiptId);
if (receiptIndex != -1) {
final receipt = receipts[receiptIndex];
receipts[receiptIndex] = receipt.copyWith(
isSubmitted: isSubmitted ?? receipt.isSubmitted,
submissionError: submissionError ?? receipt.submissionError,
submittedAt: isSubmitted == true ? DateTime.now() : receipt.submittedAt,
);
// Convert receipts to JSON strings
final receiptJsonList =
receipts.map((receipt) => json.encode(receipt.toJson())).toList();
await prefs.setStringList(_receiptsKey, receiptJsonList);
}
}
/// Remove a receipt from local storage
static Future<void> removeReceipt(String receiptId) async {
final prefs = await SharedPreferences.getInstance();
final receipts = await getReceipts();
final filteredReceipts =
receipts.where((receipt) => receipt.id != receiptId).toList();
// Convert receipts to JSON strings
final receiptJsonList = filteredReceipts
.map((receipt) => json.encode(receipt.toJson()))
.toList();
await prefs.setStringList(_receiptsKey, receiptJsonList);
}
/// Clear all receipts from local storage
static Future<void> clearAllReceipts() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_receiptsKey);
}
/// Submit a single receipt to FireFly III
static Future<bool> submitReceiptToServer(
LocalReceipt receipt,
String baseUrl,
String accessToken,
) async {
try {
// Check if the receipt is already submitted
if (receipt.isSubmitted) {
return true; // Already submitted
}
// Validate required fields
if (receipt.sourceAccountId == null ||
receipt.destinationAccountId == null) {
await updateReceiptSubmissionStatus(receipt.id,
isSubmitted: false, submissionError: 'Missing account information');
return false;
}
// Submit to FireFly III
final transactionId = await _submitToFireFly(
receipt: receipt,
baseUrl: baseUrl,
accessToken: accessToken,
);
if (transactionId != null) {
// Update receipt status to submitted
await updateReceiptSubmissionStatus(receipt.id,
isSubmitted: true, submissionError: null);
return true;
} else {
await updateReceiptSubmissionStatus(receipt.id,
isSubmitted: false, submissionError: 'Failed to submit to server');
return false;
}
} catch (e) {
await updateReceiptSubmissionStatus(receipt.id,
isSubmitted: false, submissionError: e.toString());
return false;
}
}
/// Submit all unsubmitted receipts to FireFly III
static Future<Map<String, dynamic>> submitAllUnsubmittedReceipts(
String baseUrl,
String accessToken,
) async {
final unsubmittedReceipts = await getUnsubmittedReceipts();
final results = <String, bool>{};
int successCount = 0;
int failureCount = 0;
for (final receipt in unsubmittedReceipts) {
try {
final success =
await submitReceiptToServer(receipt, baseUrl, accessToken);
results[receipt.id] = success;
if (success) {
successCount++;
} else {
failureCount++;
}
} catch (e) {
results[receipt.id] = false;
failureCount++;
// Update the receipt with the error
await updateReceiptSubmissionStatus(receipt.id,
isSubmitted: false, submissionError: e.toString());
}
}
return {
'results': results,
'successCount': successCount,
'failureCount': failureCount,
'totalCount': unsubmittedReceipts.length,
};
}
/// Private method to handle actual submission to FireFly III
static Future<String?> _submitToFireFly({
required LocalReceipt receipt,
required String baseUrl,
required String accessToken,
}) async {
try {
final transactionId = await FireflyApiService.submitDummyTransaction(
baseUrl: baseUrl,
accessToken: accessToken,
sourceId: receipt.sourceAccountId!,
destinationId: receipt.destinationAccountId!,
type: 'deposit', // Assuming deposit for receipts
description: receipt.transactionDescription ??
_generateTransactionDescription(receipt.items),
date:
'${receipt.transactionDate.year}-${receipt.transactionDate.month.toString().padLeft(2, '0')}-${receipt.transactionDate.day.toString().padLeft(2, '0')}',
amount: receipt.total.toStringAsFixed(2),
);
return transactionId;
} catch (e) {
print('Error submitting receipt to FireFly III: $e');
return null;
}
}
/// Generate transaction description
static String _generateTransactionDescription(List<ReceiptItem> items) {
if (items.isEmpty) {
return 'Transaksi Struk Belanja';
}
// Take the first 5 item descriptions
final itemNames = items.take(5).map((item) => item.description).toList();
// If there are more than 5 items, append ', dll' to the last item
if (items.length > 5) {
itemNames[4] += ', dll';
}
// Join the item names with ', '
return itemNames.join(', ');
}
}

View File

@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:cashumit/models/firefly_account.dart';
import 'package:cashumit/models/receipt_item.dart';
import 'package:cashumit/services/firefly_api_service.dart';
import 'package:cashumit/services/account_cache_service.dart';
class ReceiptService {
/// Memuat kredensial dari shared preferences.
@ -212,4 +213,29 @@ class ReceiptService {
static double _calculateTotal(List<ReceiptItem> items) {
return items.fold(0.0, (sum, item) => sum + item.total);
}
}
/// Fungsi untuk menyimpan akun ke cache
static Future<void> saveAccountsToCache(
List<Map<String, dynamic>> accounts) async {
await AccountCacheService.saveAccountsLocally(accounts);
}
/// Fungsi untuk mengambil akun dari cache
static Future<List<Map<String, dynamic>>> getCachedAccounts() async {
return await AccountCacheService.getCachedAccounts();
}
/// Fungsi untuk memperbarui cache akun dari server
static Future<void> updateAccountCache({
required String baseUrl,
required String accessToken,
}) async {
try {
final accounts =
await loadAccounts(baseUrl: baseUrl, accessToken: accessToken);
await AccountCacheService.updateAccountsFromServer(accounts);
} catch (e) {
print('Gagal memperbarui cache akun: $e');
}
}
}

View File

@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
import 'package:cashumit/services/bluetooth_service.dart';
import 'package:cashumit/screens/local_receipts_screen.dart';
import 'package:provider/provider.dart';
import 'package:cashumit/providers/receipt_provider.dart';
class ReceiptSpeedDial extends StatelessWidget {
final BluetoothService bluetoothService;
@ -14,6 +17,7 @@ class ReceiptSpeedDial extends StatelessWidget {
final Future<void> Function() onSendToFirefly;
final VoidCallback onPrintingStart;
final VoidCallback onPrintingEnd;
final VoidCallback onOpenLocalReceipts;
const ReceiptSpeedDial({
super.key,
@ -28,6 +32,7 @@ class ReceiptSpeedDial extends StatelessWidget {
required this.onSendToFirefly,
required this.onPrintingStart,
required this.onPrintingEnd,
required this.onOpenLocalReceipts,
});
@override
@ -38,6 +43,12 @@ class ReceiptSpeedDial extends StatelessWidget {
spacing: 3,
spaceBetweenChildren: 4,
children: [
SpeedDialChild(
child: const Icon(Icons.list_alt),
label: 'Lihat Nota Tersimpan',
onTap: onOpenLocalReceipts,
backgroundColor: Colors.blue,
),
SpeedDialChild(
child: const Icon(Icons.send),
label: 'Kirim ke Firefly',
@ -56,6 +67,48 @@ class ReceiptSpeedDial extends StatelessWidget {
? Colors.blue
: Colors.grey,
),
SpeedDialChild(
child: const Icon(Icons.save),
label: 'Simpan untuk Dikirim Nanti',
onTap: hasItems && hasSourceAccount && hasDestinationAccount
? () async {
final receiptProvider = context.read<ReceiptProvider>();
try {
final receiptId =
await receiptProvider.saveTransactionLocally();
if (receiptId != null) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Nota berhasil disimpan untuk dikirim nanti'),
),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Gagal menyimpan nota: $e'),
),
);
}
}
}
: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Pilih akun sumber dan tujuan terlebih dahulu'),
duration: Duration(seconds: 2),
),
);
},
backgroundColor: hasItems && hasSourceAccount && hasDestinationAccount
? Colors.orange
: Colors.grey,
),
SpeedDialChild(
child: const Icon(Icons.refresh),
label: 'Muat Ulang Akun',
@ -146,4 +199,3 @@ class ReceiptSpeedDial extends StatelessWidget {
);
}
}