cashumit/lib/services/invoice_image_service.dart

237 lines
7.2 KiB
Dart
Raw Normal View History

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;
}