tambah fitur cache nota dan submit ketika server sudah aktif
parent
77b3f5bb79
commit
eece292526
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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:
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
|
@ -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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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> {
|
|||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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(', ');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue