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.
160 lines
5.2 KiB
Dart
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);
|
|
}
|