feat: Implement ReceiptScreen with provider pattern and clean architecture

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

Mengimplementasikan ReceiptScreen dengan pola provider untuk manajemen state yang lebih baik. Memisahkan logika bisnis ke service terpisah dan membuat widget komponen yang reusable untuk struk. Menambahkan fitur konfigurasi informasi toko dan teks kustom (disclaimer, thank you note, pantun).
master
a2nr 2025-08-24 11:16:40 +07:00
parent 3baa17e9a5
commit 28e99e4e2f
5 changed files with 664 additions and 0 deletions

View File

@ -0,0 +1,172 @@
// lib/providers/receipt_provider.dart
import 'package:flutter/material.dart';
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
class ReceiptProvider with ChangeNotifier {
late ReceiptState _state;
ReceiptState get state => _state;
ReceiptProvider() {
_state = ReceiptState();
}
/// Inisialisasi state awal
Future<void> initialize() async {
await loadCredentialsAndAccounts();
// Bisa menambahkan inisialisasi lain jika diperlukan
}
/// 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)
_state = _state.copyWith(
fireflyUrl: null,
accessToken: null,
accounts: [], // Kosongkan akun juga
);
notifyListeners();
return;
}
// Periksa apakah kredensial berubah
final credentialsChanged = _state.fireflyUrl != credentials['url'] || _state.accessToken != credentials['token'];
_state = _state.copyWith(
fireflyUrl: credentials['url'],
accessToken: credentials['token'],
);
notifyListeners();
// Jika kredensial ada dan berubah, lanjutkan untuk memuat akun
if (credentialsChanged) {
await loadAccounts();
} else if (_state.accounts.isEmpty) {
// Jika akun belum pernah dimuat, muat sekarang
await loadAccounts();
}
}
/// Memuat daftar akun
Future<void> loadAccounts() async {
if (_state.fireflyUrl == null || _state.accessToken == null) {
return;
}
try {
final allAccounts = await ReceiptService.loadAccounts(
baseUrl: _state.fireflyUrl!,
accessToken: _state.accessToken!,
);
_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)
}
}
/// Menambahkan item ke receipt
void addItem(ReceiptItem item) {
_state = _state.copyWith(
items: [..._state.items, item],
);
notifyListeners();
}
/// 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,
);
notifyListeners();
}
/// 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,
);
notifyListeners();
}
/// Memilih akun sumber
void selectSourceAccount(String id, String name) {
_state = _state.copyWith(
sourceAccountId: id,
sourceAccountName: name,
);
notifyListeners();
}
/// Memilih akun tujuan
void selectDestinationAccount(String id, String name) {
_state = _state.copyWith(
destinationAccountId: id,
destinationAccountName: name,
);
notifyListeners();
}
/// Mencari ID akun berdasarkan nama dan tipe
String? findAccountIdByName(String name, String expectedType) {
return ReceiptService.findAccountIdByName(
name: name,
expectedType: expectedType,
accounts: _state.accounts,
);
}
/// Mengirim transaksi (ini akan memanggil ReceiptService dan mungkin memperbarui state setelahnya)
Future<String?> submitTransaction() async {
if (_state.items.isEmpty) {
throw Exception('Tidak ada item untuk dikirim');
}
if (_state.fireflyUrl == null || _state.accessToken == null) {
throw Exception('Kredensial Firefly III tidak ditemukan');
}
// 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');
}
final transactionId = await ReceiptService.submitTransaction(
items: _state.items,
transactionDate: _state.transactionDate,
sourceAccountId: _state.sourceAccountId!,
destinationAccountId: _state.destinationAccountId!,
accounts: _state.accounts,
baseUrl: _state.fireflyUrl!,
accessToken: _state.accessToken!,
);
return transactionId;
}
// Tambahkan metode lain sesuai kebutuhan, seperti untuk memperbarui transactionDate jika diperlukan
}

View File

@ -0,0 +1,62 @@
// lib/providers/receipt_state.dart
import 'package:cashumit/models/receipt_item.dart';
class ReceiptState {
List<ReceiptItem> items;
DateTime transactionDate;
List<Map<String, dynamic>> accounts;
String? sourceAccountId;
String? sourceAccountName;
String? destinationAccountId;
String? destinationAccountName;
String? fireflyUrl;
String? accessToken;
ReceiptState({
List<ReceiptItem>? items,
DateTime? transactionDate,
List<Map<String, dynamic>>? accounts,
this.sourceAccountId,
this.sourceAccountName,
this.destinationAccountId,
this.destinationAccountName,
this.fireflyUrl,
this.accessToken,
}) : items = items ?? [],
transactionDate = transactionDate ?? DateTime.now(),
accounts = accounts ?? [];
// CopyWith method untuk membuat salinan state dengan perubahan tertentu
ReceiptState copyWith({
List<ReceiptItem>? items,
DateTime? transactionDate,
List<Map<String, dynamic>>? accounts,
String? sourceAccountId,
String? sourceAccountName,
String? destinationAccountId,
String? destinationAccountName,
String? fireflyUrl,
String? accessToken,
}) {
return ReceiptState(
items: items ?? this.items,
transactionDate: transactionDate ?? this.transactionDate,
accounts: accounts ?? this.accounts,
sourceAccountId: sourceAccountId ?? this.sourceAccountId,
sourceAccountName: sourceAccountName ?? this.sourceAccountName,
destinationAccountId: destinationAccountId ?? this.destinationAccountId,
destinationAccountName: destinationAccountName ?? this.destinationAccountName,
fireflyUrl: fireflyUrl ?? this.fireflyUrl,
accessToken: accessToken ?? this.accessToken,
);
}
// Method untuk menghitung total
double get total => items.fold(0.0, (sum, item) => sum + item.total);
@override
String toString() {
return 'ReceiptState(items: $items, transactionDate: $transactionDate, accounts: $accounts, sourceAccountId: $sourceAccountId, sourceAccountName: $sourceAccountName, destinationAccountId: $destinationAccountId, destinationAccountName: $destinationAccountName, fireflyUrl: $fireflyUrl, accessToken: $accessToken)';
}
}

View File

@ -0,0 +1,215 @@
// lib/services/receipt_service.dart
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';
class ReceiptService {
/// Memuat kredensial dari shared preferences.
///
/// Mengembalikan Map dengan key 'url' dan 'token' jika kredensial ada dan valid,
/// atau null jika tidak ditemukan atau tidak valid.
static Future<Map<String, String>?> loadCredentials() async {
final prefs = await SharedPreferences.getInstance();
final url = prefs.getString('firefly_url');
final token = prefs.getString('firefly_token');
if (url == null || token == null || url.isEmpty || token.isEmpty) {
return null;
}
return {'url': url, 'token': token};
}
/// Memuat daftar akun sumber (revenue) dan tujuan (asset) dari API.
///
/// Mengembalikan daftar akun yang berisi akun revenue dan asset.
/// Melempar exception jika terjadi kesalahan saat memuat akun.
static Future<List<Map<String, dynamic>>> loadAccounts({
required String baseUrl,
required String accessToken,
}) async {
// Mengambil akun revenue
final revenueAccounts = await FireflyApiService.fetchAccounts(
baseUrl: baseUrl,
accessToken: accessToken,
type: 'revenue',
);
// Mengambil akun asset
final assetAccounts = await FireflyApiService.fetchAccounts(
baseUrl: baseUrl,
accessToken: accessToken,
type: 'asset',
);
// Menggabungkan akun revenue dan asset untuk dropdown
final allAccounts = <Map<String, dynamic>>[];
for (var account in revenueAccounts) {
allAccounts.add({
'id': account.id,
'name': account.name,
'type': account.type,
});
}
for (var account in assetAccounts) {
allAccounts.add({
'id': account.id,
'name': account.name,
'type': account.type,
});
}
return allAccounts;
}
/// Mencari ID akun berdasarkan nama dan tipe akun.
static String? findAccountIdByName({
required String name,
required String expectedType,
required List<Map<String, dynamic>> accounts,
}) {
if (name.isEmpty) return null;
try {
// Cari akun dengan nama yang cocok dan tipe yang diharapkan
final account = accounts.firstWhere(
(account) =>
account['name'].toString().toLowerCase() == name.toLowerCase() &&
account['type'] == expectedType,
);
return account['id'] as String?;
} catch (e) {
// Jika tidak ditemukan, coba pencarian yang lebih fleksibel
for (var account in accounts) {
if (account['type'] == expectedType &&
account['name']
.toString()
.toLowerCase()
.contains(name.toLowerCase())) {
return account['id'] as String?;
}
}
// Jika masih tidak ditemukan, kembalikan null
return null;
}
}
/// Generates a transaction description based on item names
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(', ');
}
/// Mengirim transaksi ke Firefly III.
///
/// Sebelum mengirim transaksi, metode ini melakukan beberapa validasi:
/// 1. Memastikan ada item yang akan dikirim
/// 2. Memastikan akun sumber dan tujuan telah dipilih atau dimasukkan
/// 3. Memastikan akun sumber dan tujuan berbeda
/// 4. Memastikan akun sumber dan tujuan ada di daftar akun yang dimuat
/// 5. Memastikan tipe akun sesuai (sumber: revenue, tujuan: asset)
///
/// Mengembalikan transactionId jika berhasil, atau null jika gagal.
/// Melempar exception jika ada error validasi.
static Future<String?> submitTransaction({
required List<ReceiptItem> items,
required DateTime transactionDate,
required String sourceAccountId,
required String destinationAccountId,
required List<Map<String, dynamic>> accounts, // Daftar akun yang dimuat
required String baseUrl,
required String accessToken,
}) async {
if (items.isEmpty) {
throw Exception('Tidak ada item untuk dikirim');
}
// Validasi apakah akun benar-benar ada di Firefly III
bool sourceAccountExists = false;
bool destinationAccountExists = false;
// Cari detail akun untuk validasi tipe akun
Map<String, dynamic>? sourceAccountDetails;
Map<String, dynamic>? destinationAccountDetails;
for (var account in accounts) {
if (account['id'].toString() == sourceAccountId) {
sourceAccountExists = true;
sourceAccountDetails = account;
}
if (account['id'].toString() == destinationAccountId) {
destinationAccountExists = true;
destinationAccountDetails = account;
}
}
if (!sourceAccountExists) {
throw Exception(
'Akun sumber tidak ditemukan di daftar akun yang dimuat. Klik "Muat Ulang Akun" dan coba lagi.');
}
if (!destinationAccountExists) {
throw Exception(
'Akun tujuan tidak ditemukan di daftar akun yang dimuat. Klik "Muat Ulang Akun" dan coba lagi.');
}
// Validasi tipe akun (sumber harus revenue, tujuan harus asset)
if (sourceAccountDetails != null &&
sourceAccountDetails['type'] != 'revenue') {
// Tampilkan peringatan, tapi tidak menghentikan proses
print(
'Peringatan: Akun sumber sebaiknya bertipe revenue, tetapi akun ini bertipe ${sourceAccountDetails['type']}.');
}
if (destinationAccountDetails != null &&
destinationAccountDetails['type'] != 'asset') {
// Tampilkan peringatan, tapi tidak menghentikan proses
print(
'Peringatan: Akun tujuan sebaiknya bertipe asset, tetapi akun ini bertipe ${destinationAccountDetails['type']}.');
}
if (sourceAccountId == destinationAccountId) {
throw Exception('Akun sumber dan tujuan tidak boleh sama');
}
final total = _calculateTotal(items);
// Generate transaction description
final transactionDescription = generateTransactionDescription(items);
final transactionId = await FireflyApiService.submitDummyTransaction(
baseUrl: baseUrl,
accessToken: accessToken,
sourceId: sourceAccountId,
destinationId: destinationAccountId,
type: 'deposit',
description: transactionDescription,
date:
'${transactionDate.year}-${transactionDate.month.toString().padLeft(2, '0')}-${transactionDate.day.toString().padLeft(2, '0')}',
amount: total.toStringAsFixed(2),
);
return transactionId;
}
/// Menghitung total harga semua item
static double _calculateTotal(List<ReceiptItem> items) {
return items.fold(0.0, (sum, item) => sum + item.total);
}
}

View File

@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:cashumit/widgets/store_info_widget.dart';
import 'package:cashumit/widgets/account_settings_widget.dart';
import 'package:cashumit/widgets/receipt_item_list.dart';
import 'package:cashumit/widgets/receipt_total.dart';
import 'package:cashumit/widgets/store_disclaimer.dart';
import 'package:cashumit/widgets/thank_you_pantun.dart';
import 'package:cashumit/widgets/dotted_line.dart';
import 'package:cashumit/widgets/horizontal_divider.dart';
import 'package:cashumit/models/receipt_item.dart';
class ReceiptBody extends StatelessWidget {
final List<ReceiptItem> items;
final String? sourceAccountName;
final String? destinationAccountName;
final VoidCallback onOpenStoreInfoConfig;
final VoidCallback onSelectSourceAccount;
final VoidCallback onSelectDestinationAccount;
final VoidCallback onOpenCustomTextConfig;
final double total;
final Function(int)? onEditItem;
final Function(int)? onRemoveItem;
final VoidCallback onAddItem;
const ReceiptBody({
Key? key,
required this.items,
this.sourceAccountName,
this.destinationAccountName,
required this.onOpenStoreInfoConfig,
required this.onSelectSourceAccount,
required this.onSelectDestinationAccount,
required this.onOpenCustomTextConfig,
required this.total,
this.onEditItem,
this.onRemoveItem,
required this.onAddItem,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
width: 360,
decoration: const BoxDecoration(
color: Colors.white,
),
child: Column(
children: [
// Informasi toko dengan widget yang dapat diedit
StoreInfoWidget(
onTap: onOpenStoreInfoConfig,
),
// Garis pembatas
const DottedLine(),
const SizedBox(height: 8),
// Pengaturan akun sumber dan destinasi
AccountSettingsWidget(
sourceAccount: sourceAccountName ?? 'Pilih Sumber',
destinationAccount: destinationAccountName ?? 'Pilih Tujuan',
onSelectSource: onSelectSourceAccount,
onSelectDestination: onSelectDestinationAccount,
),
const SizedBox(height: 8),
// Garis pemisah
const HorizontalDivider(),
// Daftar item
ReceiptItemList(
items: items,
onEditItem: onEditItem,
onRemoveItem: onRemoveItem,
onAddItem: onAddItem,
),
// Garis pembatas
const HorizontalDivider(),
// Total harga keseluruhan
ReceiptTotal(total: total),
const SizedBox(height: 8),
// Garis pembatas
const DottedLine(),
// Disclaimer toko
StoreDisclaimer(onTap: onOpenCustomTextConfig),
// Ucapan terima kasih dan pantun
ThankYouPantun(onTap: onOpenCustomTextConfig),
const SizedBox(height: 16),
],
),
);
}
}

View File

@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:cashumit/models/receipt_item.dart';
import 'package:cashumit/widgets/receipt_item_widget.dart';
import 'package:cashumit/widgets/add_item_button.dart';
import 'package:cashumit/widgets/horizontal_divider.dart';
class ReceiptItemList extends StatefulWidget {
final List<ReceiptItem> items;
final Function(int)? onEditItem;
final Function(int)? onRemoveItem;
final VoidCallback? onAddItem;
const ReceiptItemList({
Key? key,
required this.items,
this.onEditItem,
this.onRemoveItem,
this.onAddItem,
}) : super(key: key);
@override
State<ReceiptItemList> createState() => _ReceiptItemListState();
}
class _ReceiptItemListState extends State<ReceiptItemList> {
@override
Widget build(BuildContext context) {
return Column(
children: [
// Baris tabel keterangan
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
flex: 4,
child: Text(
'ITEM',
style: TextStyle(
fontFamily: 'Courier',
fontSize: 14,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.left,
),
),
Expanded(
flex: 1,
child: Text(
'QTY',
style: TextStyle(
fontFamily: 'Courier',
fontSize: 14,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
Expanded(
flex: 2,
child: Text(
'HARGA',
style: TextStyle(
fontFamily: 'Courier',
fontSize: 14,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.right,
),
),
Expanded(
flex: 2,
child: Text(
'TOTAL',
style: TextStyle(
fontFamily: 'Courier',
fontSize: 14,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.right,
),
),
],
),
// Garis pembatas
const HorizontalDivider(),
// Daftar item dengan Dismissible untuk swipe-to-delete
...widget.items.asMap().entries.map((entry) {
int index = entry.key;
ReceiptItem item = entry.value;
return Dismissible(
key: Key('${item.hashCode}_$index'),
direction: DismissDirection.horizontal,
background: Container(
color: Colors.red,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 20.0),
child: const Icon(Icons.delete, color: Colors.white),
),
secondaryBackground: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20.0),
child: const Icon(Icons.delete, color: Colors.white),
),
confirmDismiss: (direction) async {
// Untuk saat ini, kita mengembalikan true untuk semua arah
// Nanti bisa diganti dengan dialog konfirmasi
return true;
},
onDismissed: (direction) {
// Panggil callback onRemoveItem jika tersedia
widget.onRemoveItem?.call(index);
},
child: GestureDetector(
onTap: () => widget.onEditItem?.call(index),
child: ReceiptItemWidget(item: item),
),
);
}).toList(),
// Tombol tambah item
AddItemButton(onTap: widget.onAddItem ?? () {}),
],
);
}
}