// 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 createState() => _ReportScreenState(); } class _ReportScreenState extends State { DateTime _startDate = DateTime(DateTime.now().year, DateTime.now().month, 1); DateTime _endDate = DateTime.now(); List _assetAccounts = []; FireflyAccount? _selectedAccount; List _transactions = []; bool _isLoading = false; bool _isGeneratingPdf = false; bool _hasFetched = false; String _errorMessage = ''; String? _fireflyUrl; String? _accessToken; List _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 _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 _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 _selectStartDate() async { final picked = await showDatePicker( context: context, initialDate: _startDate, firstDate: DateTime(2020), lastDate: DateTime(2035), ); if (picked != null) { setState(() => _startDate = picked); } } Future _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 _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 _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 _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 _showSaveReportDialog() async { final defaultTitle = _currentReportTitle ?? 'Laporan ${DateFormat('MMMM yyyy').format(_endDate)} - ${_selectedAccount?.name ?? 'UPJ'}'; final titleController = TextEditingController(text: defaultTitle); final result = await showDialog( 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 _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( 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( 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( 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 _snapshotFuture; @override void initState() { super.initState(); if (widget.isPdf) { _snapshotFuture = _resolveSnapshot(); } } Future _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 _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( 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), ), ], ); } }