tane/apps/app_seeds/lib/ui/label_print_sheet.dart
vjrj efb369eb81 feat(labels): print seed labels with QR from an inventory selection
Add multi-select to the inventory and a "Print labels" flow that renders a
PDF sheet of labels (small stickers or large cards), each carrying the seed's
common name, variety, scientific name, year/origin and a tanemaki://seed QR
that holds the seed's data offline (future scan-to-import).

- SeedLabelCodec: compact, versioned tanemaki://seed URI (encode/decode)
- LabelSheetService: pure-Dart PDF grid (pdf + barcode + DejaVu), RTL-aware
- VarietyRepository.labelRows: one row per lot, locale-picked common name
- InventoryCubit selection mode; contextual app bar + label print sheet
- i18n en/es/pt (printLabels.*)

Tests: codec, PDF service (both formats + RTL), labelRows, selection cubit,
inventory widget selection.
2026-07-10 19:14:04 +02:00

160 lines
5.2 KiB
Dart

import 'package:flutter/material.dart';
import '../data/export_import/seed_label_codec.dart';
import '../data/variety_repository.dart';
import '../di/injector.dart';
import '../i18n/strings.g.dart';
import '../services/label_sheet_service.dart';
import 'quantity_kind_l10n.dart';
/// Opens the print-labels sheet for [entries] (already gathered from the
/// current selection): pick a label size, then save a PDF sheet of labels —
/// each with a QR that carries the seed's data.
Future<void> showLabelPrintSheet(
BuildContext context,
List<SeedLabelEntry> entries,
) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => _LabelPrintSheet(entries: entries),
);
}
class _LabelPrintSheet extends StatefulWidget {
const _LabelPrintSheet({required this.entries});
final List<SeedLabelEntry> entries;
@override
State<_LabelPrintSheet> createState() => _LabelPrintSheetState();
}
class _LabelPrintSheetState extends State<_LabelPrintSheet> {
LabelSheetFormat _format = LabelSheetFormat.stickers;
bool _saving = false;
@override
Widget build(BuildContext context) {
final t = context.t;
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.printLabels.title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
t.printLabels.count(n: widget.entries.length),
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
RadioGroup<LabelSheetFormat>(
groupValue: _format,
onChanged: _pick,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<LabelSheetFormat>(
key: const Key('printLabels.format.stickers'),
value: LabelSheetFormat.stickers,
contentPadding: EdgeInsets.zero,
title: Text(t.printLabels.formatStickers),
subtitle: Text(t.printLabels.formatStickersHint),
),
RadioListTile<LabelSheetFormat>(
key: const Key('printLabels.format.cards'),
value: LabelSheetFormat.cards,
contentPadding: EdgeInsets.zero,
title: Text(t.printLabels.formatCards),
subtitle: Text(t.printLabels.formatCardsHint),
),
],
),
),
const SizedBox(height: 12),
Align(
alignment: AlignmentDirectional.centerEnd,
child: FilledButton.icon(
key: const Key('printLabels.save'),
onPressed: _saving ? null : _save,
icon: const Icon(Icons.save_alt_outlined),
label: Text(t.printLabels.save),
),
),
],
),
),
);
}
void _pick(LabelSheetFormat? value) {
if (value != null) setState(() => _format = value);
}
Future<void> _save() async {
final t = context.t;
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
final rtl = Directionality.of(context) == TextDirection.rtl;
setState(() => _saving = true);
final labels = [for (final e in widget.entries) _toLabel(t, e)];
final saved = await getIt<LabelSheetService>().saveLabels(
labels: labels,
format: _format,
suggestedName: 'tanemaki-labels-${_today()}.pdf',
rtl: rtl,
);
navigator.pop();
messenger.showSnackBar(
SnackBar(
content: Text(saved ? t.printLabels.saved : t.printLabels.cancelled),
),
);
}
/// Builds one printable label from a [SeedLabelEntry]: the visible fields plus
/// the QR payload (structured, so a future scan can read it).
LabelSheetLabel _toLabel(Translations t, SeedLabelEntry e) {
final origin = _origin(e);
final details = <String>[
if (e.harvestYear != null) '${e.harvestYear}',
?origin,
if (e.quantity != null) quantityDisplay(t, e.quantity!),
];
return LabelSheetLabel(
varietyLabel: e.varietyLabel,
commonName: e.commonName,
scientificName: e.scientificName,
details: details.isEmpty ? null : details.join(' · '),
qrData: SeedLabelCodec.encode(
SeedLabelData(
varietyLabel: e.varietyLabel,
scientificName: e.scientificName,
commonName: e.commonName,
year: e.harvestYear,
origin: origin,
),
),
);
}
String? _origin(SeedLabelEntry e) {
final parts = <String>[
if (e.originName != null && e.originName!.trim().isNotEmpty)
e.originName!.trim(),
if (e.originPlace != null && e.originPlace!.trim().isNotEmpty)
e.originPlace!.trim(),
];
return parts.isEmpty ? null : parts.join(' · ');
}
String _today() => DateTime.now().toIso8601String().substring(0, 10);
}