382 lines
13 KiB
Dart
382 lines
13 KiB
Dart
|
|
import 'dart:typed_data';
|
||
|
|
import 'dart:ui' as ui;
|
||
|
|
|
||
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:cashumit/models/receipt_item.dart';
|
||
|
|
import 'package:cashumit/utils/currency_format.dart';
|
||
|
|
|
||
|
|
/// Data yang dibutuhkan untuk merender invoice.
|
||
|
|
class InvoiceData {
|
||
|
|
final String invoiceNumber;
|
||
|
|
final String storeName;
|
||
|
|
final String storeAddress;
|
||
|
|
final String adminName;
|
||
|
|
final String adminPhone;
|
||
|
|
final Uint8List? logoBytes;
|
||
|
|
final Uint8List? qrisBytes;
|
||
|
|
|
||
|
|
/// Gambar yang sudah didecode agar bisa dirender sinkron saat offscreen
|
||
|
|
/// render (Image.memory bersifat async sehingga bisa tampil kosong).
|
||
|
|
final ui.Image? logoImage;
|
||
|
|
final ui.Image? qrisImage;
|
||
|
|
final String customerName; // DITAGIH KEPADA
|
||
|
|
final DateTime date;
|
||
|
|
final List<ReceiptItem> items;
|
||
|
|
final double total;
|
||
|
|
final double tipAmount;
|
||
|
|
final String disclaimer;
|
||
|
|
final String thankYouText;
|
||
|
|
final String pantunText;
|
||
|
|
|
||
|
|
InvoiceData({
|
||
|
|
required this.invoiceNumber,
|
||
|
|
required this.storeName,
|
||
|
|
required this.storeAddress,
|
||
|
|
required this.adminName,
|
||
|
|
required this.adminPhone,
|
||
|
|
this.logoBytes,
|
||
|
|
this.qrisBytes,
|
||
|
|
this.logoImage,
|
||
|
|
this.qrisImage,
|
||
|
|
required this.customerName,
|
||
|
|
required this.date,
|
||
|
|
required this.items,
|
||
|
|
required this.total,
|
||
|
|
this.tipAmount = 0.0,
|
||
|
|
this.disclaimer = '',
|
||
|
|
this.thankYouText = '',
|
||
|
|
this.pantunText = '',
|
||
|
|
});
|
||
|
|
|
||
|
|
double get subtotal => items.fold(0.0, (s, i) => s + i.total);
|
||
|
|
|
||
|
|
double get grandTotal => subtotal + tipAmount;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Widget invoice sesuai contoh (lebar tetap, background putih).
|
||
|
|
class InvoiceImageWidget extends StatelessWidget {
|
||
|
|
final InvoiceData data;
|
||
|
|
|
||
|
|
const InvoiceImageWidget({super.key, required this.data});
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
const months = [
|
||
|
|
'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun',
|
||
|
|
'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'
|
||
|
|
];
|
||
|
|
final d = data.date;
|
||
|
|
final dateStr =
|
||
|
|
'${d.day} ${months[d.month - 1]} ${d.year}, ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
|
||
|
|
final rupiah = (num v) => CurrencyFormat.formatRupiah(v);
|
||
|
|
|
||
|
|
return Container(
|
||
|
|
width: 420,
|
||
|
|
padding: const EdgeInsets.all(20),
|
||
|
|
color: Colors.white,
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
children: [
|
||
|
|
// Header
|
||
|
|
Row(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
Expanded(
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
children: [
|
||
|
|
if (data.logoImage != null)
|
||
|
|
ClipRRect(
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
child: RawImage(
|
||
|
|
image: data.logoImage,
|
||
|
|
width: 72,
|
||
|
|
height: 72,
|
||
|
|
fit: BoxFit.cover,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
else if (data.logoBytes != null)
|
||
|
|
ClipRRect(
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
child: Image.memory(
|
||
|
|
data.logoBytes!,
|
||
|
|
width: 72,
|
||
|
|
height: 72,
|
||
|
|
fit: BoxFit.cover,
|
||
|
|
errorBuilder: (_, __, ___) =>
|
||
|
|
_logoFallback(data.storeName),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
else
|
||
|
|
_logoFallback(data.storeName),
|
||
|
|
const SizedBox(height: 10),
|
||
|
|
_storeHeader(data),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 12),
|
||
|
|
const Text('INVOICE',
|
||
|
|
textAlign: TextAlign.right,
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 26,
|
||
|
|
fontWeight: FontWeight.w800,
|
||
|
|
color: Colors.black)),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
const SizedBox(height: 16),
|
||
|
|
// Kartu info
|
||
|
|
Container(
|
||
|
|
padding: const EdgeInsets.all(14),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: const Color(0xFFF2F2F2),
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
),
|
||
|
|
child: Column(
|
||
|
|
children: [
|
||
|
|
_infoRow('DITAGIH KEPADA', data.customerName),
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
_infoRow('NO. FAKTUR', data.invoiceNumber),
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
_infoRow('DITERBITKAN', dateStr),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 16),
|
||
|
|
// Tabel
|
||
|
|
DefaultTextStyle(
|
||
|
|
style: const TextStyle(color: Colors.black, fontSize: 12),
|
||
|
|
child: Table(
|
||
|
|
columnWidths: const {
|
||
|
|
0: FlexColumnWidth(2.6),
|
||
|
|
1: FlexColumnWidth(1.2),
|
||
|
|
2: FlexColumnWidth(1.0),
|
||
|
|
3: FlexColumnWidth(1.4),
|
||
|
|
},
|
||
|
|
children: [
|
||
|
|
TableRow(
|
||
|
|
decoration: const BoxDecoration(
|
||
|
|
border: Border(bottom: BorderSide(color: Colors.black12)),
|
||
|
|
),
|
||
|
|
children: [
|
||
|
|
_th('BARANG'),
|
||
|
|
_th('HARGA', align: TextAlign.center),
|
||
|
|
_th('KUANTITAS', align: TextAlign.center),
|
||
|
|
_th('JUMLAH', align: TextAlign.right),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
...data.items.map((it) => TableRow(
|
||
|
|
children: [
|
||
|
|
_td(it.description),
|
||
|
|
_td(rupiah(it.price), align: TextAlign.center),
|
||
|
|
_td(_fmtQty(it.quantity), align: TextAlign.center),
|
||
|
|
_td(rupiah(it.total), align: TextAlign.right),
|
||
|
|
],
|
||
|
|
)),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
// Ringkasan
|
||
|
|
_summaryRow('Subtotal', rupiah(data.subtotal)),
|
||
|
|
if (data.tipAmount > 0)
|
||
|
|
_summaryRow('Tip', rupiah(data.tipAmount)),
|
||
|
|
_summaryRow('Total', rupiah(data.grandTotal)),
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
Container(
|
||
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: const Color(0xFFF2F2F2),
|
||
|
|
borderRadius: BorderRadius.circular(8),
|
||
|
|
),
|
||
|
|
child: Row(
|
||
|
|
children: [
|
||
|
|
const Expanded(
|
||
|
|
child: Text('Jumlah yang Harus Dibayar',
|
||
|
|
style: TextStyle(
|
||
|
|
fontWeight: FontWeight.w700,
|
||
|
|
fontSize: 13,
|
||
|
|
color: Colors.black)),
|
||
|
|
),
|
||
|
|
const SizedBox(width: 8),
|
||
|
|
Text(rupiah(data.grandTotal),
|
||
|
|
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13, color: Colors.black)),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
if (data.qrisImage != null || data.qrisBytes != null) ...[
|
||
|
|
const SizedBox(height: 16),
|
||
|
|
const Text('Pembayaran via QRIS',
|
||
|
|
style: TextStyle(
|
||
|
|
fontWeight: FontWeight.w700,
|
||
|
|
fontSize: 13,
|
||
|
|
color: Colors.black)),
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
Center(
|
||
|
|
child: Container(
|
||
|
|
padding: const EdgeInsets.all(8),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
border: Border.all(color: Colors.black12),
|
||
|
|
borderRadius: BorderRadius.circular(8),
|
||
|
|
),
|
||
|
|
child: data.qrisImage != null
|
||
|
|
? RawImage(
|
||
|
|
image: data.qrisImage,
|
||
|
|
width: 180,
|
||
|
|
height: 180,
|
||
|
|
fit: BoxFit.contain,
|
||
|
|
)
|
||
|
|
: Image.memory(
|
||
|
|
data.qrisBytes!,
|
||
|
|
width: 180,
|
||
|
|
height: 180,
|
||
|
|
fit: BoxFit.contain,
|
||
|
|
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
const SizedBox(height: 16),
|
||
|
|
const Divider(),
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
if (data.thankYouText.isNotEmpty)
|
||
|
|
Text(
|
||
|
|
data.thankYouText,
|
||
|
|
style: const TextStyle(
|
||
|
|
fontSize: 12,
|
||
|
|
fontWeight: FontWeight.w700,
|
||
|
|
color: Colors.black),
|
||
|
|
textAlign: TextAlign.center,
|
||
|
|
),
|
||
|
|
if (data.pantunText.isNotEmpty) ...[
|
||
|
|
const SizedBox(height: 4),
|
||
|
|
..._pantunLines(data.pantunText),
|
||
|
|
],
|
||
|
|
if (data.disclaimer.isNotEmpty) ...[
|
||
|
|
const SizedBox(height: 8),
|
||
|
|
Text(
|
||
|
|
data.disclaimer,
|
||
|
|
style: const TextStyle(fontSize: 10, color: Colors.black54),
|
||
|
|
textAlign: TextAlign.center,
|
||
|
|
),
|
||
|
|
],
|
||
|
|
if (data.storeName.isNotEmpty) ...[
|
||
|
|
const SizedBox(height: 4),
|
||
|
|
Text(data.storeName,
|
||
|
|
style: const TextStyle(fontSize: 10, color: Colors.black54),
|
||
|
|
textAlign: TextAlign.center),
|
||
|
|
],
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _logoFallback(String name) {
|
||
|
|
return Container(
|
||
|
|
width: 72,
|
||
|
|
height: 72,
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: const Color(0xFFF2F2F2),
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
),
|
||
|
|
alignment: Alignment.center,
|
||
|
|
child: Text(name.isNotEmpty ? name[0].toUpperCase() : 'T',
|
||
|
|
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.black)),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
Widget _storeHeader(InvoiceData data) {
|
||
|
|
return Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
children: [
|
||
|
|
Text(data.storeName,
|
||
|
|
style: const TextStyle(
|
||
|
|
fontSize: 14,
|
||
|
|
fontWeight: FontWeight.w700,
|
||
|
|
color: Colors.black)),
|
||
|
|
if (data.storeAddress.isNotEmpty) ...[
|
||
|
|
const SizedBox(height: 2),
|
||
|
|
Text(data.storeAddress,
|
||
|
|
style: const TextStyle(fontSize: 10, color: Colors.black87)),
|
||
|
|
],
|
||
|
|
if (data.adminPhone.isNotEmpty) ...[
|
||
|
|
const SizedBox(height: 2),
|
||
|
|
Text('Telp: ${data.adminPhone}',
|
||
|
|
style: const TextStyle(fontSize: 10, color: Colors.black87)),
|
||
|
|
],
|
||
|
|
if (data.adminName.isNotEmpty) ...[
|
||
|
|
const SizedBox(height: 2),
|
||
|
|
Text('Admin: ${data.adminName}',
|
||
|
|
style: const TextStyle(fontSize: 10, color: Colors.black87)),
|
||
|
|
],
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
static List<Widget> _pantunLines(String text) {
|
||
|
|
return text
|
||
|
|
.split(RegExp(r'\\n|\n'))
|
||
|
|
.map((line) => line.trim())
|
||
|
|
.where((line) => line.isNotEmpty)
|
||
|
|
.map((line) => Text(
|
||
|
|
line,
|
||
|
|
style: const TextStyle(fontSize: 10, color: Colors.black87),
|
||
|
|
textAlign: TextAlign.center,
|
||
|
|
))
|
||
|
|
.toList();
|
||
|
|
}
|
||
|
|
|
||
|
|
static Widget _infoRow(String label, String value) {
|
||
|
|
return Row(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
|
children: [
|
||
|
|
SizedBox(
|
||
|
|
width: 120,
|
||
|
|
child: Text(label,
|
||
|
|
style: const TextStyle(
|
||
|
|
fontSize: 11, fontWeight: FontWeight.w700, color: Colors.black54)),
|
||
|
|
),
|
||
|
|
Expanded(
|
||
|
|
child: Text(value, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.black)),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
static Widget _th(String t, {TextAlign align = TextAlign.left}) {
|
||
|
|
return Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||
|
|
child: Text(t,
|
||
|
|
textAlign: align,
|
||
|
|
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Colors.black54)),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
static Widget _td(String t, {TextAlign align = TextAlign.left}) {
|
||
|
|
return Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||
|
|
child: Text(t, textAlign: align, style: const TextStyle(fontSize: 12, color: Colors.black)),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
static Widget _summaryRow(String label, String value) {
|
||
|
|
return Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||
|
|
child: Row(
|
||
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
|
|
children: [
|
||
|
|
Text(label, style: const TextStyle(fontSize: 12, color: Colors.black)),
|
||
|
|
Text(value, style: const TextStyle(fontSize: 12, color: Colors.black)),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
static String _fmtQty(double q) {
|
||
|
|
if (q == q.truncateToDouble()) return q.toInt().toString();
|
||
|
|
return q.toStringAsFixed(2);
|
||
|
|
}
|
||
|
|
}
|