tane/apps/app_seeds/lib/services/label_sheet_service.dart
vjrj baa8867287 feat(i18n): full RTL/CJK support — fonts, Japanese locale, directional fixes
Bundle Noto Sans Arabic + Noto Sans JP (SIL OFL 1.1) as per-glyph fallbacks
for both the UI text theme and every generated PDF, so RTL (Arabic) and CJK
render on all platforms — including desktop, where the system font may lack
them — instead of tofu. A shared pdf_fonts.dart centralizes the PDF theme and
the three label/catalog/recovery services reuse it.

Add Japanese (ja) as the CJK reference locale — fittingly, the app's own name
is Japanese (種, tane, 'seed'). Core strings translated; the rest fall back to
English via slang. Wired into supportedLocales (automatic) and the settings
picker.

Fix three physical Alignment.centerLeft -> AlignmentDirectional.centerStart in
variety_detail_screen, and make month-abbreviation and avatar-initial
truncation grapheme-safe (.characters) so CJK text is never cut mid-character.

Tests: CJK/Arabic PDF glyph render, ja locale resolution + English fallback.
The chat-bubble mirroring flagged by the audit was verified correct (already
respects Directionality) and left unchanged.
2026-07-14 11:11:19 +02:00

227 lines
7.2 KiB
Dart

import 'dart:math' as math;
import 'dart:typed_data';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'file_service.dart';
import 'pdf_fonts.dart';
/// How the labels are tiled on the page: many small stickers to peel onto
/// packets, or fewer larger cards for jars and boxes.
enum LabelSheetFormat { stickers, cards }
/// One printable seed label. All strings arrive already localized — this
/// service knows nothing about i18n or the domain. [qrData] is the opaque
/// payload for the QR (built by the caller via `SeedLabelCodec`).
class LabelSheetLabel {
const LabelSheetLabel({
required this.varietyLabel,
required this.qrData,
this.commonName,
this.scientificName,
this.details,
});
/// The variety's label, as its keeper writes it ("Acelga de Perales").
final String varietyLabel;
/// The species' common name in the keeper's locale, shown uppercase on top
/// ("ACELGA"), like the old hand-written labels. Null when unknown.
final String? commonName;
/// Scientific name of the linked species (rendered in italics), if any.
final String? scientificName;
/// The batch facts line ("2006 · Perales · 2 cobs"), if any.
final String? details;
/// The QR payload (a `tane://seed` URI), so the seed's data rides on the
/// physical packet.
final String qrData;
}
/// Fixed page geometry per [LabelSheetFormat], in PDF points.
class _Geometry {
const _Geometry({
required this.columns,
required this.rowHeight,
required this.qr,
required this.common,
required this.title,
required this.small,
});
final int columns;
final double rowHeight;
final double qr;
final double common;
final double title;
final double small;
}
/// Renders a sheet of seed labels (each with a QR) as a PDF and hands it to the
/// user via the save dialog — a paper bridge that needs no network. Pure of
/// platform channels apart from loading the bundled font, so it runs (and is
/// tested) on the host.
///
/// The embedded DejaVu face covers Latin (extended), Cyrillic and Greek, with
/// Noto Arabic + Noto CJK as per-glyph fallbacks (see [buildPdfTheme]) so RTL
/// and CJK text render. Fonts are a per-locale resource, extensible to any
/// script.
class LabelSheetService {
LabelSheetService({required FileService files}) : _files = files;
final FileService _files;
/// Outer page margin (points, ~8.5 mm).
static const _margin = 24.0;
static _Geometry _geometryOf(LabelSheetFormat format) => switch (format) {
// ~3 columns of ~64 mm stickers, tight text.
LabelSheetFormat.stickers => const _Geometry(
columns: 3,
rowHeight: 104,
qr: 46,
common: 7,
title: 9,
small: 6.5,
),
// ~2 columns of ~96 mm cards, room for the full facts line.
LabelSheetFormat.cards => const _Geometry(
columns: 2,
rowHeight: 152,
qr: 82,
common: 9,
title: 13,
small: 9,
),
};
Future<Uint8List> buildPdf({
required List<LabelSheetLabel> labels,
required LabelSheetFormat format,
bool rtl = false,
}) async {
final textDirection = rtl ? pw.TextDirection.rtl : pw.TextDirection.ltr;
final fonts = await loadPdfFonts();
final doc = pw.Document(theme: fonts.theme);
final g = _geometryOf(format);
final labelWidth = (PdfPageFormat.a4.width - _margin * 2) / g.columns;
pw.Widget labelBox(LabelSheetLabel label) => pw.Container(
width: labelWidth,
height: g.rowHeight,
padding: const pw.EdgeInsets.all(6),
decoration: pw.BoxDecoration(
border: pw.Border.all(color: PdfColors.grey400, width: 0.5),
),
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.BarcodeWidget(
barcode: pw.Barcode.qrCode(),
data: label.qrData,
width: g.qr,
height: g.qr,
drawText: false,
),
pw.SizedBox(width: 6),
pw.Expanded(
// Only the label's text mirrors for RTL; the grid itself stays
// left-to-right (a physical sheet has no reading direction).
child: pw.Directionality(
textDirection: textDirection,
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
mainAxisAlignment: pw.MainAxisAlignment.center,
mainAxisSize: pw.MainAxisSize.min,
children: [
if (label.commonName != null)
pw.Text(
label.commonName!.toUpperCase(),
maxLines: 1,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(
font: fonts.bold,
fontSize: g.common,
color: PdfColors.grey800,
),
),
pw.Text(
label.varietyLabel,
maxLines: 2,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(font: fonts.bold, fontSize: g.title),
),
if (label.scientificName != null)
pw.Text(
label.scientificName!,
maxLines: 1,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(
fontSize: g.small,
fontStyle: pw.FontStyle.italic,
color: PdfColors.grey700,
),
),
if (label.details != null)
pw.Text(
label.details!,
maxLines: 2,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(fontSize: g.small),
),
],
),
),
),
],
),
);
// One MultiPage child per grid row, so pagination breaks cleanly between
// rows and never splits a label. The last row is padded with empty cells to
// keep the grid left-aligned.
final rows = <pw.Widget>[];
for (var i = 0; i < labels.length; i += g.columns) {
final end = math.min(i + g.columns, labels.length);
rows.add(
pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
for (var j = i; j < end; j++) labelBox(labels[j]),
for (var k = end - i; k < g.columns; k++)
pw.SizedBox(width: labelWidth),
],
),
);
}
doc.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(_margin),
build: (context) => rows,
),
);
return doc.save();
}
/// Builds the sheet and asks the user where to keep it. Returns true when
/// saved, false when they cancelled the dialog.
Future<bool> saveLabels({
required List<LabelSheetLabel> labels,
required LabelSheetFormat format,
required String suggestedName,
bool rtl = false,
}) async {
final bytes = await buildPdf(labels: labels, format: format, rtl: rtl);
final path = await _files.saveFile(
suggestedName: suggestedName,
bytes: bytes,
);
return path != null;
}
}