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
63f48db5c2
commit
d36fd05741
4 changed files with 298 additions and 4 deletions
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
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