tane/apps/app_seeds/lib/ui/label_print_sheet.dart
vjrj d36fd05741 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
2026-07-10 20:12:36 +02:00

306 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter/services.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): 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,
List<SeedLabelEntry> entries,
) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => _LabelPrintSheet(entries: entries),
);
}
/// 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});
final List<SeedLabelEntry> entries;
@override
State<_LabelPrintSheet> createState() => _LabelPrintSheetState();
}
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;
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: _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,
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 || _total == 0) ? null : _save,
icon: const Icon(Icons.save_alt_outlined),
label: Text(t.printLabels.save),
),
),
],
),
),
);
}
void _pick(LabelSheetFormat? 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 {
final t = context.t;
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
final rtl = Directionality.of(context) == TextDirection.rtl;
setState(() => _saving = true);
// 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,
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);
}
/// 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,
),
],
),
);
}
}