cashumit/lib/widgets/invoice_image_widget.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);
}
}