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