diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index 4b21f0b..4c30010 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -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 get props => [ varietyLabel, @@ -157,6 +163,7 @@ class SeedLabelEntry extends Equatable { quantity, originName, originPlace, + suggestedCopies, ]; } @@ -1325,6 +1332,28 @@ class VarietyRepository { (lotsByVariety[l.varietyId] ??= []).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 = {}; + 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), ); } diff --git a/apps/app_seeds/lib/ui/label_print_sheet.dart b/apps/app_seeds/lib/ui/label_print_sheet.dart index d1f1f0b..0a1119d 100644 --- a/apps/app_seeds/lib/ui/label_print_sheet.dart +++ b/apps/app_seeds/lib/ui/label_print_sheet.dart @@ -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 showLabelPrintSheet( BuildContext context, @@ -21,6 +23,9 @@ Future 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 _copies = [ + for (final e in widget.entries) e.suggestedCopies.clamp(0, _maxCopies), + ]; + late final List _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( 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 _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 = [ + 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().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 onChanged; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final subtitle = [ + 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, + ), + ], + ), + ); + } +} diff --git a/apps/app_seeds/test/data/variety_repository_test.dart b/apps/app_seeds/test/data/variety_repository_test.dart index a852fe8..b41f3c5 100644 --- a/apps/app_seeds/test/data/variety_repository_test.dart +++ b/apps/app_seeds/test/data/variety_repository_test.dart @@ -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); + }); }); } diff --git a/apps/app_seeds/test/ui/label_print_sheet_test.dart b/apps/app_seeds/test/ui/label_print_sheet_test.dart new file mode 100644 index 0000000..258f383 --- /dev/null +++ b/apps/app_seeds/test/ui/label_print_sheet_test.dart @@ -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 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 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( + find.byKey(const Key('printLabels.save')), + ); + expect(save.onPressed, isNull); + }); +}