feat(inventory): local sharing — per-lot share terms, filter and printable catalog

The local half of the original Fase 2 (no network, no Offer entity yet):
- Lot sheet gains a 'do you share it?' choice (gift/swap first, sale last,
  never the default); declaring plenty of abundance reveals it with a nudge.
- Lot lines and list tiles badge shared batches; an 'I share' filter and
  a printable PDF catalog (paper bridge for fairs) round out the view.
- Catalog PDF embeds DejaVu (Latin-ext/Cyrillic/Greek); fonts are a
  per-locale resource like the OCR packs.
This commit is contained in:
vjrj 2026-07-09 22:19:55 +02:00
parent e3ec855630
commit ba87bf2719
20 changed files with 982 additions and 36 deletions

View file

@ -0,0 +1,132 @@
import 'dart:typed_data';
import 'package:flutter/services.dart' show rootBundle;
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'file_service.dart';
/// One printable line of the "what I share" catalog. All strings arrive
/// already localized this service knows nothing about i18n or the domain.
class ShareCatalogRow {
const ShareCatalogRow({
required this.name,
required this.mode,
this.scientificName,
this.details,
});
/// The variety's label, as its keeper writes it.
final String name;
/// Scientific name of the linked species, if any (rendered in italics).
final String? scientificName;
/// The batch facts line ("Seeds · 2024 · 2 cobs"), if any.
final String? details;
/// On what terms it is offered ("To give away" / "To swap" / "For sale").
final String mode;
}
/// Renders the "what I share" catalog as a PDF and hands it to the user via
/// the save dialog a paper bridge for fairs and meet-ups, no network needed.
class ShareCatalogService {
ShareCatalogService({required FileService files}) : _files = files;
final FileService _files;
/// Builds the catalog PDF. 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.
/// Like the OCR traineddata, the PDF font is a per-locale resource: wider
/// scripts (Arabic, CJK) ship alongside their locales, not hardcoded here.
Future<Uint8List> buildPdf({
required String title,
required String date,
required List<ShareCatalogRow> rows,
}) async {
final base = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans.ttf'),
);
final bold = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans-Bold.ttf'),
);
final doc = pw.Document(
theme: pw.ThemeData.withFont(base: base, bold: bold, italic: base),
);
doc.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
build: (context) => [
pw.Text(title, style: pw.TextStyle(font: bold, fontSize: 20)),
pw.SizedBox(height: 2),
pw.Text(
date,
style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey700),
),
pw.SizedBox(height: 12),
for (final row in rows)
pw.Container(
padding: const pw.EdgeInsets.symmetric(vertical: 6),
decoration: const pw.BoxDecoration(
border: pw.Border(
bottom: pw.BorderSide(color: PdfColors.grey300, width: 0.5),
),
),
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
row.name,
style: pw.TextStyle(font: bold, fontSize: 12),
),
if (row.scientificName != null)
pw.Text(
row.scientificName!,
style: pw.TextStyle(
fontSize: 10,
fontStyle: pw.FontStyle.italic,
color: PdfColors.grey700,
),
),
if (row.details != null)
pw.Text(
row.details!,
style: const pw.TextStyle(fontSize: 10),
),
],
),
),
pw.SizedBox(width: 8),
pw.Text(row.mode, style: const pw.TextStyle(fontSize: 10)),
],
),
),
],
),
);
return doc.save();
}
/// Builds the PDF and asks the user where to keep it. Returns true when
/// saved, false when they cancelled the dialog.
Future<bool> saveCatalog({
required String title,
required String date,
required String suggestedName,
required List<ShareCatalogRow> rows,
}) async {
final bytes = await buildPdf(title: title, date: date, rows: rows);
final path = await _files.saveFile(
suggestedName: suggestedName,
bytes: bytes,
);
return path != null;
}
}