1544 lines
54 KiB
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),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|