481 lines
19 KiB
Dart
481 lines
19 KiB
Dart
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();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
),
|
|
);
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Helper for multi-price display (used in new layout)
|
|
static pw.Widget _buildMultiPriceTag(Map<String, int> priceVariants, double fontSize, pw.Font font) {
|
|
return pw.Wrap(
|
|
spacing: 8,
|
|
runSpacing: 2,
|
|
children: 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.75, color: PdfColors.grey700),
|
|
),
|
|
pw.TextSpan(
|
|
text: '${PricingHelper.formatPrice(e.value)}円',
|
|
style: pw.TextStyle(font: font, fontSize: fontSize, fontWeight: pw.FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
).toList(),
|
|
);
|
|
}
|
|
|
|
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 pw.Widget _buildStarRating(String label, int value, pw.Font font, String pdfSize) {
|
|
final double starFontSize = _getStarFontSize(pdfSize);
|
|
return pw.Row(
|
|
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
|
children: [
|
|
pw.Text(label, style: pw.TextStyle(fontSize: starFontSize, font: font, color: PdfColors.grey600)),
|
|
pw.SizedBox(width: 2),
|
|
pw.Text('★' * value + '☆' * (5 - value), style: pw.TextStyle(fontSize: starFontSize, font: font, color: PdfColors.orange400)),
|
|
]
|
|
);
|
|
}
|
|
|
|
|
|
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 _getStarFontSize(String pdfSize) => pdfSize == 'a5' ? 6.0 : 7.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 _getItemsPerPage(String pdfSize) {
|
|
switch (pdfSize) {
|
|
case 'a5': return 5; // Portrait, 1 col, 5 items
|
|
case 'b5': return 8;
|
|
case 'a4': default: return 10;
|
|
}
|
|
}
|
|
|
|
static int _getCrossAxisCount(String pdfSize) {
|
|
switch (pdfSize) {
|
|
case 'a5': return 1;
|
|
case 'b5': return 2;
|
|
case 'a4': default: return 2;
|
|
}
|
|
}
|
|
|
|
// Unused but kept if needed for reference
|
|
static double _getChildAspectRatio(String pdfSize) => 1.0;
|
|
}
|