import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; import 'package:printing/printing.dart'; import 'package:qr/qr.dart'; import '../models/sake_item.dart'; import 'pricing_calculator.dart'; import 'pricing_helper.dart'; class PdfService { static Future generateMenuPdf( List items, { required String title, required String date, required bool includePhoto, required bool includePoem, required bool includeChart, required bool includePrice, required bool includeDate, required bool includeQr, // New parameter String pdfSize = 'a4', // 'a4', 'a5', 'b5' bool isMonochrome = false, bool isPortrait = true, double density = 1.2, }) async { final pdf = pw.Document(); // Font Loading final font = await PdfGoogleFonts.notoSansJPRegular(); // Prepare Images (Monochrome processing handled in View or here? // Ideally Printing package handles output color, but for "Preview" we might want processing. // But Printing package's `PdfPreview` usually handles grayscale display if pdf is standard. // However, user requested "Monochrome Preview Fix", meaning usually `PdfPreview` shows color even if printer driver will default to bw. // We can pre-process images to grayscale here if isMonochrome is true. final Map itemImages = {}; for (var item in items) { if (item.displayData.imagePaths.isNotEmpty) { final file = File(item.displayData.imagePaths.first); if (await file.exists()) { try { final bytes = await file.readAsBytes(); // In a real app we might use `image` package to grayscale here. // For now, let's load raw bytes. // Printing package's `PdfPreview` has `build(...)` that returns bytes. itemImages[item.id] = pw.MemoryImage(bytes); } catch (e) { debugPrint('Error loading image for ${item.displayData.displayName}: $e'); itemImages[item.id] = null; } } } } final PdfPageFormat rawFormat = _getPageFormat(pdfSize); final PdfPageFormat pageFormat = isPortrait ? rawFormat : rawFormat.landscape; // Density Calculation // Base items per page (Normal Density 1.0) // A4: 8, A5: 4, B5: 6 (Roughly) // With Density 1.2 (High) -> +20% items // With Density 1.5 -> +50% items // Let's define base items per page for "1.0" int baseItems = 0; switch (pdfSize) { case 'a5': baseItems = 4; break; case 'b5': baseItems = 6; break; case 'a4': default: baseItems = 8; break; } final int itemsPerPage = (baseItems * density).round().clamp(1, 20); // Safety clamp final int crossAxisCount = _getCrossAxisCount(pdfSize); // 1 or 2 for (var i = 0; i < items.length; i += itemsPerPage) { final chunk = items.skip(i).take(itemsPerPage).toList(); final double titleFontSize = _getTitleFontSize(pdfSize); final double dateFontSize = _getDateFontSize(pdfSize); final double margin = _getPageMargin(pdfSize); pdf.addPage( pw.Page( pageFormat: pageFormat, margin: pw.EdgeInsets.all(margin), theme: pw.ThemeData.withFont(base: font), build: (context) { return pw.Column( children: [ // Header pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ // Title Row with Tax Label pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.start, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Expanded( child: pw.Container( decoration: pw.BoxDecoration( border: pw.Border(bottom: pw.BorderSide(color: PdfColors.grey400, width: 1.5)), ), padding: const pw.EdgeInsets.only(bottom: 4), child: pw.Text( title, style: pw.TextStyle(fontSize: titleFontSize, font: font, fontWeight: pw.FontWeight.bold), ), ), ), // Tax Included Label (Top Right) if (includePrice) ...[ pw.SizedBox(width: 8), pw.Container( padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 3), decoration: pw.BoxDecoration( border: pw.Border.all(color: PdfColors.grey500), borderRadius: pw.BorderRadius.circular(4), ), child: pw.Text( '税込価格', style: pw.TextStyle( fontSize: dateFontSize * 0.95, font: font, color: PdfColors.grey700, ), ), ), ], ], ), // Date (if included) if (includeDate && date.isNotEmpty) pw.Padding( padding: const pw.EdgeInsets.only(bottom: 4, top: 4), child: pw.Text(date, style: pw.TextStyle(fontSize: dateFontSize, font: font)), ), ], ), pw.SizedBox(height: 4), pw.Expanded( child: pw.Column( children: [ for (var r = 0; r < (itemsPerPage / crossAxisCount).ceil(); r++) pw.Expanded( child: pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.stretch, children: [ for (var c = 0; c < crossAxisCount; c++) ...[ () { final indexInChunk = r * crossAxisCount + c; if (indexInChunk < chunk.length) { return pw.Expanded( child: _buildMenuItem( chunk[indexInChunk], includePhoto ? itemImages[chunk[indexInChunk].id] : null, includePoem, includeChart, includePrice, includeQr, font, pdfSize, density, // Pass density to scale standard sizes if needed isMonochrome, ), ); } else { return pw.Expanded(child: pw.SizedBox()); } }(), if (c < crossAxisCount - 1) pw.SizedBox(width: _getGridSpacing(pdfSize)), ], ], ), ), ], ), ), ], ); }, ), ); } return pdf.save(); } static pw.Widget _buildMenuItem( SakeItem item, pw.MemoryImage? image, bool includePoem, bool includeChart, bool includePrice, bool includeQr, pw.Font font, String pdfSize, double density, bool isMonochrome, ) { // When absolute density increases (more items), font sizes might need specific scaling // IF the container becomes too small. // However, `pw.Expanded` handles height distribution. // If text overflows, we might need auto-scaling. // For now, let's keep fonts standard but reduce padding slightly if density is high. final price = includePrice ? PricingCalculator.calculatePrice(item) : 0; // Scale down fonts slightly if density > 1.2 double scale = 1.0; if (density > 1.2) scale = 0.9; if (density > 1.4) scale = 0.85; final double nameFontSize = _getNameFontSize(pdfSize) * scale; final double priceFontSize = _getPriceFontSize(pdfSize) * scale; final double infoFontSize = _getInfoFontSize(pdfSize) * scale; final double poemFontSize = _getPoemFontSize(pdfSize) * scale; final double imageSize = _getImageSize(pdfSize) * scale; // Extreme compaction for high density final double containerPadding = density > 1.0 ? 2 : _getContainerPadding(pdfSize) * scale; // Grayscale Filter for Image // For monochrome logic: // We can't easily filter the image bytes without decode, but text colors are handled. final accentColor = isMonochrome ? PdfColors.grey800 : PdfColors.brown400; final subColor = isMonochrome ? PdfColors.grey700 : PdfColors.grey700; return pw.Container( decoration: pw.BoxDecoration( border: pw.Border(bottom: pw.BorderSide(color: PdfColors.grey300, width: 0.5)), ), padding: pw.EdgeInsets.symmetric(vertical: containerPadding, horizontal: containerPadding), child: pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ // Left: Photo if (image != null) ...[ pw.Container( width: imageSize, height: imageSize, decoration: pw.BoxDecoration( borderRadius: pw.BorderRadius.circular(4), image: pw.DecorationImage(image: image, fit: pw.BoxFit.cover), ), ), pw.SizedBox(width: 10), // Increased Gap ], // Right: Content pw.Expanded( child: pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ // Left Column: Name, Brewery, Catch Copy pw.Expanded( child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, mainAxisAlignment: pw.MainAxisAlignment.start, children: [ // 1. Name pw.Text( item.displayData.displayName, style: pw.TextStyle(fontSize: nameFontSize, fontWeight: pw.FontWeight.bold, font: font), maxLines: 2, ), // 2. Brewery / Prefecture if (item.itemType != ItemType.set) ...[ pw.SizedBox(height: 2), pw.Text( '${item.displayData.displayBrewery} / ${item.displayData.displayPrefecture}', style: pw.TextStyle(fontSize: infoFontSize, color: subColor, font: font), maxLines: 1, ), ], // 3. Catch Copy (Poem) if (includePoem && item.displayData.catchCopy != null) ...[ pw.SizedBox(height: 4), pw.Container( padding: const pw.EdgeInsets.only(left: 6), decoration: pw.BoxDecoration( border: pw.Border(left: pw.BorderSide(color: isMonochrome ? PdfColors.grey500 : PdfColors.grey400, width: 1.5)) ), child: pw.Text( item.displayData.catchCopy!, style: pw.TextStyle(fontSize: poemFontSize, font: font, fontStyle: pw.FontStyle.italic, color: accentColor), maxLines: 2, tightBounds: true, ), ), ], ], ), ), // Right Column: Price (right-aligned) if (includePrice) ...[ pw.SizedBox(width: 8), _buildCompactPriceTag(item, price, priceFontSize, font), ], // 6. QR Code (Data-in-QR) if (includeQr) ...[ pw.SizedBox(width: 8), _buildQrCode(item), ] ], ), ), ], ) ); } static pw.Widget _buildQrCode(SakeItem item) { // Compact JSON for QR final jsonStr = item.toQrJson(); // "Cute" QR Rendering using CustomPaint (Vector) to draw circles final qrCode = QrCode.fromData( data: jsonStr, errorCorrectLevel: QrErrorCorrectLevel.M, ); final qrImage = QrImage(qrCode); return pw.Container( width: 40, height: 40, child: pw.CustomPaint( size: const PdfPoint(40, 40), painter: (PdfGraphics canvas, PdfPoint size) { final double pixelSize = size.x / qrImage.moduleCount; for (var x = 0; x < qrImage.moduleCount; x++) { for (var y = 0; y < qrImage.moduleCount; y++) { if (qrImage.isDark(y, x)) { final double cx = x * pixelSize + pixelSize / 2; final double cy = y * pixelSize + pixelSize / 2; // Increase radius ratio for cuter look (0.9 -> 1.0 or slightly overlaps? 0.85 for cleaner dots) // Let's use 1.0 for full circles that touch (cute) final double radius = pixelSize / 2 * 0.95; // Corner finding logic for "Eyes" (Position Detection Patterns) // Top-Left: (0-6, 0-6). Top-Right: (last-7 to last, 0-6). Bottom-Left: (0-6, last-7 to last) // We can color them differently or keep uniformity. // For "Cute", let's keep it uniform black circles but sharp. canvas.drawEllipse(cx, cy, radius, radius); canvas.setFillColor(PdfColors.black); canvas.fillPath(); } } } } ), ); } static pw.Widget _buildCompactPriceTag(SakeItem item, int calcPrice, double fontSize, pw.Font font) { if (item.userData.priceVariants != null && item.userData.priceVariants!.isNotEmpty) { // Multi-price: Column aligned to right return pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.end, children: item.userData.priceVariants!.entries.take(3).map((e) => pw.RichText( text: pw.TextSpan( children: [ pw.TextSpan(text: '${e.key} ', style: pw.TextStyle(font: font, fontSize: fontSize * 0.7, color: PdfColors.grey700)), pw.TextSpan(text: '${PricingHelper.formatPrice(e.value)}円', style: pw.TextStyle(font: font, fontSize: fontSize, fontWeight: pw.FontWeight.bold)), ] ) ) ).toList(), ); } else if (calcPrice > 0) { return pw.Text( '${PricingHelper.formatPrice(calcPrice)}円', style: pw.TextStyle(font: font, fontSize: fontSize, fontWeight: pw.FontWeight.bold), ); } return pw.SizedBox(); } static PdfPageFormat _getPageFormat(String size) { switch (size) { case 'a5': return PdfPageFormat.a5; // 148 x 210 case 'b5': return PdfPageFormat(182 * PdfPageFormat.mm, 257 * PdfPageFormat.mm); // 182 x 257 (Portrait) case 'a4': default: return PdfPageFormat.a4; // 210 x 297 } } // TWEAKED SIZES For "Standard" Template static double _getTitleFontSize(String pdfSize) => pdfSize == 'a5' ? 16.0 : 20.0; static double _getDateFontSize(String pdfSize) => pdfSize == 'a5' ? 9.0 : 10.0; static double _getNameFontSize(String pdfSize) => pdfSize == 'a5' ? 12.0 : (pdfSize == 'b5' ? 12.0 : 14.0); static double _getPriceFontSize(String pdfSize) => pdfSize == 'a5' ? 12.0 : (pdfSize == 'b5' ? 12.0 : 14.0); static double _getInfoFontSize(String pdfSize) => pdfSize == 'a5' ? 8.0 : (pdfSize == 'b5' ? 8.0 : 9.0); static double _getPoemFontSize(String pdfSize) => pdfSize == 'a5' ? 8.5 : (pdfSize == 'b5' ? 8.5 : 10.0); static double _getImageSize(String pdfSize) { switch (pdfSize) { case 'a5': return 50.0; // Increased from 40 case 'b5': return 50.0; case 'a4': default: return 60.0; } } static double _getContainerPadding(String pdfSize) => 6.0; static double _getPageMargin(String pdfSize) => 10.0 * PdfPageFormat.mm; static double _getGridSpacing(String pdfSize) => 8.0; static int _getCrossAxisCount(String pdfSize) { switch (pdfSize) { case 'a5': return 1; case 'b5': return 2; case 'a4': default: return 2; } } }