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.
This commit is contained in:
parent
432acded29
commit
efb369eb81
19 changed files with 1465 additions and 116 deletions
160
apps/app_seeds/lib/ui/label_print_sheet.dart
Normal file
160
apps/app_seeds/lib/ui/label_print_sheet.dart
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue