cashumit/lib/screens/report_screen.dart

1544 lines
54 KiB
Dart

// lib/screens/report_screen.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:open_file/open_file.dart';
import '../models/firefly_account.dart';
import '../models/report_transaction.dart';
import '../models/report_signature_config.dart';
import '../models/saved_report.dart';
import '../services/firefly_api_service.dart';
import '../services/account_cache_service.dart';
import '../services/report_pdf_service.dart';
import '../services/saved_report_service.dart';
import '../services/tag_cache_service.dart';
import '../services/pdf_snapshot_service.dart';
import '../widgets/edit_transaction_dialog.dart';
import '../utils/currency_format.dart';
import 'saved_reports_screen.dart';
class ReportScreen extends StatefulWidget {
final SavedReport? savedReport;
const ReportScreen({super.key, this.savedReport});
@override
State<ReportScreen> createState() => _ReportScreenState();
}
class _ReportScreenState extends State<ReportScreen> {
DateTime _startDate = DateTime(DateTime.now().year, DateTime.now().month, 1);
DateTime _endDate = DateTime.now();
List<FireflyAccount> _assetAccounts = [];
FireflyAccount? _selectedAccount;
List<ReportTransaction> _transactions = [];
bool _isLoading = false;
bool _isGeneratingPdf = false;
bool _hasFetched = false;
String _errorMessage = '';
String? _fireflyUrl;
String? _accessToken;
List<String> _serverTags = [];
String? _currentReportId;
String? _currentReportTitle;
ReportSignatureConfig? _customSignatureConfig;
final ImagePicker _imagePicker = ImagePicker();
@override
void initState() {
super.initState();
if (widget.savedReport != null) {
final sr = widget.savedReport!;
_currentReportId = sr.id;
_currentReportTitle = sr.title;
_startDate = sr.startDate;
_endDate = sr.endDate;
_transactions = List.from(sr.transactions);
_customSignatureConfig = sr.signatureConfig;
_hasFetched = true;
}
_loadConfigAndAccounts();
}
Future<void> _loadConfigAndAccounts() async {
final prefs = await SharedPreferences.getInstance();
final url = prefs.getString('firefly_url');
final token = prefs.getString('firefly_token');
setState(() {
_fireflyUrl = url;
_accessToken = token;
});
// Muat akun dari cache atau API
try {
final cachedAccounts = await AccountCacheService.getCachedAccounts();
final assets = cachedAccounts
.where((a) =>
(a['type']?.toString().toLowerCase() == 'asset') ||
(a['attributes'] != null &&
a['attributes']['type']?.toString().toLowerCase() == 'asset'))
.map((a) => FireflyAccount.fromJson(a))
.toList();
if (mounted) {
setState(() {
_assetAccounts = assets;
if (widget.savedReport != null) {
final sr = widget.savedReport!;
_selectedAccount = _assetAccounts.firstWhere(
(a) => a.id == sr.accountId,
orElse: () => FireflyAccount(
id: sr.accountId,
name: sr.accountName,
type: 'asset',
),
);
if (!_assetAccounts.any((a) => a.id == _selectedAccount!.id)) {
_assetAccounts.insert(0, _selectedAccount!);
}
} else if (_assetAccounts.isNotEmpty) {
_selectedAccount = _assetAccounts.firstWhere(
(a) => a.name.toLowerCase().contains('upj'),
orElse: () => _assetAccounts.first,
);
}
});
}
// Jika ada token, segarkan akun dari API
if (url != null && token != null && url.isNotEmpty && token.isNotEmpty) {
final freshAssets = await FireflyApiService.fetchAccounts(
baseUrl: url,
accessToken: token,
type: 'asset',
);
if (mounted && freshAssets.isNotEmpty) {
setState(() {
_assetAccounts = freshAssets;
if (widget.savedReport != null) {
final sr = widget.savedReport!;
_selectedAccount = _assetAccounts.firstWhere(
(a) => a.id == sr.accountId,
orElse: () => FireflyAccount(
id: sr.accountId,
name: sr.accountName,
type: 'asset',
),
);
if (!_assetAccounts.any((a) => a.id == _selectedAccount!.id)) {
_assetAccounts.insert(0, _selectedAccount!);
}
} else if (_selectedAccount == null ||
!_assetAccounts.any((a) => a.id == _selectedAccount!.id)) {
_selectedAccount = _assetAccounts.firstWhere(
(a) => a.name.toLowerCase().contains('upj'),
orElse: () => _assetAccounts.first,
);
}
});
}
}
// Muat daftar tag dari server / cache
final tags = await TagCacheService.getTagsWithFallback(() async {
if (url != null && token != null && url.isNotEmpty && token.isNotEmpty) {
return await FireflyApiService.fetchTags(
baseUrl: url,
accessToken: token,
);
}
return [];
});
if (mounted && tags.isNotEmpty) {
setState(() {
_serverTags = tags;
});
}
} catch (_) {}
}
Future<void> _fetchTransactions() async {
if (_fireflyUrl == null ||
_accessToken == null ||
_fireflyUrl!.isEmpty ||
_accessToken!.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Kredensial Firefly III belum diatur. Silakan periksa menu Pengaturan.'),
),
);
return;
}
if (_selectedAccount == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Pilih akun dompet terlebih dahulu')),
);
return;
}
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
final list = await FireflyApiService.fetchAccountTransactions(
baseUrl: _fireflyUrl!,
accessToken: _accessToken!,
accountId: _selectedAccount!.id,
startDate: _startDate,
endDate: _endDate,
);
setState(() {
_transactions = list;
_hasFetched = true;
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = 'Gagal memuat transaksi: $e';
});
}
}
Future<void> _selectStartDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _startDate,
firstDate: DateTime(2020),
lastDate: DateTime(2035),
);
if (picked != null) {
setState(() => _startDate = picked);
}
}
Future<void> _selectEndDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _endDate,
firstDate: DateTime(2020),
lastDate: DateTime(2035),
);
if (picked != null) {
setState(() => _endDate = picked);
}
}
// ==================== PENGELOLAAN ATTACHMENT ====================
Future<void> _pickFotoBarang(ReportTransaction tx, ImageSource source) async {
try {
final picked = await _imagePicker.pickImage(source: source, imageQuality: 85);
if (picked == null) return;
final appDir = await getApplicationDocumentsDirectory();
final targetPath =
'${appDir.path}/foto_barang_${tx.id}_${DateTime.now().millisecondsSinceEpoch}.jpg';
final savedFile = await File(picked.path).copy(targetPath);
setState(() {
tx.fotoBarangPath = savedFile.path;
tx.fotoBarangName = picked.name;
});
_autoUpdateSavedReport();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal mengambil foto: $e')),
);
}
}
Future<void> _pickInvoice(ReportTransaction tx) async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf', 'jpg', 'jpeg', 'png'],
);
if (result == null || result.files.single.path == null) return;
final file = result.files.single;
final isPdf = file.name.toLowerCase().endsWith('.pdf');
final appDir = await getApplicationDocumentsDirectory();
final ext = isPdf ? 'pdf' : 'jpg';
final timestamp = DateTime.now().millisecondsSinceEpoch;
final targetPath = '${appDir.path}/invoice_${tx.id}_$timestamp.$ext';
final savedFile = await File(file.path!).copy(targetPath);
String? snapshotPath;
if (isPdf) {
final targetSnapshotPath =
'${appDir.path}/invoice_snapshot_${tx.id}_$timestamp.png';
snapshotPath = await PdfSnapshotService.renderFirstPage(
pdfPath: savedFile.path,
outputPngPath: targetSnapshotPath,
);
}
setState(() {
tx.invoicePath = savedFile.path;
tx.invoiceName = file.name;
tx.invoiceType = isPdf ? 'pdf' : 'image';
tx.invoiceSnapshotPath = snapshotPath;
});
_autoUpdateSavedReport();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal memilih berkas invoice: $e')),
);
}
}
void _previewAttachment({
required String title,
required String subtitle,
required String filePath,
required bool isPdf,
String? snapshotPath,
ReportTransaction? transaction,
}) {
showDialog(
context: context,
builder: (ctx) => _AttachmentPreviewDialog(
title: title,
subtitle: subtitle,
filePath: filePath,
isPdf: isPdf,
initialSnapshotPath: snapshotPath,
transaction: transaction,
onSnapshotSaved: _autoUpdateSavedReport,
),
);
}
Future<void> _autoUpdateSavedReport() async {
if (_currentReportId != null) {
final report = SavedReport(
id: _currentReportId!,
title: _currentReportTitle ?? 'Laporan Keuangan',
accountId: _selectedAccount?.id ?? '',
accountName: _selectedAccount?.name ?? 'Kas UPJ',
startDate: _startDate,
endDate: _endDate,
createdAt: widget.savedReport?.createdAt ?? DateTime.now(),
updatedAt: DateTime.now(),
transactions: _transactions,
signatureConfig: _customSignatureConfig,
);
await SavedReportService.saveReport(report);
}
}
Future<void> _showSaveReportDialog() async {
final defaultTitle = _currentReportTitle ??
'Laporan ${DateFormat('MMMM yyyy').format(_endDate)} - ${_selectedAccount?.name ?? 'UPJ'}';
final titleController = TextEditingController(text: defaultTitle);
final result = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Simpan Draft Laporan'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Draft laporan akan disimpan di perangkat lokal beserta seluruh bukti nota dan foto barang yang sudah dilampirkan.',
style: TextStyle(fontSize: 12.5, color: Colors.grey),
),
const SizedBox(height: 14),
TextField(
controller: titleController,
decoration: const InputDecoration(
labelText: 'Judul Laporan',
border: OutlineInputBorder(),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Batal'),
),
ElevatedButton.icon(
icon: const Icon(Icons.save, size: 16),
label: const Text('Simpan Draft'),
onPressed: () {
if (titleController.text.trim().isNotEmpty) {
Navigator.pop(ctx, titleController.text.trim());
}
},
),
],
),
);
if (result != null && result.isNotEmpty) {
final reportId = _currentReportId ?? DateTime.now().millisecondsSinceEpoch.toString();
final report = SavedReport(
id: reportId,
title: result,
accountId: _selectedAccount?.id ?? '',
accountName: _selectedAccount?.name ?? 'Kas UPJ',
startDate: _startDate,
endDate: _endDate,
createdAt: widget.savedReport?.createdAt ?? DateTime.now(),
updatedAt: DateTime.now(),
transactions: _transactions,
signatureConfig: _customSignatureConfig,
);
await SavedReportService.saveReport(report);
if (mounted) {
setState(() {
_currentReportId = reportId;
_currentReportTitle = result;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Draft "$result" berhasil disimpan!'),
backgroundColor: Colors.green,
),
);
}
}
}
void _openEditTransaction(ReportTransaction t) {
showDialog(
context: context,
builder: (ctx) => EditTransactionDialog(
transaction: t,
fireflyUrl: _fireflyUrl,
accessToken: _accessToken,
availableTags: _serverTags,
onSaveLocal: (updatedTx) {
setState(() {
final idx = _transactions.indexWhere((item) => item.id == t.id);
if (idx != -1) {
_transactions[idx] = updatedTx;
}
// Update _serverTags cache jika ada tag baru
for (final tag in updatedTx.tags) {
if (!_serverTags.contains(tag)) {
_serverTags.add(tag);
}
}
});
_autoUpdateSavedReport();
},
onSyncToServer: (updatedTx) async {
if (_fireflyUrl == null ||
_accessToken == null ||
_fireflyUrl!.isEmpty ||
_accessToken!.isEmpty) {
return 'Kredensial Firefly III belum diatur di Pengaturan';
}
final groupId = updatedTx.transactionGroupId ?? updatedTx.id;
if (groupId.isEmpty) {
return 'ID transaksi Firefly tidak ditemukan untuk transaksi ini';
}
return await FireflyApiService.updateTransaction(
baseUrl: _fireflyUrl!,
accessToken: _accessToken!,
transactionGroupId: groupId,
transactionJournalId: updatedTx.transactionJournalId,
date: updatedTx.date,
description: updatedTx.description,
amount: updatedTx.amount,
tags: updatedTx.tags,
);
},
),
);
}
// ==================== GENERATE & OPEN PDF ====================
Future<void> _generateAndOpenPdf() async {
if (_transactions.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Tidak ada transaksi untuk dilaporkan')),
);
return;
}
// Periksa apakah ada pengeluaran yang belum lengkap attachment-nya
final incompleteCount = _transactions
.where((t) => t.isPengeluaran && !t.isComplete)
.length;
if (incompleteCount > 0) {
final proceed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange),
SizedBox(width: 8),
Text('Attachment Belum Lengkap'),
],
),
content: Text(
'Terdapat $incompleteCount transaksi pengeluaran yang belum memiliki Foto Barang dan/atau Invoice.\n\nApakah Anda ingin tetap membuat laporan?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Periksa Kembali'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF14532D)),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Lanjutkan Buat PDF', style: TextStyle(color: Colors.white)),
),
],
),
);
if (proceed != true) return;
}
setState(() => _isGeneratingPdf = true);
try {
final signatureConfig = _customSignatureConfig ?? await ReportSignatureConfig.load();
final pdfFile = await ReportPdfService.generateReportPdf(
transactions: _transactions,
startDate: _startDate,
endDate: _endDate,
config: signatureConfig,
accountName: _selectedAccount?.name,
fireflyBaseUrl: _fireflyUrl,
);
setState(() => _isGeneratingPdf = false);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Laporan PDF berhasil dibuat: ${pdfFile.path.split('/').last}'),
action: SnackBarAction(
label: 'BUKA',
textColor: Colors.yellowAccent,
onPressed: () {
OpenFile.open(pdfFile.path);
},
),
duration: const Duration(seconds: 5),
),
);
// Buka otomatis file PDF dengan default PDF viewer perangkat
await OpenFile.open(pdfFile.path);
} catch (e) {
setState(() => _isGeneratingPdf = false);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal membuat PDF: $e')),
);
}
}
// ==================== BUILD UI ====================
@override
Widget build(BuildContext context) {
const primaryColor = Color(0xFF14532D); // Hijau gelap resmi UPJ
final dateShortFmt = DateFormat('dd/MM/yyyy');
double totalPemasukan = 0;
double totalPengeluaran = 0;
int incompleteCount = 0;
for (final t in _transactions) {
if (t.isPemasukan) {
totalPemasukan += t.amount;
} else {
totalPengeluaran += t.amount;
if (!t.isComplete) incompleteCount++;
}
}
final saldoBersih = totalPemasukan - totalPengeluaran;
return Scaffold(
appBar: AppBar(
title: Text(
_currentReportTitle ?? 'Laporan Keuangan',
style: const TextStyle(fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
backgroundColor: primaryColor,
foregroundColor: Colors.white,
actions: [
IconButton(
icon: const Icon(Icons.folder_open_outlined),
tooltip: 'Laporan Tersimpan',
onPressed: () async {
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SavedReportsScreen()),
);
},
),
if (_hasFetched && _transactions.isNotEmpty)
IconButton(
icon: const Icon(Icons.save_outlined),
tooltip: 'Simpan Draft Laporan',
onPressed: _showSaveReportDialog,
),
if (_hasFetched && _transactions.isNotEmpty)
IconButton(
icon: _isGeneratingPdf
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2),
)
: const Icon(Icons.picture_as_pdf),
tooltip: 'Generate PDF',
onPressed: _isGeneratingPdf ? null : _generateAndOpenPdf,
),
],
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ==================== FILTER CARD ====================
Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.calendar_month, color: primaryColor, size: 20),
SizedBox(width: 8),
Text(
'Filter Periode & Akun',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
],
),
const Divider(height: 20),
// Pilihan Akun
DropdownButtonFormField<FireflyAccount>(
decoration: const InputDecoration(
labelText: 'Akun Dompet (Asset)',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
value: _selectedAccount,
isExpanded: true,
items: _assetAccounts.map((account) {
return DropdownMenuItem<FireflyAccount>(
value: account,
child: Text(account.name, overflow: TextOverflow.ellipsis),
);
}).toList(),
onChanged: (account) {
setState(() => _selectedAccount = account);
},
),
const SizedBox(height: 12),
// Pilihan Tanggal
Row(
children: [
Expanded(
child: InkWell(
onTap: _selectStartDate,
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Tanggal Mulai',
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 10, vertical: 8),
),
child: Text(
dateShortFmt.format(_startDate),
style: const TextStyle(fontSize: 13),
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: InkWell(
onTap: _selectEndDate,
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Tanggal Akhir',
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 10, vertical: 8),
),
child: Text(
dateShortFmt.format(_endDate),
style: const TextStyle(fontSize: 13),
),
),
),
),
],
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
height: 44,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
icon: _isLoading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
color: Colors.white, strokeWidth: 2),
)
: const Icon(Icons.cloud_download_outlined),
label: Text(_isLoading ? 'Mengambil Data...' : 'Ambil Data dari Firefly III'),
onPressed: _isLoading ? null : _fetchTransactions,
),
),
if (_errorMessage.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
_errorMessage,
style: const TextStyle(color: Colors.red, fontSize: 12),
),
],
],
),
),
// ==================== RINGKASAN SALDO ====================
if (_hasFetched) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF0FDF4),
border: Border.all(color: const Color(0xFFBBF7D0)),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Pemasukan',
style: TextStyle(fontSize: 11, color: Color(0xFF166534))),
const SizedBox(height: 4),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
CurrencyFormat.formatRupiah(totalPemasukan),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Color(0xFF166534),
),
),
),
],
),
),
),
const SizedBox(width: 8),
Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFEF2F2),
border: Border.all(color: const Color(0xFFFECACA)),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Pengeluaran',
style: TextStyle(fontSize: 11, color: Color(0xFF991B1B))),
const SizedBox(height: 4),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
CurrencyFormat.formatRupiah(totalPengeluaran),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Color(0xFF991B1B),
),
),
),
],
),
),
),
const SizedBox(width: 8),
Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
border: Border.all(color: const Color(0xFFCBD5E1)),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Saldo Bersih',
style: TextStyle(fontSize: 11, color: Color(0xFF334155))),
const SizedBox(height: 4),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
CurrencyFormat.formatRupiah(saldoBersih),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: saldoBersih >= 0
? const Color(0xFF166534)
: const Color(0xFF991B1B),
),
),
),
],
),
),
),
],
),
),
// Banner kelengkapan attachment
if (incompleteCount > 0)
Container(
margin: const EdgeInsets.fromLTRB(12, 10, 12, 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFFFEF3C7),
border: Border.all(color: const Color(0xFFFDE68A)),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.info_outline, color: Color(0xFFB45309), size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'$incompleteCount pengeluaran belum lengkap buktinya (butuh foto fisik barang + nota/invoice).',
style: const TextStyle(
fontSize: 11.5,
color: Color(0xFF92400E),
fontWeight: FontWeight.w500,
),
),
),
],
),
),
// ==================== DAFTAR TRANSAKSI ====================
Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Daftar Transaksi (${_transactions.length})',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
if (_transactions.isNotEmpty)
TextButton.icon(
icon: const Icon(Icons.print, size: 16),
label: const Text('Buat PDF'),
onPressed: _isGeneratingPdf ? null : _generateAndOpenPdf,
),
],
),
),
if (_transactions.isEmpty)
const Padding(
padding: EdgeInsets.all(32),
child: Center(
child: Text(
'Tidak ada transaksi pada periode yang dipilih.',
style: TextStyle(color: Colors.grey),
),
),
)
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
itemCount: _transactions.length,
itemBuilder: (context, index) {
final t = _transactions[index];
return _buildTransactionCard(t, index + 1);
},
),
const SizedBox(height: 32),
],
],
),
),
);
}
Widget _buildTransactionCard(ReportTransaction t, int number) {
final isPengeluaran = t.isPengeluaran;
final dateShortFmt = DateFormat('dd/MM/yyyy');
return Card(
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: Colors.grey.shade200),
),
elevation: 1,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Baris Header Transaksi
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 14,
backgroundColor:
isPengeluaran ? const Color(0xFFFEE2E2) : const Color(0xFFDCFCE7),
child: Text(
'$number',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: isPengeluaran
? const Color(0xFFDC2626)
: const Color(0xFF16A34A),
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.description,
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
'${dateShortFmt.format(t.date)} • ${t.counterpartyName}',
style: TextStyle(fontSize: 11.5, color: Colors.grey.shade600),
),
if (t.tags.isNotEmpty) ...[
const SizedBox(height: 4),
Wrap(
spacing: 4,
runSpacing: 2,
children: t.tags.map((tag) => Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Text(
'#$tag',
style: const TextStyle(
fontSize: 9.5,
color: Color(0xFF475569),
fontWeight: FontWeight.w500,
),
),
)).toList(),
),
],
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${isPengeluaran ? "- " : "+ "}${CurrencyFormat.formatRupiah(t.amount)}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: isPengeluaran
? const Color(0xFFDC2626)
: const Color(0xFF16A34A),
),
),
const SizedBox(height: 2),
IconButton(
icon: const Icon(Icons.edit_note, size: 22, color: Color(0xFF0284C7)),
tooltip: 'Edit Transaksi',
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(6),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
onPressed: () => _openEditTransaction(t),
),
],
),
],
),
// Bagian Lampiran untuk Pengeluaran
if (isPengeluaran) ...[
const Divider(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Lampiran SPJ Pengeluaran:',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
),
_buildStatusBadge(t),
],
),
const SizedBox(height: 8),
Row(
children: [
// Box Foto Barang
Expanded(
child: _buildAttachmentButton(
label: t.hasFotoBarang ? 'Foto Barang ✓' : 'Foto Barang',
hasFile: t.hasFotoBarang,
icon: Icons.camera_alt_outlined,
color: const Color(0xFF0284C7),
onPick: () => _showPhotoSourceDialog(t),
onPreview: t.hasFotoBarang
? () => _previewAttachment(
title: 'Foto Fisik Barang',
subtitle: t.description,
filePath: t.fotoBarangPath!,
isPdf: false,
)
: null,
onDelete: t.hasFotoBarang
? () => setState(() {
t.fotoBarangPath = null;
t.fotoBarangName = null;
_autoUpdateSavedReport();
})
: null,
),
),
const SizedBox(width: 8),
// Box Invoice
Expanded(
child: _buildAttachmentButton(
label: t.hasInvoice ? 'Invoice ✓' : 'Invoice / Nota',
hasFile: t.hasInvoice,
icon: Icons.receipt_long_outlined,
color: const Color(0xFF7C3AED),
onPick: () => _pickInvoice(t),
onPreview: t.hasInvoice
? () => _previewAttachment(
title: 'Invoice / Bukti Pembayaran',
subtitle: t.description,
filePath: t.invoicePath!,
isPdf: t.invoiceType == 'pdf',
snapshotPath: t.invoiceSnapshotPath,
transaction: t,
)
: null,
onDelete: t.hasInvoice
? () {
final invPath = t.invoicePath;
final snapPath = t.invoiceSnapshotPath;
setState(() {
t.invoicePath = null;
t.invoiceName = null;
t.invoiceType = null;
t.invoiceSnapshotPath = null;
});
if (invPath != null) {
try {
final f = File(invPath);
if (f.existsSync()) f.deleteSync();
} catch (_) {}
}
if (snapPath != null) {
try {
final f = File(snapPath);
if (f.existsSync()) f.deleteSync();
} catch (_) {}
}
_autoUpdateSavedReport();
}
: null,
),
),
],
),
],
],
),
),
);
}
Widget _buildStatusBadge(ReportTransaction t) {
if (t.isComplete) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFDCFCE7),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Lengkap',
style: TextStyle(fontSize: 10, color: Color(0xFF16A34A), fontWeight: FontWeight.bold),
),
);
} else if (t.hasFotoBarang || t.hasInvoice) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFFEF3C7),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'1 dari 2 berkas',
style: TextStyle(fontSize: 10, color: Color(0xFFB45309), fontWeight: FontWeight.bold),
),
);
} else {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFFEE2E2),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Belum ada',
style: TextStyle(fontSize: 10, color: Color(0xFFDC2626), fontWeight: FontWeight.bold),
),
);
}
}
Widget _buildAttachmentButton({
required String label,
required bool hasFile,
required IconData icon,
required Color color,
required VoidCallback onPick,
VoidCallback? onPreview,
VoidCallback? onDelete,
}) {
return Container(
decoration: BoxDecoration(
color: hasFile ? color.withOpacity(0.08) : Colors.grey.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: hasFile ? color.withOpacity(0.5) : Colors.grey.shade300,
style: hasFile ? BorderStyle.solid : BorderStyle.solid,
),
),
child: Column(
children: [
InkWell(
onTap: onPick,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 16, color: hasFile ? color : Colors.grey.shade700),
const SizedBox(width: 4),
Flexible(
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: hasFile ? color : Colors.grey.shade800,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
if (hasFile)
Container(
decoration: BoxDecoration(
border: Border(top: BorderSide(color: color.withOpacity(0.2))),
),
child: Row(
children: [
Expanded(
child: InkWell(
onTap: onPreview,
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 6),
child: Center(
child: Text(
'Lihat',
style: TextStyle(fontSize: 10, color: Colors.blue, fontWeight: FontWeight.bold),
),
),
),
),
),
Container(width: 0.5, height: 16, color: Colors.grey.shade300),
Expanded(
child: InkWell(
onTap: onDelete,
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 6),
child: Center(
child: Text(
'Hapus',
style: TextStyle(fontSize: 10, color: Colors.red, fontWeight: FontWeight.bold),
),
),
),
),
),
],
),
),
],
),
);
}
void _showPhotoSourceDialog(ReportTransaction t) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => SafeArea(
child: Wrap(
children: [
ListTile(
leading: const Icon(Icons.camera_alt, color: Color(0xFF14532D)),
title: const Text('Ambil dari Kamera'),
onTap: () {
Navigator.pop(ctx);
_pickFotoBarang(t, ImageSource.camera);
},
),
ListTile(
leading: const Icon(Icons.photo_library, color: Color(0xFF14532D)),
title: const Text('Pilih dari Galeri'),
onTap: () {
Navigator.pop(ctx);
_pickFotoBarang(t, ImageSource.gallery);
},
),
],
),
),
);
}
}
class _AttachmentPreviewDialog extends StatefulWidget {
final String title;
final String subtitle;
final String filePath;
final bool isPdf;
final String? initialSnapshotPath;
final ReportTransaction? transaction;
final VoidCallback? onSnapshotSaved;
const _AttachmentPreviewDialog({
required this.title,
required this.subtitle,
required this.filePath,
required this.isPdf,
this.initialSnapshotPath,
this.transaction,
this.onSnapshotSaved,
});
@override
State<_AttachmentPreviewDialog> createState() =>
_AttachmentPreviewDialogState();
}
class _AttachmentPreviewDialogState extends State<_AttachmentPreviewDialog> {
late Future<String?> _snapshotFuture;
@override
void initState() {
super.initState();
if (widget.isPdf) {
_snapshotFuture = _resolveSnapshot();
}
}
Future<String?> _resolveSnapshot() async {
if (widget.initialSnapshotPath != null &&
File(widget.initialSnapshotPath!).existsSync()) {
return widget.initialSnapshotPath;
}
final pdfFile = File(widget.filePath);
if (!pdfFile.existsSync()) return null;
final targetPng =
'${pdfFile.parent.path}/snapshot_${pdfFile.uri.pathSegments.last}.png';
final rendered = await PdfSnapshotService.renderFirstPage(
pdfPath: widget.filePath,
outputPngPath: targetPng,
);
if (rendered != null && widget.transaction != null) {
widget.transaction!.invoiceSnapshotPath = rendered;
widget.onSnapshotSaved?.call();
}
return rendered;
}
Widget _buildFallbackBadge() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.picture_as_pdf, color: Colors.red, size: 36),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.filePath.split('/').last,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
);
}
Future<void> _openOriginalPdf(BuildContext context) async {
try {
final srcFile = File(widget.filePath);
if (!srcFile.existsSync()) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Berkas PDF fisik tidak ditemukan'),
backgroundColor: Colors.red,
),
);
}
return;
}
final tempDir = await getTemporaryDirectory();
final fileName = widget.filePath.split('/').last;
final tempFile = File('${tempDir.path}/$fileName');
await srcFile.copy(tempFile.path);
final result = await OpenFile.open(tempFile.path);
if (result.type != ResultType.done && context.mounted) {
final msg = result.type == ResultType.noAppToOpen
? 'Tidak ada aplikasi pembaca PDF yang terpasang di perangkat'
: 'Gagal membuka berkas: ${result.message}';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
backgroundColor: Colors.red.shade700,
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Terjadi kesalahan membuka PDF: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final hasFile = File(widget.filePath).existsSync();
return AlertDialog(
title: Text(widget.title,
style: const TextStyle(fontWeight: FontWeight.bold)),
content: SizedBox(
width: double.maxFinite,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.subtitle,
style: const TextStyle(fontSize: 12, color: Colors.grey)),
const SizedBox(height: 12),
if (!widget.isPdf && hasFile)
Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 280),
child: Image.file(
File(widget.filePath),
fit: BoxFit.contain,
errorBuilder: (ctx, err, stack) => Container(
padding: const EdgeInsets.all(16),
color: Colors.grey.shade100,
child: const Text('Gagal memuat gambar'),
),
),
),
),
)
else if (widget.isPdf)
FutureBuilder<String?>(
future: _snapshotFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
height: 140,
alignment: Alignment.center,
child: const Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(height: 10),
Text(
'Memuat cuplikan dokumen PDF...',
style:
TextStyle(fontSize: 11.5, color: Colors.grey),
),
],
),
);
}
final renderedPath = snapshot.data;
if (renderedPath != null &&
File(renderedPath).existsSync()) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints:
const BoxConstraints(maxHeight: 280),
child: Image.file(
File(renderedPath),
fit: BoxFit.contain,
errorBuilder: (ctx, err, stack) =>
_buildFallbackBadge(),
),
),
),
),
const SizedBox(height: 6),
Text(
'Cuplikan halaman 1 dari ${widget.filePath.split('/').last}',
style: TextStyle(
fontSize: 10.5,
color: Colors.grey.shade600,
fontStyle: FontStyle.italic,
),
),
],
);
}
return _buildFallbackBadge();
},
)
else
Container(
padding: const EdgeInsets.all(16),
color: Colors.grey.shade100,
child: const Text('Berkas tidak ditemukan di penyimpanan'),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Tutup'),
),
if (widget.isPdf)
ElevatedButton.icon(
icon: const Icon(Icons.open_in_new, size: 16),
label: const Text('Buka File PDF Asli'),
onPressed: () => _openOriginalPdf(context),
),
],
);
}
}