feat(labels): choose copies per label, prefilled from container count

Each label in the print sheet gets an editable copy count, suggested from
the lot's latest condition-check container count (3 jars → 3 labels), and
falling back to 1. Type or step the count; the total updates live and Save
is disabled at zero. On save each seed expands into that many identical
labels.

- SeedLabelEntry.suggestedCopies; labelRows reads the latest container count
- label print sheet: per-row −/number/+ stepper, live total, expand on save
- tests: repository suggestedCopies + print-sheet copy chooser
This commit is contained in:
vjrj 2026-07-10 20:12:36 +02:00
parent 4d110e8082
commit 4b71859416
4 changed files with 298 additions and 4 deletions

View file

@ -134,6 +134,7 @@ class SeedLabelEntry extends Equatable {
this.quantity,
this.originName,
this.originPlace,
this.suggestedCopies = 1,
});
final String varietyLabel;
@ -147,6 +148,11 @@ class SeedLabelEntry extends Equatable {
final String? originName;
final String? originPlace;
/// How many copies of this label to suggest the latest condition check's
/// container count (e.g. 3 jars 3 labels), or 1 when unknown. Always 1;
/// the print sheet prefills it and lets the keeper edit.
final int suggestedCopies;
@override
List<Object?> get props => [
varietyLabel,
@ -157,6 +163,7 @@ class SeedLabelEntry extends Equatable {
quantity,
originName,
originPlace,
suggestedCopies,
];
}
@ -1325,6 +1332,28 @@ class VarietyRepository {
(lotsByVariety[l.varietyId] ??= <Lot>[]).add(l);
}
// Suggest one label per stored container: the latest condition check's
// container count (3 jars 3 labels). `.first` is the most recent because
// we order by check date descending.
final containersByLot = <String, int>{};
if (lots.isNotEmpty) {
final checks =
await (_db.select(_db.conditionChecks)
..where(
(c) =>
c.lotId.isIn(lots.map((l) => l.id).toList()) &
c.isDeleted.equals(false),
)
..orderBy([(c) => OrderingTerm.desc(c.checkedOn)]))
.get();
for (final c in checks) {
final count = c.containerCount;
if (count != null && count > 0) {
containersByLot.putIfAbsent(c.lotId, () => count);
}
}
}
SeedLabelEntry entryFor(Variety v, Lot? l) {
final hasQuantity =
l != null &&
@ -1346,6 +1375,7 @@ class VarietyRepository {
: null,
originName: l?.originName,
originPlace: l?.originPlace,
suggestedCopies: l == null ? 1 : (containersByLot[l.id] ?? 1),
);
}

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../data/export_import/seed_label_codec.dart';
import '../data/variety_repository.dart';
@ -8,7 +9,8 @@ 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
/// current selection): choose how many copies of each label (prefilled from the
/// stored container count) and 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,
@ -21,6 +23,9 @@ Future<void> showLabelPrintSheet(
);
}
/// Upper bound on copies per label a printed sheet, not a print run.
const _maxCopies = 99;
class _LabelPrintSheet extends StatefulWidget {
const _LabelPrintSheet({required this.entries});
@ -34,6 +39,24 @@ class _LabelPrintSheetState extends State<_LabelPrintSheet> {
LabelSheetFormat _format = LabelSheetFormat.stickers;
bool _saving = false;
/// One editable copy-count per entry, prefilled from its suggested count.
late final List<int> _copies = [
for (final e in widget.entries) e.suggestedCopies.clamp(0, _maxCopies),
];
late final List<TextEditingController> _controllers = [
for (final n in _copies) TextEditingController(text: '$n'),
];
int get _total => _copies.fold(0, (sum, n) => sum + n);
@override
void dispose() {
for (final c in _controllers) {
c.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final t = context.t;
@ -50,10 +73,32 @@ class _LabelPrintSheetState extends State<_LabelPrintSheet> {
),
const SizedBox(height: 4),
Text(
t.printLabels.count(n: widget.entries.length),
t.printLabels.count(n: _total),
key: const Key('printLabels.total'),
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
// The per-seed copy list can be long; keep the format chooser and
// the save button always visible by scrolling only this section.
Flexible(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.4,
),
child: ListView.builder(
shrinkWrap: true,
itemCount: widget.entries.length,
itemBuilder: (context, i) => _CopyRow(
entry: widget.entries[i],
controller: _controllers[i],
onMinus: _copies[i] > 0 ? () => _bump(i, -1) : null,
onPlus: _copies[i] < _maxCopies ? () => _bump(i, 1) : null,
onChanged: (value) => _setCopies(i, value),
),
),
),
),
const Divider(height: 20),
RadioGroup<LabelSheetFormat>(
groupValue: _format,
onChanged: _pick,
@ -82,7 +127,7 @@ class _LabelPrintSheetState extends State<_LabelPrintSheet> {
alignment: AlignmentDirectional.centerEnd,
child: FilledButton.icon(
key: const Key('printLabels.save'),
onPressed: _saving ? null : _save,
onPressed: (_saving || _total == 0) ? null : _save,
icon: const Icon(Icons.save_alt_outlined),
label: Text(t.printLabels.save),
),
@ -97,6 +142,19 @@ class _LabelPrintSheetState extends State<_LabelPrintSheet> {
if (value != null) setState(() => _format = value);
}
/// Steps the count for entry [i] by [delta], syncing the text field.
void _bump(int i, int delta) {
final next = (_copies[i] + delta).clamp(0, _maxCopies);
_controllers[i].text = '$next';
setState(() => _copies[i] = next);
}
/// Applies a typed value (empty 0), clamped to the allowed range.
void _setCopies(int i, String value) {
final parsed = int.tryParse(value) ?? 0;
setState(() => _copies[i] = parsed.clamp(0, _maxCopies));
}
Future<void> _save() async {
final t = context.t;
final messenger = ScaffoldMessenger.of(context);
@ -104,7 +162,11 @@ class _LabelPrintSheetState extends State<_LabelPrintSheet> {
final rtl = Directionality.of(context) == TextDirection.rtl;
setState(() => _saving = true);
final labels = [for (final e in widget.entries) _toLabel(t, e)];
// Expand each entry into its chosen number of identical labels.
final labels = <LabelSheetLabel>[
for (var i = 0; i < widget.entries.length; i++)
for (var c = 0; c < _copies[i]; c++) _toLabel(t, widget.entries[i]),
];
final saved = await getIt<LabelSheetService>().saveLabels(
labels: labels,
format: _format,
@ -158,3 +220,87 @@ class _LabelPrintSheetState extends State<_LabelPrintSheet> {
String _today() => DateTime.now().toIso8601String().substring(0, 10);
}
/// One row of the copy chooser: the seed's name plus a /number/+ stepper. The
/// number is directly editable so "12 jars" is a quick type, not twelve taps.
class _CopyRow extends StatelessWidget {
const _CopyRow({
required this.entry,
required this.controller,
required this.onMinus,
required this.onPlus,
required this.onChanged,
});
final SeedLabelEntry entry;
final TextEditingController controller;
final VoidCallback? onMinus;
final VoidCallback? onPlus;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final subtitle = <String>[
if (entry.commonName != null && entry.commonName!.trim().isNotEmpty)
entry.commonName!,
if (entry.harvestYear != null) '${entry.harvestYear}',
].join(' · ');
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
entry.varietyLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge,
),
if (subtitle.isNotEmpty)
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
],
),
),
const SizedBox(width: 8),
IconButton(
key: Key('printLabels.copies.minus.${entry.varietyLabel}'),
onPressed: onMinus,
icon: const Icon(Icons.remove_circle_outline),
visualDensity: VisualDensity.compact,
),
SizedBox(
width: 44,
child: TextField(
key: Key('printLabels.copies.field.${entry.varietyLabel}'),
controller: controller,
onChanged: onChanged,
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 8),
),
),
),
IconButton(
key: Key('printLabels.copies.plus.${entry.varietyLabel}'),
onPressed: onPlus,
icon: const Icon(Icons.add_circle_outline),
visualDensity: VisualDensity.compact,
),
],
),
);
}
}

View file

@ -132,5 +132,26 @@ void main() {
final rows = await repo.labelRows({selected}, languageCode: 'en');
expect(rows.map((r) => r.varietyLabel), ['Selected']);
});
test('suggests copies from the latest container count', () async {
final v = await repo.addQuickVariety(label: 'Tomate');
final withJars = await repo.addLot(varietyId: v, harvestYear: 2024);
final noCheck = await repo.addLot(varietyId: v, harvestYear: 2025);
// Two checks on the same lot: the most recent (3 jars) wins.
await repo.addConditionCheck(lotId: withJars, checkedOn: 1, containerCount: 2);
await repo.addConditionCheck(lotId: withJars, checkedOn: 2, containerCount: 3);
final rows = await repo.labelRows({v}, languageCode: 'en');
final byYear = {for (final r in rows) r.harvestYear: r};
expect(byYear[2024]!.suggestedCopies, 3); // latest check
expect(byYear[2025]!.suggestedCopies, 1); // no check default 1
expect(noCheck, isNotEmpty);
});
test('a variety with no lots suggests a single copy', () async {
final v = await repo.addQuickVariety(label: 'Alubia');
final rows = await repo.labelRows({v}, languageCode: 'en');
expect(rows.single.suggestedCopies, 1);
});
});
}

View file

@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/variety_repository.dart';
import 'package:tane/i18n/strings.g.dart';
import 'package:tane/ui/label_print_sheet.dart';
/// Hosts a button that opens the print-labels sheet for [entries].
Widget _host(List<SeedLabelEntry> entries) {
LocaleSettings.setLocaleSync(AppLocale.en);
return TranslationProvider(
child: MaterialApp(
supportedLocales: AppLocaleUtils.supportedLocales,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: Scaffold(
body: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showLabelPrintSheet(context, entries),
child: const Text('Open'),
),
),
),
),
);
}
void main() {
Future<void> open(WidgetTester tester) async {
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
}
testWidgets('prefills copies from the suggested count and totals them', (
tester,
) async {
await tester.pumpWidget(
_host(const [
SeedLabelEntry(varietyLabel: 'Tomate', suggestedCopies: 3),
SeedLabelEntry(varietyLabel: 'Alubia', suggestedCopies: 1),
]),
);
await open(tester);
// 3 + 1 suggested = 4 labels.
expect(find.text('4 labels'), findsOneWidget);
});
testWidgets('the stepper and the field both edit a copy count', (
tester,
) async {
await tester.pumpWidget(
_host(const [
SeedLabelEntry(varietyLabel: 'Tomate', suggestedCopies: 3),
SeedLabelEntry(varietyLabel: 'Alubia', suggestedCopies: 1),
]),
);
await open(tester);
await tester.tap(
find.byKey(const Key('printLabels.copies.minus.Tomate')),
);
await tester.pumpAndSettle();
expect(find.text('3 labels'), findsOneWidget); // 2 + 1
await tester.enterText(
find.byKey(const Key('printLabels.copies.field.Tomate')),
'5',
);
await tester.pumpAndSettle();
expect(find.text('6 labels'), findsOneWidget); // 5 + 1
});
testWidgets('saving is disabled when the total is zero', (tester) async {
await tester.pumpWidget(
_host(const [
SeedLabelEntry(varietyLabel: 'Tomate', suggestedCopies: 1),
]),
);
await open(tester);
await tester.enterText(
find.byKey(const Key('printLabels.copies.field.Tomate')),
'0',
);
await tester.pumpAndSettle();
expect(find.text('0 labels'), findsOneWidget);
final save = tester.widget<FilledButton>(
find.byKey(const Key('printLabels.save')),
);
expect(save.onPressed, isNull);
});
}