tane/apps/app_seeds/lib/ui/label_print_sheet.dart
vjrj 064248144a refactor(branding): rename app Tanemaki → Tane
Public name is now Tane (種, "seed"); Tanemaki (種まき, "to scatter seeds")
is kept only as etymology in the About screen and onboarding.

App not yet distributed, so format tokens rename cleanly with no migration:
backup extension .tanemaki→.tane, QR scheme tanemaki://→tane://, file
prefixes tane-*. Website link and species-catalog User-Agent →
tane.comunes.org. Android label capitalised to "Tane".

Identity/crypto constants already use org.comunes.tane (derivation, HKDF
backup, sync namespace, TANE1 recovery tag) — left untouched, so keys,
backups and sync are unaffected. Tests updated; analyze + full suite green.
2026-07-12 13:04:16 +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: 'tane-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,
),
],
),
);
}
}