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
306 lines
10 KiB
Dart
306 lines
10 KiB
Dart
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,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|