ponshu-room-lite/lib/services/pdf_service.dart

435 lines
17 KiB
Dart
Raw Normal View History

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<Uint8List> generateMenuPdf(
List<SakeItem> 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<String, pw.MemoryImage?> 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.name}: $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
pw.ImageProvider? processedImage = image;
// For monochrome logic:
// We can't easily filter the image bytes without decode, but text colors are handled.
final textColor = isMonochrome ? PdfColors.black : PdfColors.black;
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.name,
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.brewery} / ${item.displayData.prefecture}',
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;
}
}
}