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:
parent
4d110e8082
commit
4b71859416
4 changed files with 298 additions and 4 deletions
|
|
@ -134,6 +134,7 @@ class SeedLabelEntry extends Equatable {
|
||||||
this.quantity,
|
this.quantity,
|
||||||
this.originName,
|
this.originName,
|
||||||
this.originPlace,
|
this.originPlace,
|
||||||
|
this.suggestedCopies = 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String varietyLabel;
|
final String varietyLabel;
|
||||||
|
|
@ -147,6 +148,11 @@ class SeedLabelEntry extends Equatable {
|
||||||
final String? originName;
|
final String? originName;
|
||||||
final String? originPlace;
|
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
|
@override
|
||||||
List<Object?> get props => [
|
List<Object?> get props => [
|
||||||
varietyLabel,
|
varietyLabel,
|
||||||
|
|
@ -157,6 +163,7 @@ class SeedLabelEntry extends Equatable {
|
||||||
quantity,
|
quantity,
|
||||||
originName,
|
originName,
|
||||||
originPlace,
|
originPlace,
|
||||||
|
suggestedCopies,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1325,6 +1332,28 @@ class VarietyRepository {
|
||||||
(lotsByVariety[l.varietyId] ??= <Lot>[]).add(l);
|
(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) {
|
SeedLabelEntry entryFor(Variety v, Lot? l) {
|
||||||
final hasQuantity =
|
final hasQuantity =
|
||||||
l != null &&
|
l != null &&
|
||||||
|
|
@ -1346,6 +1375,7 @@ class VarietyRepository {
|
||||||
: null,
|
: null,
|
||||||
originName: l?.originName,
|
originName: l?.originName,
|
||||||
originPlace: l?.originPlace,
|
originPlace: l?.originPlace,
|
||||||
|
suggestedCopies: l == null ? 1 : (containersByLot[l.id] ?? 1),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import '../data/export_import/seed_label_codec.dart';
|
import '../data/export_import/seed_label_codec.dart';
|
||||||
import '../data/variety_repository.dart';
|
import '../data/variety_repository.dart';
|
||||||
|
|
@ -8,7 +9,8 @@ import '../services/label_sheet_service.dart';
|
||||||
import 'quantity_kind_l10n.dart';
|
import 'quantity_kind_l10n.dart';
|
||||||
|
|
||||||
/// Opens the print-labels sheet for [entries] (already gathered from the
|
/// 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.
|
/// each with a QR that carries the seed's data.
|
||||||
Future<void> showLabelPrintSheet(
|
Future<void> showLabelPrintSheet(
|
||||||
BuildContext context,
|
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 {
|
class _LabelPrintSheet extends StatefulWidget {
|
||||||
const _LabelPrintSheet({required this.entries});
|
const _LabelPrintSheet({required this.entries});
|
||||||
|
|
||||||
|
|
@ -34,6 +39,24 @@ class _LabelPrintSheetState extends State<_LabelPrintSheet> {
|
||||||
LabelSheetFormat _format = LabelSheetFormat.stickers;
|
LabelSheetFormat _format = LabelSheetFormat.stickers;
|
||||||
bool _saving = false;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
|
|
@ -50,10 +73,32 @@ class _LabelPrintSheetState extends State<_LabelPrintSheet> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
t.printLabels.count(n: widget.entries.length),
|
t.printLabels.count(n: _total),
|
||||||
|
key: const Key('printLabels.total'),
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
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>(
|
RadioGroup<LabelSheetFormat>(
|
||||||
groupValue: _format,
|
groupValue: _format,
|
||||||
onChanged: _pick,
|
onChanged: _pick,
|
||||||
|
|
@ -82,7 +127,7 @@ class _LabelPrintSheetState extends State<_LabelPrintSheet> {
|
||||||
alignment: AlignmentDirectional.centerEnd,
|
alignment: AlignmentDirectional.centerEnd,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
key: const Key('printLabels.save'),
|
key: const Key('printLabels.save'),
|
||||||
onPressed: _saving ? null : _save,
|
onPressed: (_saving || _total == 0) ? null : _save,
|
||||||
icon: const Icon(Icons.save_alt_outlined),
|
icon: const Icon(Icons.save_alt_outlined),
|
||||||
label: Text(t.printLabels.save),
|
label: Text(t.printLabels.save),
|
||||||
),
|
),
|
||||||
|
|
@ -97,6 +142,19 @@ class _LabelPrintSheetState extends State<_LabelPrintSheet> {
|
||||||
if (value != null) setState(() => _format = value);
|
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 {
|
Future<void> _save() async {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
|
@ -104,7 +162,11 @@ class _LabelPrintSheetState extends State<_LabelPrintSheet> {
|
||||||
final rtl = Directionality.of(context) == TextDirection.rtl;
|
final rtl = Directionality.of(context) == TextDirection.rtl;
|
||||||
setState(() => _saving = true);
|
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(
|
final saved = await getIt<LabelSheetService>().saveLabels(
|
||||||
labels: labels,
|
labels: labels,
|
||||||
format: _format,
|
format: _format,
|
||||||
|
|
@ -158,3 +220,87 @@ class _LabelPrintSheetState extends State<_LabelPrintSheet> {
|
||||||
|
|
||||||
String _today() => DateTime.now().toIso8601String().substring(0, 10);
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -132,5 +132,26 @@ void main() {
|
||||||
final rows = await repo.labelRows({selected}, languageCode: 'en');
|
final rows = await repo.labelRows({selected}, languageCode: 'en');
|
||||||
expect(rows.map((r) => r.varietyLabel), ['Selected']);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
97
apps/app_seeds/test/ui/label_print_sheet_test.dart
Normal file
97
apps/app_seeds/test/ui/label_print_sheet_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue