237 lines
7.2 KiB
Dart
237 lines
7.2 KiB
Dart
|
|
import 'dart:io';
|
||
|
|
import 'dart:typed_data';
|
||
|
|
import 'dart:ui' as ui;
|
||
|
|
|
||
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:flutter/rendering.dart';
|
||
|
|
import 'package:path_provider/path_provider.dart';
|
||
|
|
import 'package:intl/date_symbol_data_local.dart';
|
||
|
|
|
||
|
|
import 'package:cashumit/models/receipt_item.dart';
|
||
|
|
import 'package:cashumit/services/invoice_number_service.dart';
|
||
|
|
import 'package:cashumit/services/media_store_service.dart';
|
||
|
|
import 'package:cashumit/widgets/invoice_image_widget.dart';
|
||
|
|
|
||
|
|
class InvoiceImageService {
|
||
|
|
/// Bangun InvoiceData dari konteks struk.
|
||
|
|
///
|
||
|
|
/// Jika [invoiceNumber] diberikan, nomor tersebut dipakai ulang (tidak
|
||
|
|
/// menambah counter). Jika null, nomor faktur baru dibuat.
|
||
|
|
static Future<InvoiceDataBundle> prepare({
|
||
|
|
required String storeName,
|
||
|
|
required String storeAddress,
|
||
|
|
required String adminName,
|
||
|
|
required String adminPhone,
|
||
|
|
String? logoPath,
|
||
|
|
String? qrisPath,
|
||
|
|
String disclaimer = '',
|
||
|
|
String thankYouText = '',
|
||
|
|
String pantunText = '',
|
||
|
|
required String customerName,
|
||
|
|
required DateTime date,
|
||
|
|
required List<ReceiptItem> items,
|
||
|
|
double tipAmount = 0.0,
|
||
|
|
String? invoiceNumber,
|
||
|
|
}) async {
|
||
|
|
await initializeDateFormatting('id_ID', null);
|
||
|
|
debugPrint('Invoice prepare: items=${items.length} customer=$customerName');
|
||
|
|
Uint8List? logoBytes;
|
||
|
|
if (logoPath != null) {
|
||
|
|
try {
|
||
|
|
final f = File(logoPath);
|
||
|
|
if (await f.exists()) {
|
||
|
|
logoBytes = await f.readAsBytes();
|
||
|
|
}
|
||
|
|
} catch (_) {}
|
||
|
|
}
|
||
|
|
|
||
|
|
Uint8List? qrisBytes;
|
||
|
|
if (qrisPath != null) {
|
||
|
|
try {
|
||
|
|
final f = File(qrisPath);
|
||
|
|
if (await f.exists()) {
|
||
|
|
qrisBytes = await f.readAsBytes();
|
||
|
|
}
|
||
|
|
} catch (_) {}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Decode lebih dulu agar gambar bisa dirender sinkron saat offscreen.
|
||
|
|
final logoImage = await _decodeImage(logoBytes);
|
||
|
|
final qrisImage = await _decodeImage(qrisBytes);
|
||
|
|
|
||
|
|
final resolvedInvoiceNumber =
|
||
|
|
invoiceNumber ?? InvoiceNumberService.nextNumber(date: date);
|
||
|
|
final total =
|
||
|
|
items.fold<double>(0.0, (s, i) => s + i.total) + tipAmount;
|
||
|
|
|
||
|
|
final data = InvoiceData(
|
||
|
|
invoiceNumber: resolvedInvoiceNumber,
|
||
|
|
storeName: storeName,
|
||
|
|
storeAddress: storeAddress,
|
||
|
|
adminName: adminName,
|
||
|
|
adminPhone: adminPhone,
|
||
|
|
logoBytes: logoBytes,
|
||
|
|
qrisBytes: qrisBytes,
|
||
|
|
logoImage: logoImage,
|
||
|
|
qrisImage: qrisImage,
|
||
|
|
customerName: customerName,
|
||
|
|
date: date,
|
||
|
|
items: items,
|
||
|
|
total: total,
|
||
|
|
tipAmount: tipAmount,
|
||
|
|
disclaimer: disclaimer,
|
||
|
|
thankYouText: thankYouText,
|
||
|
|
pantunText: pantunText,
|
||
|
|
);
|
||
|
|
|
||
|
|
return InvoiceDataBundle(
|
||
|
|
data: data, invoiceNumber: resolvedInvoiceNumber);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Decode bytes gambar menjadi [ui.Image] (sinkron saat dirender).
|
||
|
|
static Future<ui.Image?> _decodeImage(Uint8List? bytes) async {
|
||
|
|
if (bytes == null || bytes.isEmpty) return null;
|
||
|
|
try {
|
||
|
|
final codec = await ui.instantiateImageCodec(bytes);
|
||
|
|
final frame = await codec.getNextFrame();
|
||
|
|
return frame.image;
|
||
|
|
} catch (_) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Render widget invoice ke PNG bytes (offscreen).
|
||
|
|
static Future<Uint8List> renderToPng(InvoiceData data, {double pixelRatio = 2.5}) async {
|
||
|
|
final widget = InvoiceImageWidget(data: data);
|
||
|
|
final repaintBoundary = RenderRepaintBoundary();
|
||
|
|
|
||
|
|
final view = WidgetsBinding.instance.platformDispatcher.views.first;
|
||
|
|
final renderView = RenderView(
|
||
|
|
view: view,
|
||
|
|
child: RenderPositionedBox(
|
||
|
|
alignment: Alignment.topCenter,
|
||
|
|
child: repaintBoundary,
|
||
|
|
),
|
||
|
|
configuration: ViewConfiguration(
|
||
|
|
logicalConstraints: const BoxConstraints(maxWidth: 420),
|
||
|
|
physicalConstraints: const BoxConstraints(maxWidth: 420),
|
||
|
|
devicePixelRatio: pixelRatio,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
|
||
|
|
final pipelineOwner = PipelineOwner();
|
||
|
|
final buildOwner = BuildOwner(focusManager: FocusManager());
|
||
|
|
|
||
|
|
pipelineOwner.rootNode = renderView;
|
||
|
|
renderView.prepareInitialFrame();
|
||
|
|
|
||
|
|
final rootElement = RenderObjectToWidgetAdapter<RenderBox>(
|
||
|
|
container: repaintBoundary,
|
||
|
|
child: Directionality(
|
||
|
|
textDirection: TextDirection.ltr,
|
||
|
|
child: DefaultTextStyle(
|
||
|
|
style: const TextStyle(
|
||
|
|
color: Colors.black87,
|
||
|
|
fontSize: 14,
|
||
|
|
fontFamily: 'CourierPrime, Courier, monospace',
|
||
|
|
),
|
||
|
|
child: MediaQuery(
|
||
|
|
data: const MediaQueryData(size: Size(420, 4000)),
|
||
|
|
child: widget,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
).attachToRenderTree(buildOwner);
|
||
|
|
|
||
|
|
buildOwner.buildScope(rootElement);
|
||
|
|
buildOwner.finalizeTree();
|
||
|
|
|
||
|
|
pipelineOwner.flushLayout();
|
||
|
|
pipelineOwner.flushCompositingBits();
|
||
|
|
pipelineOwner.flushPaint();
|
||
|
|
|
||
|
|
final ui.Image image = await repaintBoundary.toImage(pixelRatio: pixelRatio);
|
||
|
|
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||
|
|
image.dispose();
|
||
|
|
if (byteData == null) throw Exception('Gagal merender invoice');
|
||
|
|
return byteData.buffer.asUint8List();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Simpan bytes invoice ke penyimpanan lokal dan (opsional) folder Download.
|
||
|
|
static Future<InvoiceSaveResult> saveBytes({
|
||
|
|
required Uint8List bytes,
|
||
|
|
required String invoiceNumber,
|
||
|
|
bool alsoSaveToDownloads = false,
|
||
|
|
}) async {
|
||
|
|
String? localPath;
|
||
|
|
try {
|
||
|
|
final dir = await getApplicationDocumentsDirectory();
|
||
|
|
final invDir = Directory('${dir.path}/invoices');
|
||
|
|
if (!await invDir.exists()) await invDir.create(recursive: true);
|
||
|
|
final file = File('${invDir.path}/invoice_${invoiceNumber}.png');
|
||
|
|
await file.writeAsBytes(bytes);
|
||
|
|
localPath = file.path;
|
||
|
|
} catch (_) {}
|
||
|
|
|
||
|
|
String? downloadsUri;
|
||
|
|
String? downloadsDisplayPath;
|
||
|
|
if (alsoSaveToDownloads) {
|
||
|
|
final saved = await MediaStoreService.saveToDownloads(
|
||
|
|
bytes: bytes,
|
||
|
|
fileName: 'invoice_$invoiceNumber.png',
|
||
|
|
);
|
||
|
|
if (saved != null) {
|
||
|
|
downloadsUri = saved.uri;
|
||
|
|
downloadsDisplayPath = saved.displayPath;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return InvoiceSaveResult(
|
||
|
|
localPath: localPath,
|
||
|
|
downloadsUri: downloadsUri,
|
||
|
|
downloadsDisplayPath: downloadsDisplayPath,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Render + simpan salinan lokal, kembalikan bytes + path.
|
||
|
|
static Future<InvoiceRenderResult> generate({
|
||
|
|
required InvoiceData data,
|
||
|
|
}) async {
|
||
|
|
debugPrint('Invoice generate: items=${data.items.length}');
|
||
|
|
final bytes = await renderToPng(data);
|
||
|
|
final saved = await saveBytes(
|
||
|
|
bytes: bytes, invoiceNumber: data.invoiceNumber);
|
||
|
|
return InvoiceRenderResult(
|
||
|
|
bytes: bytes,
|
||
|
|
localPath: saved.localPath,
|
||
|
|
invoiceNumber: data.invoiceNumber);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
class InvoiceDataBundle {
|
||
|
|
final InvoiceData data;
|
||
|
|
final String invoiceNumber;
|
||
|
|
InvoiceDataBundle({required this.data, required this.invoiceNumber});
|
||
|
|
}
|
||
|
|
|
||
|
|
class InvoiceRenderResult {
|
||
|
|
final Uint8List bytes;
|
||
|
|
final String? localPath;
|
||
|
|
final String invoiceNumber;
|
||
|
|
InvoiceRenderResult({required this.bytes, this.localPath, required this.invoiceNumber});
|
||
|
|
}
|
||
|
|
|
||
|
|
class InvoiceSaveResult {
|
||
|
|
final String? localPath;
|
||
|
|
final String? downloadsUri;
|
||
|
|
final String? downloadsDisplayPath;
|
||
|
|
|
||
|
|
const InvoiceSaveResult({
|
||
|
|
this.localPath,
|
||
|
|
this.downloadsUri,
|
||
|
|
this.downloadsDisplayPath,
|
||
|
|
});
|
||
|
|
|
||
|
|
bool get savedAnywhere => localPath != null || downloadsUri != null;
|
||
|
|
}
|