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:
vjrj 2026-07-10 19:14:04 +02:00
parent 0cecb943f0
commit bf091bf852
19 changed files with 1465 additions and 116 deletions

View file

@ -11,6 +11,7 @@ import '../services/share_catalog_service.dart';
import '../state/inventory_cubit.dart';
import 'app_drawer.dart';
import 'draft_triage.dart';
import 'label_print_sheet.dart';
import 'quantity_kind_l10n.dart';
import 'quantity_picker.dart';
import 'quick_add_sheet.dart';
@ -26,89 +27,162 @@ class InventoryListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = context.t;
return Scaffold(
appBar: AppBar(
title: Text(t.inventory.title),
actions: [
// The paper bridge: only offered once something is marked to share.
BlocSelector<InventoryCubit, InventoryState, bool>(
selector: (state) => state.hasShared,
builder: (context, hasShared) => hasShared
? IconButton(
key: const Key('inventory.printCatalog'),
icon: const Icon(Icons.print_outlined),
tooltip: t.share.printCatalog,
onPressed: () => _printCatalog(context),
)
: const SizedBox.shrink(),
),
IconButton(
key: const Key('inventory.captureBurst'),
icon: const Icon(Icons.add_a_photo_outlined),
tooltip: t.draft.capture,
onPressed: () => _captureBurst(context),
),
],
),
drawer: const AppDrawer(),
floatingActionButton: FloatingActionButton(
key: const Key('inventory.addFab'),
tooltip: t.quickAdd.title,
onPressed: () => showQuickAddSheet(
context,
repository: context.read<VarietyRepository>(),
),
child: const Icon(Icons.add),
),
body: BlocBuilder<InventoryCubit, InventoryState>(
builder: (context, state) {
if (state.loading) {
return const Center(child: CircularProgressIndicator());
}
return Column(
children: [
if (state.drafts.isNotEmpty)
_TriageBanner(count: state.drafts.length),
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
child: TextField(
key: const Key('inventory.search'),
decoration: InputDecoration(
hintText: t.inventory.searchHint,
prefixIcon: const Icon(Icons.search, color: seedMuted),
hintStyle: const TextStyle(color: seedMuted),
filled: true,
fillColor: Colors.white,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide.none,
),
return BlocBuilder<InventoryCubit, InventoryState>(
builder: (context, state) {
return Scaffold(
appBar: state.selectionMode
? _selectionAppBar(context, state)
: _defaultAppBar(context, state),
// In selection mode the bars are contextual: no drawer or add FAB, so
// the whole screen is about picking a subset to print.
drawer: state.selectionMode ? null : const AppDrawer(),
floatingActionButton: state.selectionMode
? null
: FloatingActionButton(
key: const Key('inventory.addFab'),
tooltip: t.quickAdd.title,
onPressed: () => showQuickAddSheet(
context,
repository: context.read<VarietyRepository>(),
),
onChanged: context.read<InventoryCubit>().search,
child: const Icon(Icons.add),
),
),
_FilterBar(state: state),
Expanded(
child: _InventoryBody(
items: state.visibleItems,
// Distinguish "no seeds at all" from "filters hid them all".
filtered:
state.categoryFilter.isNotEmpty ||
state.typeFilter.isNotEmpty ||
state.organicOnly ||
state.needsReproductionOnly ||
state.sharingOnly,
body: state.loading
? const Center(child: CircularProgressIndicator())
: Column(
children: [
if (state.drafts.isNotEmpty && !state.selectionMode)
_TriageBanner(count: state.drafts.length),
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
child: TextField(
key: const Key('inventory.search'),
decoration: InputDecoration(
hintText: t.inventory.searchHint,
prefixIcon: const Icon(Icons.search, color: seedMuted),
hintStyle: const TextStyle(color: seedMuted),
filled: true,
fillColor: Colors.white,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 14,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide.none,
),
),
onChanged: context.read<InventoryCubit>().search,
),
),
_FilterBar(state: state),
Expanded(
child: _InventoryBody(
items: state.visibleItems,
selectionMode: state.selectionMode,
selectedIds: state.selectedIds,
// Distinguish "no seeds at all" from "filters hid them".
filtered:
state.categoryFilter.isNotEmpty ||
state.typeFilter.isNotEmpty ||
state.organicOnly ||
state.needsReproductionOnly ||
state.sharingOnly,
),
),
],
),
),
],
);
},
),
);
},
);
}
/// The normal top bar: print-what-I-share, enter label selection, capture.
PreferredSizeWidget _defaultAppBar(
BuildContext context,
InventoryState state,
) {
final t = context.t;
return AppBar(
title: Text(t.inventory.title),
actions: [
// The paper bridge: only offered once something is marked to share.
if (state.hasShared)
IconButton(
key: const Key('inventory.printCatalog'),
icon: const Icon(Icons.print_outlined),
tooltip: t.share.printCatalog,
onPressed: () => _printCatalog(context),
),
// Enter multi-select to print labels only once there's something to
// label.
if (state.items.isNotEmpty)
IconButton(
key: const Key('inventory.selectLabels'),
icon: const Icon(Icons.label_outline),
tooltip: t.printLabels.action,
onPressed: context.read<InventoryCubit>().startSelection,
),
IconButton(
key: const Key('inventory.captureBurst'),
icon: const Icon(Icons.add_a_photo_outlined),
tooltip: t.draft.capture,
onPressed: () => _captureBurst(context),
),
],
);
}
/// The contextual bar shown while picking seeds to print labels for.
PreferredSizeWidget _selectionAppBar(
BuildContext context,
InventoryState state,
) {
final t = context.t;
final cubit = context.read<InventoryCubit>();
return AppBar(
leading: IconButton(
key: const Key('inventory.selection.close'),
icon: const Icon(Icons.close),
tooltip: t.common.cancel,
onPressed: cubit.exitSelection,
),
title: Text(t.printLabels.selected(n: state.selectedIds.length)),
actions: [
TextButton(
key: const Key('inventory.selection.selectAll'),
onPressed: cubit.selectAllVisible,
child: Text(t.printLabels.selectAll),
),
IconButton(
key: const Key('inventory.selection.print'),
icon: const Icon(Icons.label_outline),
tooltip: t.printLabels.action,
onPressed: state.selectedIds.isEmpty
? null
: () => _printLabels(context, state.selectedIds),
),
],
);
}
/// Gathers the label rows for the selection and opens the print sheet.
Future<void> _printLabels(BuildContext context, Set<String> ids) async {
final t = context.t;
final repository = context.read<VarietyRepository>();
final messenger = ScaffoldMessenger.of(context);
final entries = await repository.labelRows(
ids,
languageCode: LocaleSettings.currentLocale.languageCode,
);
if (!context.mounted) return;
if (entries.isEmpty) {
messenger.showSnackBar(SnackBar(content: Text(t.printLabels.none)));
return;
}
await showLabelPrintSheet(context, entries);
}
/// Assembles the localized rows of the "what I share" catalog and saves it
/// as a PDF wherever the user chooses.
Future<void> _printCatalog(BuildContext context) async {
@ -303,7 +377,12 @@ class _FilterBar extends StatelessWidget {
}
class _InventoryBody extends StatelessWidget {
const _InventoryBody({required this.items, this.filtered = false});
const _InventoryBody({
required this.items,
this.filtered = false,
this.selectionMode = false,
this.selectedIds = const {},
});
final List<VarietyListItem> items;
@ -311,6 +390,12 @@ class _InventoryBody extends StatelessWidget {
/// than "no seeds yet".
final bool filtered;
/// Whether the list is in multi-select mode (tiles show a checkbox).
final bool selectionMode;
/// Ids currently selected, so each tile can reflect its checkbox state.
final Set<String> selectedIds;
@override
Widget build(BuildContext context) {
final t = context.t;
@ -348,7 +433,13 @@ class _InventoryBody extends StatelessWidget {
currentCategory = category;
rows.add(_CategoryHeader(title: category));
}
rows.add(_VarietyTile(item: item));
rows.add(
_VarietyTile(
item: item,
selectionMode: selectionMode,
selected: selectedIds.contains(item.id),
),
);
}
return ListView(children: rows);
}
@ -376,15 +467,35 @@ class _CategoryHeader extends StatelessWidget {
}
class _VarietyTile extends StatelessWidget {
const _VarietyTile({required this.item});
const _VarietyTile({
required this.item,
this.selectionMode = false,
this.selected = false,
});
final VarietyListItem item;
/// Whether the list is picking seeds to print labels for: tap toggles the
/// selection instead of opening the detail, and the tile shows a checkbox.
final bool selectionMode;
final bool selected;
@override
Widget build(BuildContext context) {
final cubit = context.read<InventoryCubit>();
void open() => context.push('/variety/${item.id}');
void toggle() => cubit.toggleSelection(item.id);
return ListTile(
leading: _Avatar(item: item),
key: Key('inventory.tile.${item.id}'),
selected: selectionMode && selected,
leading: selectionMode
? Checkbox(
key: Key('inventory.select.${item.id}'),
value: selected,
onChanged: (_) => toggle(),
)
: _Avatar(item: item),
title: Text(
item.label,
style: const TextStyle(fontWeight: FontWeight.bold),
@ -395,42 +506,47 @@ class _VarietyTile extends StatelessWidget {
item.scientificName!,
style: const TextStyle(fontStyle: FontStyle.italic),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (item.isShared)
Padding(
padding: const EdgeInsetsDirectional.only(end: 4),
child: Tooltip(
key: Key('inventory.shared.${item.id}'),
message: context.t.share.filterChip,
child: const Icon(
Icons.volunteer_activism_outlined,
size: 20,
// In selection mode the trailing actions (which navigate away) give way to
// the checkbox flow.
trailing: selectionMode
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (item.isShared)
Padding(
padding: const EdgeInsetsDirectional.only(end: 4),
child: Tooltip(
key: Key('inventory.shared.${item.id}'),
message: context.t.share.filterChip,
child: const Icon(
Icons.volunteer_activism_outlined,
size: 20,
color: seedGreen,
),
),
),
if (item.isOrganic)
Padding(
padding: const EdgeInsetsDirectional.only(end: 4),
child: Tooltip(
key: Key('inventory.organic.${item.id}'),
message: context.t.editVariety.organic,
child: const Icon(Icons.eco, size: 20, color: seedGreen),
),
),
_ViabilityDot(item.viability),
IconButton(
icon: const Icon(Icons.edit_outlined),
// Action colour: this is a tap target, not secondary text.
color: seedGreen,
tooltip: context.t.common.edit,
onPressed: open,
),
),
],
),
if (item.isOrganic)
Padding(
padding: const EdgeInsetsDirectional.only(end: 4),
child: Tooltip(
key: Key('inventory.organic.${item.id}'),
message: context.t.editVariety.organic,
child: const Icon(Icons.eco, size: 20, color: seedGreen),
),
),
_ViabilityDot(item.viability),
IconButton(
icon: const Icon(Icons.edit_outlined),
// Action colour: this is a tap target, not secondary text.
color: seedGreen,
tooltip: context.t.common.edit,
onPressed: open,
),
],
),
onTap: open,
onTap: selectionMode ? toggle : open,
onLongPress: selectionMode ? null : () => cubit.enterSelection(item.id),
);
}
}

View 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);
}