feat(labels): print seed labels with QR from an inventory selection
Add multi-select to the inventory and a "Print labels" flow that renders a PDF sheet of labels (small stickers or large cards), each carrying the seed's common name, variety, scientific name, year/origin and a tanemaki://seed QR that holds the seed's data offline (future scan-to-import). - SeedLabelCodec: compact, versioned tanemaki://seed URI (encode/decode) - LabelSheetService: pure-Dart PDF grid (pdf + barcode + DejaVu), RTL-aware - VarietyRepository.labelRows: one row per lot, locale-picked common name - InventoryCubit selection mode; contextual app bar + label print sheet - i18n en/es/pt (printLabels.*) Tests: codec, PDF service (both formats + RTL), labelRows, selection cubit, inventory widget selection.
This commit is contained in:
parent
0cecb943f0
commit
bf091bf852
19 changed files with 1465 additions and 116 deletions
92
apps/app_seeds/lib/data/export_import/seed_label_codec.dart
Normal file
92
apps/app_seeds/lib/data/export_import/seed_label_codec.dart
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// The seed facts a printed label carries in its QR code — just enough to
|
||||||
|
/// identify the seed offline, so the data travels on the physical packet with
|
||||||
|
/// no server and no account. Kept deliberately small so the QR stays scannable
|
||||||
|
/// at sticker size.
|
||||||
|
class SeedLabelData extends Equatable {
|
||||||
|
const SeedLabelData({
|
||||||
|
required this.varietyLabel,
|
||||||
|
this.scientificName,
|
||||||
|
this.commonName,
|
||||||
|
this.year,
|
||||||
|
this.origin,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The variety's label, as its keeper writes it (e.g. "Acelga de Perales").
|
||||||
|
final String varietyLabel;
|
||||||
|
|
||||||
|
/// Scientific name of the linked species, if any (e.g. "Beta vulgaris").
|
||||||
|
final String? scientificName;
|
||||||
|
|
||||||
|
/// The species' common name in the keeper's locale (e.g. "Acelga").
|
||||||
|
final String? commonName;
|
||||||
|
|
||||||
|
/// Harvest year of the batch, if known.
|
||||||
|
final int? year;
|
||||||
|
|
||||||
|
/// Where the seed came from (origin name and/or place), if recorded.
|
||||||
|
final String? origin;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
varietyLabel,
|
||||||
|
scientificName,
|
||||||
|
commonName,
|
||||||
|
year,
|
||||||
|
origin,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes/decodes [SeedLabelData] as a compact, versioned `tanemaki://seed`
|
||||||
|
/// URI — self-describing and parseable, so a future scan-to-import feature can
|
||||||
|
/// read a label a keeper printed today. Unicode and RTL text are percent-encoded
|
||||||
|
/// by [Uri], so any QR reader round-trips them safely.
|
||||||
|
///
|
||||||
|
/// Shape: `tanemaki://seed?v=<variety>&s=<scientific>&c=<common>&y=<year>&o=<origin>`
|
||||||
|
/// Absent fields are omitted, keeping the payload (and the QR) as small as the
|
||||||
|
/// data allows.
|
||||||
|
abstract final class SeedLabelCodec {
|
||||||
|
static const _scheme = 'tanemaki';
|
||||||
|
static const _host = 'seed';
|
||||||
|
|
||||||
|
static String encode(SeedLabelData data) {
|
||||||
|
final params = <String, String>{
|
||||||
|
'v': data.varietyLabel,
|
||||||
|
if (_present(data.scientificName)) 's': data.scientificName!.trim(),
|
||||||
|
if (_present(data.commonName)) 'c': data.commonName!.trim(),
|
||||||
|
if (data.year != null) 'y': '${data.year}',
|
||||||
|
if (_present(data.origin)) 'o': data.origin!.trim(),
|
||||||
|
};
|
||||||
|
return Uri(
|
||||||
|
scheme: _scheme,
|
||||||
|
host: _host,
|
||||||
|
queryParameters: params,
|
||||||
|
).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a `tanemaki://seed` URI back to [SeedLabelData]. Returns null when
|
||||||
|
/// [input] is not a well-formed seed label (wrong scheme/host, or no variety).
|
||||||
|
static SeedLabelData? decode(String input) {
|
||||||
|
final uri = Uri.tryParse(input.trim());
|
||||||
|
if (uri == null || uri.scheme != _scheme || uri.host != _host) return null;
|
||||||
|
final q = uri.queryParameters;
|
||||||
|
final variety = q['v']?.trim();
|
||||||
|
if (variety == null || variety.isEmpty) return null;
|
||||||
|
final year = q['y'];
|
||||||
|
return SeedLabelData(
|
||||||
|
varietyLabel: variety,
|
||||||
|
scientificName: _clean(q['s']),
|
||||||
|
commonName: _clean(q['c']),
|
||||||
|
year: year == null ? null : int.tryParse(year),
|
||||||
|
origin: _clean(q['o']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _present(String? value) => value != null && value.trim().isNotEmpty;
|
||||||
|
|
||||||
|
static String? _clean(String? value) {
|
||||||
|
final trimmed = value?.trim();
|
||||||
|
return (trimmed == null || trimmed.isEmpty) ? null : trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -117,6 +117,49 @@ class SharedCatalogEntry extends Equatable {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One printable seed label for a selection of the inventory: a variety batch
|
||||||
|
/// (lot) with the facts a physical label carries — the species common name in
|
||||||
|
/// the keeper's locale, the variety label, the scientific name, the harvest
|
||||||
|
/// year and where it came from. A variety with no lots yields a single
|
||||||
|
/// name-only entry. Built by [VarietyRepository.labelRows]; unlike
|
||||||
|
/// [SharedCatalogEntry] it is not restricted to shared lots — you label what
|
||||||
|
/// you hold, private or not.
|
||||||
|
class SeedLabelEntry extends Equatable {
|
||||||
|
const SeedLabelEntry({
|
||||||
|
required this.varietyLabel,
|
||||||
|
this.commonName,
|
||||||
|
this.scientificName,
|
||||||
|
this.category,
|
||||||
|
this.harvestYear,
|
||||||
|
this.quantity,
|
||||||
|
this.originName,
|
||||||
|
this.originPlace,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String varietyLabel;
|
||||||
|
|
||||||
|
/// The species' common name in the keeper's locale ("Acelga"), if known.
|
||||||
|
final String? commonName;
|
||||||
|
final String? scientificName;
|
||||||
|
final String? category;
|
||||||
|
final int? harvestYear;
|
||||||
|
final Quantity? quantity;
|
||||||
|
final String? originName;
|
||||||
|
final String? originPlace;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
varietyLabel,
|
||||||
|
commonName,
|
||||||
|
scientificName,
|
||||||
|
category,
|
||||||
|
harvestYear,
|
||||||
|
quantity,
|
||||||
|
originName,
|
||||||
|
originPlace,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/// A lot the user has marked for sharing, carrying the lot [lotId] (so the
|
/// A lot the user has marked for sharing, carrying the lot [lotId] (so the
|
||||||
/// published offer has a stable, updatable id) plus the summary a network offer
|
/// published offer has a stable, updatable id) plus the summary a network offer
|
||||||
/// needs. Distinct from [SharedCatalogEntry], which is for the printable local
|
/// needs. Distinct from [SharedCatalogEntry], which is for the printable local
|
||||||
|
|
@ -1237,6 +1280,123 @@ class VarietyRepository {
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Printable label rows for a selection of [varietyIds]: one entry per
|
||||||
|
/// non-deleted lot (year/origin/quantity are per batch), or a single
|
||||||
|
/// name-only entry for a selected variety that holds no lots. [languageCode]
|
||||||
|
/// picks the species common name shown on top. Ordered by category, label
|
||||||
|
/// then harvest year. Draft and deleted varieties are excluded.
|
||||||
|
Future<List<SeedLabelEntry>> labelRows(
|
||||||
|
Set<String> varietyIds, {
|
||||||
|
required String languageCode,
|
||||||
|
}) async {
|
||||||
|
if (varietyIds.isEmpty) return const [];
|
||||||
|
final varieties =
|
||||||
|
await (_db.select(_db.varieties)..where(
|
||||||
|
(v) =>
|
||||||
|
v.id.isIn(varietyIds) &
|
||||||
|
v.isDeleted.equals(false) &
|
||||||
|
v.isDraft.equals(false),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
if (varieties.isEmpty) return const [];
|
||||||
|
|
||||||
|
final speciesIds = varieties
|
||||||
|
.map((v) => v.speciesId)
|
||||||
|
.whereType<String>()
|
||||||
|
.toSet();
|
||||||
|
final sciNames = await _scientificNamesFor(speciesIds);
|
||||||
|
final commonNames = await _commonNamesFor(speciesIds, languageCode);
|
||||||
|
|
||||||
|
final lots =
|
||||||
|
await (_db.select(_db.lots)..where(
|
||||||
|
(l) =>
|
||||||
|
l.varietyId.isIn(varieties.map((v) => v.id).toList()) &
|
||||||
|
l.isDeleted.equals(false),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
final lotsByVariety = <String, List<Lot>>{};
|
||||||
|
for (final l in lots) {
|
||||||
|
(lotsByVariety[l.varietyId] ??= <Lot>[]).add(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
SeedLabelEntry entryFor(Variety v, Lot? l) {
|
||||||
|
final hasQuantity =
|
||||||
|
l != null &&
|
||||||
|
(l.quantityKind != null ||
|
||||||
|
l.quantityPrecise != null ||
|
||||||
|
l.quantityLabel != null);
|
||||||
|
return SeedLabelEntry(
|
||||||
|
varietyLabel: v.label,
|
||||||
|
commonName: v.speciesId == null ? null : commonNames[v.speciesId],
|
||||||
|
scientificName: v.speciesId == null ? null : sciNames[v.speciesId],
|
||||||
|
category: v.category,
|
||||||
|
harvestYear: l?.harvestYear,
|
||||||
|
quantity: hasQuantity
|
||||||
|
? Quantity(
|
||||||
|
kind: _parseKind(l.quantityKind),
|
||||||
|
count: l.quantityPrecise,
|
||||||
|
label: l.quantityLabel,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
originName: l?.originName,
|
||||||
|
originPlace: l?.originPlace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final entries = <SeedLabelEntry>[];
|
||||||
|
for (final v in varieties) {
|
||||||
|
final vLots = lotsByVariety[v.id];
|
||||||
|
if (vLots == null || vLots.isEmpty) {
|
||||||
|
entries.add(entryFor(v, null));
|
||||||
|
} else {
|
||||||
|
for (final l in vLots) {
|
||||||
|
entries.add(entryFor(v, l));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries.sort((a, b) {
|
||||||
|
final byCategory = (a.category ?? '').toLowerCase().compareTo(
|
||||||
|
(b.category ?? '').toLowerCase(),
|
||||||
|
);
|
||||||
|
if (byCategory != 0) return byCategory;
|
||||||
|
final byLabel = a.varietyLabel.toLowerCase().compareTo(
|
||||||
|
b.varietyLabel.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (byLabel != 0) return byLabel;
|
||||||
|
return (a.harvestYear ?? 0).compareTo(b.harvestYear ?? 0);
|
||||||
|
});
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps each of [speciesIds] to its best common name for [languageCode] (one
|
||||||
|
/// query), preferring a name in the locale, else any. Species without a
|
||||||
|
/// common name are absent from the map.
|
||||||
|
Future<Map<String, String>> _commonNamesFor(
|
||||||
|
Set<String> speciesIds,
|
||||||
|
String languageCode,
|
||||||
|
) async {
|
||||||
|
if (speciesIds.isEmpty) return const {};
|
||||||
|
final rows =
|
||||||
|
await (_db.select(_db.speciesCommonNames)..where(
|
||||||
|
(n) => n.speciesId.isIn(speciesIds) & n.isDeleted.equals(false),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
final bySpecies = <String, List<({String name, String? language})>>{};
|
||||||
|
for (final n in rows) {
|
||||||
|
(bySpecies[n.speciesId] ??= []).add((
|
||||||
|
name: n.name,
|
||||||
|
language: n.language,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
final result = <String, String>{};
|
||||||
|
for (final entry in bySpecies.entries) {
|
||||||
|
final inLocale = entry.value.where((n) => n.language == languageCode);
|
||||||
|
result[entry.key] = (inLocale.isEmpty ? entry.value.first : inLocale.first)
|
||||||
|
.name;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// Lots the user marked to share (not private), each with its id and the
|
/// Lots the user marked to share (not private), each with its id and the
|
||||||
/// variety label — the input for publishing offers to the network (Block 2).
|
/// variety label — the input for publishing offers to the network (Block 2).
|
||||||
Future<List<ShareableLot>> shareableLots() async {
|
Future<List<ShareableLot>> shareableLots() async {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import '../services/file_service.dart';
|
||||||
import '../services/ocr/label_text_extractor.dart';
|
import '../services/ocr/label_text_extractor.dart';
|
||||||
import '../services/ocr/ocr_language.dart';
|
import '../services/ocr/ocr_language.dart';
|
||||||
import '../services/ocr/tesseract_label_extractor.dart';
|
import '../services/ocr/tesseract_label_extractor.dart';
|
||||||
|
import '../services/label_sheet_service.dart';
|
||||||
import '../services/onboarding_store.dart';
|
import '../services/onboarding_store.dart';
|
||||||
import '../services/recovery_sheet_service.dart';
|
import '../services/recovery_sheet_service.dart';
|
||||||
import '../services/share_catalog_service.dart';
|
import '../services/share_catalog_service.dart';
|
||||||
|
|
@ -104,6 +105,9 @@ Future<void> configureDependencies() async {
|
||||||
)
|
)
|
||||||
..registerSingleton<RecoverySheetService>(
|
..registerSingleton<RecoverySheetService>(
|
||||||
RecoverySheetService(files: fileService),
|
RecoverySheetService(files: fileService),
|
||||||
|
)
|
||||||
|
..registerSingleton<LabelSheetService>(
|
||||||
|
LabelSheetService(files: fileService),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Automatic silent backups need real file storage; the web build has none, so
|
// Automatic silent backups need real file storage; the web build has none, so
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,23 @@
|
||||||
"catalogSaved": "Catalog saved",
|
"catalogSaved": "Catalog saved",
|
||||||
"cancelled": "Cancelled"
|
"cancelled": "Cancelled"
|
||||||
},
|
},
|
||||||
|
"printLabels": {
|
||||||
|
"action": "Print labels",
|
||||||
|
"title": "Print labels",
|
||||||
|
"selectHint": "Pick the seeds to print labels for",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"selected": "{n} selected",
|
||||||
|
"none": "Select seeds first",
|
||||||
|
"format": "Label size",
|
||||||
|
"formatStickers": "Small stickers",
|
||||||
|
"formatStickersHint": "Many small labels per page — to peel onto packets",
|
||||||
|
"formatCards": "Large cards",
|
||||||
|
"formatCardsHint": "Fewer, bigger labels — for jars and boxes",
|
||||||
|
"count": "{n} labels",
|
||||||
|
"save": "Save labels",
|
||||||
|
"saved": "Labels saved",
|
||||||
|
"cancelled": "Cancelled"
|
||||||
|
},
|
||||||
"cropCalendar": {
|
"cropCalendar": {
|
||||||
"add": "Crop calendar",
|
"add": "Crop calendar",
|
||||||
"title": "Crop calendar",
|
"title": "Crop calendar",
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,23 @@
|
||||||
"catalogSaved": "Catálogo guardado",
|
"catalogSaved": "Catálogo guardado",
|
||||||
"cancelled": "Cancelado"
|
"cancelled": "Cancelado"
|
||||||
},
|
},
|
||||||
|
"printLabels": {
|
||||||
|
"action": "Imprimir etiquetas",
|
||||||
|
"title": "Imprimir etiquetas",
|
||||||
|
"selectHint": "Elige las semillas para imprimir sus etiquetas",
|
||||||
|
"selectAll": "Seleccionar todo",
|
||||||
|
"selected": "{n} seleccionadas",
|
||||||
|
"none": "Selecciona semillas primero",
|
||||||
|
"format": "Tamaño de etiqueta",
|
||||||
|
"formatStickers": "Pegatinas pequeñas",
|
||||||
|
"formatStickersHint": "Muchas etiquetas pequeñas por hoja — para pegar en sobres",
|
||||||
|
"formatCards": "Tarjetas grandes",
|
||||||
|
"formatCardsHint": "Menos etiquetas, más grandes — para botes y cajas",
|
||||||
|
"count": "{n} etiquetas",
|
||||||
|
"save": "Guardar etiquetas",
|
||||||
|
"saved": "Etiquetas guardadas",
|
||||||
|
"cancelled": "Cancelado"
|
||||||
|
},
|
||||||
"cropCalendar": {
|
"cropCalendar": {
|
||||||
"add": "Calendario de cultivo",
|
"add": "Calendario de cultivo",
|
||||||
"title": "Calendario de cultivo",
|
"title": "Calendario de cultivo",
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,23 @@
|
||||||
"catalogSaved": "Catálogo guardado",
|
"catalogSaved": "Catálogo guardado",
|
||||||
"cancelled": "Cancelado"
|
"cancelled": "Cancelado"
|
||||||
},
|
},
|
||||||
|
"printLabels": {
|
||||||
|
"action": "Imprimir etiquetas",
|
||||||
|
"title": "Imprimir etiquetas",
|
||||||
|
"selectHint": "Escolhe as sementes para imprimir as etiquetas",
|
||||||
|
"selectAll": "Selecionar tudo",
|
||||||
|
"selected": "{n} selecionadas",
|
||||||
|
"none": "Seleciona sementes primeiro",
|
||||||
|
"format": "Tamanho da etiqueta",
|
||||||
|
"formatStickers": "Autocolantes pequenos",
|
||||||
|
"formatStickersHint": "Muitas etiquetas pequenas por folha — para colar em envelopes",
|
||||||
|
"formatCards": "Cartões grandes",
|
||||||
|
"formatCardsHint": "Menos etiquetas, maiores — para frascos e caixas",
|
||||||
|
"count": "{n} etiquetas",
|
||||||
|
"save": "Guardar etiquetas",
|
||||||
|
"saved": "Etiquetas guardadas",
|
||||||
|
"cancelled": "Cancelado"
|
||||||
|
},
|
||||||
"cropCalendar": {
|
"cropCalendar": {
|
||||||
"add": "Calendário de cultivo",
|
"add": "Calendário de cultivo",
|
||||||
"title": "Calendário de cultivo",
|
"title": "Calendário de cultivo",
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 3
|
/// Locales: 3
|
||||||
/// Strings: 998 (332 per locale)
|
/// Strings: 1043 (347 per locale)
|
||||||
///
|
///
|
||||||
/// Built on 2026-07-10 at 10:26 UTC
|
/// Built on 2026-07-10 at 15:14 UTC
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
||||||
late final Translations$provenance$en provenance = Translations$provenance$en.internal(_root);
|
late final Translations$provenance$en provenance = Translations$provenance$en.internal(_root);
|
||||||
late final Translations$abundance$en abundance = Translations$abundance$en.internal(_root);
|
late final Translations$abundance$en abundance = Translations$abundance$en.internal(_root);
|
||||||
late final Translations$share$en share = Translations$share$en.internal(_root);
|
late final Translations$share$en share = Translations$share$en.internal(_root);
|
||||||
|
late final Translations$printLabels$en printLabels = Translations$printLabels$en.internal(_root);
|
||||||
late final Translations$cropCalendar$en cropCalendar = Translations$cropCalendar$en.internal(_root);
|
late final Translations$cropCalendar$en cropCalendar = Translations$cropCalendar$en.internal(_root);
|
||||||
late final Translations$needsReproduction$en needsReproduction = Translations$needsReproduction$en.internal(_root);
|
late final Translations$needsReproduction$en needsReproduction = Translations$needsReproduction$en.internal(_root);
|
||||||
late final Translations$preservation$en preservation = Translations$preservation$en.internal(_root);
|
late final Translations$preservation$en preservation = Translations$preservation$en.internal(_root);
|
||||||
|
|
@ -872,6 +873,60 @@ class Translations$share$en {
|
||||||
String get cancelled => 'Cancelled';
|
String get cancelled => 'Cancelled';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path: printLabels
|
||||||
|
class Translations$printLabels$en {
|
||||||
|
Translations$printLabels$en.internal(this._root);
|
||||||
|
|
||||||
|
final Translations _root; // ignore: unused_field
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
|
||||||
|
/// en: 'Print labels'
|
||||||
|
String get action => 'Print labels';
|
||||||
|
|
||||||
|
/// en: 'Print labels'
|
||||||
|
String get title => 'Print labels';
|
||||||
|
|
||||||
|
/// en: 'Pick the seeds to print labels for'
|
||||||
|
String get selectHint => 'Pick the seeds to print labels for';
|
||||||
|
|
||||||
|
/// en: 'Select all'
|
||||||
|
String get selectAll => 'Select all';
|
||||||
|
|
||||||
|
/// en: '{n} selected'
|
||||||
|
String selected({required Object n}) => '${n} selected';
|
||||||
|
|
||||||
|
/// en: 'Select seeds first'
|
||||||
|
String get none => 'Select seeds first';
|
||||||
|
|
||||||
|
/// en: 'Label size'
|
||||||
|
String get format => 'Label size';
|
||||||
|
|
||||||
|
/// en: 'Small stickers'
|
||||||
|
String get formatStickers => 'Small stickers';
|
||||||
|
|
||||||
|
/// en: 'Many small labels per page — to peel onto packets'
|
||||||
|
String get formatStickersHint => 'Many small labels per page — to peel onto packets';
|
||||||
|
|
||||||
|
/// en: 'Large cards'
|
||||||
|
String get formatCards => 'Large cards';
|
||||||
|
|
||||||
|
/// en: 'Fewer, bigger labels — for jars and boxes'
|
||||||
|
String get formatCardsHint => 'Fewer, bigger labels — for jars and boxes';
|
||||||
|
|
||||||
|
/// en: '{n} labels'
|
||||||
|
String count({required Object n}) => '${n} labels';
|
||||||
|
|
||||||
|
/// en: 'Save labels'
|
||||||
|
String get save => 'Save labels';
|
||||||
|
|
||||||
|
/// en: 'Labels saved'
|
||||||
|
String get saved => 'Labels saved';
|
||||||
|
|
||||||
|
/// en: 'Cancelled'
|
||||||
|
String get cancelled => 'Cancelled';
|
||||||
|
}
|
||||||
|
|
||||||
// Path: cropCalendar
|
// Path: cropCalendar
|
||||||
class Translations$cropCalendar$en {
|
class Translations$cropCalendar$en {
|
||||||
Translations$cropCalendar$en.internal(this._root);
|
Translations$cropCalendar$en.internal(this._root);
|
||||||
|
|
@ -1876,6 +1931,21 @@ extension on Translations {
|
||||||
'share.catalogTitle' => 'What I share',
|
'share.catalogTitle' => 'What I share',
|
||||||
'share.catalogSaved' => 'Catalog saved',
|
'share.catalogSaved' => 'Catalog saved',
|
||||||
'share.cancelled' => 'Cancelled',
|
'share.cancelled' => 'Cancelled',
|
||||||
|
'printLabels.action' => 'Print labels',
|
||||||
|
'printLabels.title' => 'Print labels',
|
||||||
|
'printLabels.selectHint' => 'Pick the seeds to print labels for',
|
||||||
|
'printLabels.selectAll' => 'Select all',
|
||||||
|
'printLabels.selected' => ({required Object n}) => '${n} selected',
|
||||||
|
'printLabels.none' => 'Select seeds first',
|
||||||
|
'printLabels.format' => 'Label size',
|
||||||
|
'printLabels.formatStickers' => 'Small stickers',
|
||||||
|
'printLabels.formatStickersHint' => 'Many small labels per page — to peel onto packets',
|
||||||
|
'printLabels.formatCards' => 'Large cards',
|
||||||
|
'printLabels.formatCardsHint' => 'Fewer, bigger labels — for jars and boxes',
|
||||||
|
'printLabels.count' => ({required Object n}) => '${n} labels',
|
||||||
|
'printLabels.save' => 'Save labels',
|
||||||
|
'printLabels.saved' => 'Labels saved',
|
||||||
|
'printLabels.cancelled' => 'Cancelled',
|
||||||
'cropCalendar.add' => 'Crop calendar',
|
'cropCalendar.add' => 'Crop calendar',
|
||||||
'cropCalendar.title' => 'Crop calendar',
|
'cropCalendar.title' => 'Crop calendar',
|
||||||
'cropCalendar.sow' => 'Sow',
|
'cropCalendar.sow' => 'Sow',
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
||||||
@override late final _Translations$provenance$es provenance = _Translations$provenance$es._(_root);
|
@override late final _Translations$provenance$es provenance = _Translations$provenance$es._(_root);
|
||||||
@override late final _Translations$abundance$es abundance = _Translations$abundance$es._(_root);
|
@override late final _Translations$abundance$es abundance = _Translations$abundance$es._(_root);
|
||||||
@override late final _Translations$share$es share = _Translations$share$es._(_root);
|
@override late final _Translations$share$es share = _Translations$share$es._(_root);
|
||||||
|
@override late final _Translations$printLabels$es printLabels = _Translations$printLabels$es._(_root);
|
||||||
@override late final _Translations$cropCalendar$es cropCalendar = _Translations$cropCalendar$es._(_root);
|
@override late final _Translations$cropCalendar$es cropCalendar = _Translations$cropCalendar$es._(_root);
|
||||||
@override late final _Translations$needsReproduction$es needsReproduction = _Translations$needsReproduction$es._(_root);
|
@override late final _Translations$needsReproduction$es needsReproduction = _Translations$needsReproduction$es._(_root);
|
||||||
@override late final _Translations$preservation$es preservation = _Translations$preservation$es._(_root);
|
@override late final _Translations$preservation$es preservation = _Translations$preservation$es._(_root);
|
||||||
|
|
@ -487,6 +488,30 @@ class _Translations$share$es extends Translations$share$en {
|
||||||
@override String get cancelled => 'Cancelado';
|
@override String get cancelled => 'Cancelado';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path: printLabels
|
||||||
|
class _Translations$printLabels$es extends Translations$printLabels$en {
|
||||||
|
_Translations$printLabels$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||||
|
|
||||||
|
final TranslationsEs _root; // ignore: unused_field
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
@override String get action => 'Imprimir etiquetas';
|
||||||
|
@override String get title => 'Imprimir etiquetas';
|
||||||
|
@override String get selectHint => 'Elige las semillas para imprimir sus etiquetas';
|
||||||
|
@override String get selectAll => 'Seleccionar todo';
|
||||||
|
@override String selected({required Object n}) => '${n} seleccionadas';
|
||||||
|
@override String get none => 'Selecciona semillas primero';
|
||||||
|
@override String get format => 'Tamaño de etiqueta';
|
||||||
|
@override String get formatStickers => 'Pegatinas pequeñas';
|
||||||
|
@override String get formatStickersHint => 'Muchas etiquetas pequeñas por hoja — para pegar en sobres';
|
||||||
|
@override String get formatCards => 'Tarjetas grandes';
|
||||||
|
@override String get formatCardsHint => 'Menos etiquetas, más grandes — para botes y cajas';
|
||||||
|
@override String count({required Object n}) => '${n} etiquetas';
|
||||||
|
@override String get save => 'Guardar etiquetas';
|
||||||
|
@override String get saved => 'Etiquetas guardadas';
|
||||||
|
@override String get cancelled => 'Cancelado';
|
||||||
|
}
|
||||||
|
|
||||||
// Path: cropCalendar
|
// Path: cropCalendar
|
||||||
class _Translations$cropCalendar$es extends Translations$cropCalendar$en {
|
class _Translations$cropCalendar$es extends Translations$cropCalendar$en {
|
||||||
_Translations$cropCalendar$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
_Translations$cropCalendar$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||||
|
|
@ -1228,6 +1253,21 @@ extension on TranslationsEs {
|
||||||
'share.catalogTitle' => 'Lo que comparto',
|
'share.catalogTitle' => 'Lo que comparto',
|
||||||
'share.catalogSaved' => 'Catálogo guardado',
|
'share.catalogSaved' => 'Catálogo guardado',
|
||||||
'share.cancelled' => 'Cancelado',
|
'share.cancelled' => 'Cancelado',
|
||||||
|
'printLabels.action' => 'Imprimir etiquetas',
|
||||||
|
'printLabels.title' => 'Imprimir etiquetas',
|
||||||
|
'printLabels.selectHint' => 'Elige las semillas para imprimir sus etiquetas',
|
||||||
|
'printLabels.selectAll' => 'Seleccionar todo',
|
||||||
|
'printLabels.selected' => ({required Object n}) => '${n} seleccionadas',
|
||||||
|
'printLabels.none' => 'Selecciona semillas primero',
|
||||||
|
'printLabels.format' => 'Tamaño de etiqueta',
|
||||||
|
'printLabels.formatStickers' => 'Pegatinas pequeñas',
|
||||||
|
'printLabels.formatStickersHint' => 'Muchas etiquetas pequeñas por hoja — para pegar en sobres',
|
||||||
|
'printLabels.formatCards' => 'Tarjetas grandes',
|
||||||
|
'printLabels.formatCardsHint' => 'Menos etiquetas, más grandes — para botes y cajas',
|
||||||
|
'printLabels.count' => ({required Object n}) => '${n} etiquetas',
|
||||||
|
'printLabels.save' => 'Guardar etiquetas',
|
||||||
|
'printLabels.saved' => 'Etiquetas guardadas',
|
||||||
|
'printLabels.cancelled' => 'Cancelado',
|
||||||
'cropCalendar.add' => 'Calendario de cultivo',
|
'cropCalendar.add' => 'Calendario de cultivo',
|
||||||
'cropCalendar.title' => 'Calendario de cultivo',
|
'cropCalendar.title' => 'Calendario de cultivo',
|
||||||
'cropCalendar.sow' => 'Siembra',
|
'cropCalendar.sow' => 'Siembra',
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ class TranslationsPt extends Translations with BaseTranslations<AppLocale, Trans
|
||||||
@override late final _Translations$provenance$pt provenance = _Translations$provenance$pt._(_root);
|
@override late final _Translations$provenance$pt provenance = _Translations$provenance$pt._(_root);
|
||||||
@override late final _Translations$abundance$pt abundance = _Translations$abundance$pt._(_root);
|
@override late final _Translations$abundance$pt abundance = _Translations$abundance$pt._(_root);
|
||||||
@override late final _Translations$share$pt share = _Translations$share$pt._(_root);
|
@override late final _Translations$share$pt share = _Translations$share$pt._(_root);
|
||||||
|
@override late final _Translations$printLabels$pt printLabels = _Translations$printLabels$pt._(_root);
|
||||||
@override late final _Translations$cropCalendar$pt cropCalendar = _Translations$cropCalendar$pt._(_root);
|
@override late final _Translations$cropCalendar$pt cropCalendar = _Translations$cropCalendar$pt._(_root);
|
||||||
@override late final _Translations$needsReproduction$pt needsReproduction = _Translations$needsReproduction$pt._(_root);
|
@override late final _Translations$needsReproduction$pt needsReproduction = _Translations$needsReproduction$pt._(_root);
|
||||||
@override late final _Translations$preservation$pt preservation = _Translations$preservation$pt._(_root);
|
@override late final _Translations$preservation$pt preservation = _Translations$preservation$pt._(_root);
|
||||||
|
|
@ -483,6 +484,30 @@ class _Translations$share$pt extends Translations$share$en {
|
||||||
@override String get cancelled => 'Cancelado';
|
@override String get cancelled => 'Cancelado';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path: printLabels
|
||||||
|
class _Translations$printLabels$pt extends Translations$printLabels$en {
|
||||||
|
_Translations$printLabels$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
||||||
|
|
||||||
|
final TranslationsPt _root; // ignore: unused_field
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
@override String get action => 'Imprimir etiquetas';
|
||||||
|
@override String get title => 'Imprimir etiquetas';
|
||||||
|
@override String get selectHint => 'Escolhe as sementes para imprimir as etiquetas';
|
||||||
|
@override String get selectAll => 'Selecionar tudo';
|
||||||
|
@override String selected({required Object n}) => '${n} selecionadas';
|
||||||
|
@override String get none => 'Seleciona sementes primeiro';
|
||||||
|
@override String get format => 'Tamanho da etiqueta';
|
||||||
|
@override String get formatStickers => 'Autocolantes pequenos';
|
||||||
|
@override String get formatStickersHint => 'Muitas etiquetas pequenas por folha — para colar em envelopes';
|
||||||
|
@override String get formatCards => 'Cartões grandes';
|
||||||
|
@override String get formatCardsHint => 'Menos etiquetas, maiores — para frascos e caixas';
|
||||||
|
@override String count({required Object n}) => '${n} etiquetas';
|
||||||
|
@override String get save => 'Guardar etiquetas';
|
||||||
|
@override String get saved => 'Etiquetas guardadas';
|
||||||
|
@override String get cancelled => 'Cancelado';
|
||||||
|
}
|
||||||
|
|
||||||
// Path: cropCalendar
|
// Path: cropCalendar
|
||||||
class _Translations$cropCalendar$pt extends Translations$cropCalendar$en {
|
class _Translations$cropCalendar$pt extends Translations$cropCalendar$en {
|
||||||
_Translations$cropCalendar$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
_Translations$cropCalendar$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
||||||
|
|
@ -1220,6 +1245,21 @@ extension on TranslationsPt {
|
||||||
'share.catalogTitle' => 'O que partilho',
|
'share.catalogTitle' => 'O que partilho',
|
||||||
'share.catalogSaved' => 'Catálogo guardado',
|
'share.catalogSaved' => 'Catálogo guardado',
|
||||||
'share.cancelled' => 'Cancelado',
|
'share.cancelled' => 'Cancelado',
|
||||||
|
'printLabels.action' => 'Imprimir etiquetas',
|
||||||
|
'printLabels.title' => 'Imprimir etiquetas',
|
||||||
|
'printLabels.selectHint' => 'Escolhe as sementes para imprimir as etiquetas',
|
||||||
|
'printLabels.selectAll' => 'Selecionar tudo',
|
||||||
|
'printLabels.selected' => ({required Object n}) => '${n} selecionadas',
|
||||||
|
'printLabels.none' => 'Seleciona sementes primeiro',
|
||||||
|
'printLabels.format' => 'Tamanho da etiqueta',
|
||||||
|
'printLabels.formatStickers' => 'Autocolantes pequenos',
|
||||||
|
'printLabels.formatStickersHint' => 'Muitas etiquetas pequenas por folha — para colar em envelopes',
|
||||||
|
'printLabels.formatCards' => 'Cartões grandes',
|
||||||
|
'printLabels.formatCardsHint' => 'Menos etiquetas, maiores — para frascos e caixas',
|
||||||
|
'printLabels.count' => ({required Object n}) => '${n} etiquetas',
|
||||||
|
'printLabels.save' => 'Guardar etiquetas',
|
||||||
|
'printLabels.saved' => 'Etiquetas guardadas',
|
||||||
|
'printLabels.cancelled' => 'Cancelado',
|
||||||
'cropCalendar.add' => 'Calendário de cultivo',
|
'cropCalendar.add' => 'Calendário de cultivo',
|
||||||
'cropCalendar.title' => 'Calendário de cultivo',
|
'cropCalendar.title' => 'Calendário de cultivo',
|
||||||
'cropCalendar.sow' => 'Sementeira',
|
'cropCalendar.sow' => 'Sementeira',
|
||||||
|
|
|
||||||
233
apps/app_seeds/lib/services/label_sheet_service.dart
Normal file
233
apps/app_seeds/lib/services/label_sheet_service.dart
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
|
import 'package:pdf/pdf.dart';
|
||||||
|
import 'package:pdf/widgets.dart' as pw;
|
||||||
|
|
||||||
|
import 'file_service.dart';
|
||||||
|
|
||||||
|
/// How the labels are tiled on the page: many small stickers to peel onto
|
||||||
|
/// packets, or fewer larger cards for jars and boxes.
|
||||||
|
enum LabelSheetFormat { stickers, cards }
|
||||||
|
|
||||||
|
/// One printable seed label. All strings arrive already localized — this
|
||||||
|
/// service knows nothing about i18n or the domain. [qrData] is the opaque
|
||||||
|
/// payload for the QR (built by the caller via `SeedLabelCodec`).
|
||||||
|
class LabelSheetLabel {
|
||||||
|
const LabelSheetLabel({
|
||||||
|
required this.varietyLabel,
|
||||||
|
required this.qrData,
|
||||||
|
this.commonName,
|
||||||
|
this.scientificName,
|
||||||
|
this.details,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The variety's label, as its keeper writes it ("Acelga de Perales").
|
||||||
|
final String varietyLabel;
|
||||||
|
|
||||||
|
/// The species' common name in the keeper's locale, shown uppercase on top
|
||||||
|
/// ("ACELGA"), like the old hand-written labels. Null when unknown.
|
||||||
|
final String? commonName;
|
||||||
|
|
||||||
|
/// Scientific name of the linked species (rendered in italics), if any.
|
||||||
|
final String? scientificName;
|
||||||
|
|
||||||
|
/// The batch facts line ("2006 · Perales · 2 cobs"), if any.
|
||||||
|
final String? details;
|
||||||
|
|
||||||
|
/// The QR payload (a `tanemaki://seed` URI), so the seed's data rides on the
|
||||||
|
/// physical packet.
|
||||||
|
final String qrData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fixed page geometry per [LabelSheetFormat], in PDF points.
|
||||||
|
class _Geometry {
|
||||||
|
const _Geometry({
|
||||||
|
required this.columns,
|
||||||
|
required this.rowHeight,
|
||||||
|
required this.qr,
|
||||||
|
required this.common,
|
||||||
|
required this.title,
|
||||||
|
required this.small,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int columns;
|
||||||
|
final double rowHeight;
|
||||||
|
final double qr;
|
||||||
|
final double common;
|
||||||
|
final double title;
|
||||||
|
final double small;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a sheet of seed labels (each with a QR) as a PDF and hands it to the
|
||||||
|
/// user via the save dialog — a paper bridge that needs no network. Pure of
|
||||||
|
/// platform channels apart from loading the bundled font, so it runs (and is
|
||||||
|
/// tested) on the host.
|
||||||
|
///
|
||||||
|
/// The embedded DejaVu face covers Latin (extended), Cyrillic and Greek. Like
|
||||||
|
/// the OCR traineddata, the PDF font is a per-locale resource: wider scripts
|
||||||
|
/// (Arabic, CJK) ship alongside their locales, not hardcoded here.
|
||||||
|
class LabelSheetService {
|
||||||
|
LabelSheetService({required FileService files}) : _files = files;
|
||||||
|
|
||||||
|
final FileService _files;
|
||||||
|
|
||||||
|
/// Outer page margin (points, ~8.5 mm).
|
||||||
|
static const _margin = 24.0;
|
||||||
|
|
||||||
|
static _Geometry _geometryOf(LabelSheetFormat format) => switch (format) {
|
||||||
|
// ~3 columns of ~64 mm stickers, tight text.
|
||||||
|
LabelSheetFormat.stickers => const _Geometry(
|
||||||
|
columns: 3,
|
||||||
|
rowHeight: 104,
|
||||||
|
qr: 46,
|
||||||
|
common: 7,
|
||||||
|
title: 9,
|
||||||
|
small: 6.5,
|
||||||
|
),
|
||||||
|
// ~2 columns of ~96 mm cards, room for the full facts line.
|
||||||
|
LabelSheetFormat.cards => const _Geometry(
|
||||||
|
columns: 2,
|
||||||
|
rowHeight: 152,
|
||||||
|
qr: 82,
|
||||||
|
common: 9,
|
||||||
|
title: 13,
|
||||||
|
small: 9,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<Uint8List> buildPdf({
|
||||||
|
required List<LabelSheetLabel> labels,
|
||||||
|
required LabelSheetFormat format,
|
||||||
|
bool rtl = false,
|
||||||
|
}) async {
|
||||||
|
final textDirection = rtl ? pw.TextDirection.rtl : pw.TextDirection.ltr;
|
||||||
|
final base = pw.Font.ttf(
|
||||||
|
await rootBundle.load('assets/fonts/DejaVuSans.ttf'),
|
||||||
|
);
|
||||||
|
final bold = pw.Font.ttf(
|
||||||
|
await rootBundle.load('assets/fonts/DejaVuSans-Bold.ttf'),
|
||||||
|
);
|
||||||
|
final doc = pw.Document(
|
||||||
|
theme: pw.ThemeData.withFont(base: base, bold: bold, italic: base),
|
||||||
|
);
|
||||||
|
|
||||||
|
final g = _geometryOf(format);
|
||||||
|
final labelWidth = (PdfPageFormat.a4.width - _margin * 2) / g.columns;
|
||||||
|
|
||||||
|
pw.Widget labelBox(LabelSheetLabel label) => pw.Container(
|
||||||
|
width: labelWidth,
|
||||||
|
height: g.rowHeight,
|
||||||
|
padding: const pw.EdgeInsets.all(6),
|
||||||
|
decoration: pw.BoxDecoration(
|
||||||
|
border: pw.Border.all(color: PdfColors.grey400, width: 0.5),
|
||||||
|
),
|
||||||
|
child: pw.Row(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
pw.BarcodeWidget(
|
||||||
|
barcode: pw.Barcode.qrCode(),
|
||||||
|
data: label.qrData,
|
||||||
|
width: g.qr,
|
||||||
|
height: g.qr,
|
||||||
|
drawText: false,
|
||||||
|
),
|
||||||
|
pw.SizedBox(width: 6),
|
||||||
|
pw.Expanded(
|
||||||
|
// Only the label's text mirrors for RTL; the grid itself stays
|
||||||
|
// left-to-right (a physical sheet has no reading direction).
|
||||||
|
child: pw.Directionality(
|
||||||
|
textDirection: textDirection,
|
||||||
|
child: pw.Column(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: pw.MainAxisAlignment.center,
|
||||||
|
mainAxisSize: pw.MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (label.commonName != null)
|
||||||
|
pw.Text(
|
||||||
|
label.commonName!.toUpperCase(),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: pw.TextOverflow.clip,
|
||||||
|
style: pw.TextStyle(
|
||||||
|
font: bold,
|
||||||
|
fontSize: g.common,
|
||||||
|
color: PdfColors.grey800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pw.Text(
|
||||||
|
label.varietyLabel,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: pw.TextOverflow.clip,
|
||||||
|
style: pw.TextStyle(font: bold, fontSize: g.title),
|
||||||
|
),
|
||||||
|
if (label.scientificName != null)
|
||||||
|
pw.Text(
|
||||||
|
label.scientificName!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: pw.TextOverflow.clip,
|
||||||
|
style: pw.TextStyle(
|
||||||
|
fontSize: g.small,
|
||||||
|
fontStyle: pw.FontStyle.italic,
|
||||||
|
color: PdfColors.grey700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (label.details != null)
|
||||||
|
pw.Text(
|
||||||
|
label.details!,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: pw.TextOverflow.clip,
|
||||||
|
style: pw.TextStyle(fontSize: g.small),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// One MultiPage child per grid row, so pagination breaks cleanly between
|
||||||
|
// rows and never splits a label. The last row is padded with empty cells to
|
||||||
|
// keep the grid left-aligned.
|
||||||
|
final rows = <pw.Widget>[];
|
||||||
|
for (var i = 0; i < labels.length; i += g.columns) {
|
||||||
|
final end = math.min(i + g.columns, labels.length);
|
||||||
|
rows.add(
|
||||||
|
pw.Row(
|
||||||
|
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
for (var j = i; j < end; j++) labelBox(labels[j]),
|
||||||
|
for (var k = end - i; k < g.columns; k++)
|
||||||
|
pw.SizedBox(width: labelWidth),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.addPage(
|
||||||
|
pw.MultiPage(
|
||||||
|
pageFormat: PdfPageFormat.a4,
|
||||||
|
margin: const pw.EdgeInsets.all(_margin),
|
||||||
|
build: (context) => rows,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return doc.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the sheet and asks the user where to keep it. Returns true when
|
||||||
|
/// saved, false when they cancelled the dialog.
|
||||||
|
Future<bool> saveLabels({
|
||||||
|
required List<LabelSheetLabel> labels,
|
||||||
|
required LabelSheetFormat format,
|
||||||
|
required String suggestedName,
|
||||||
|
bool rtl = false,
|
||||||
|
}) async {
|
||||||
|
final bytes = await buildPdf(labels: labels, format: format, rtl: rtl);
|
||||||
|
final path = await _files.saveFile(
|
||||||
|
suggestedName: suggestedName,
|
||||||
|
bytes: bytes,
|
||||||
|
);
|
||||||
|
return path != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,8 @@ class InventoryState extends Equatable {
|
||||||
this.needsReproductionOnly = false,
|
this.needsReproductionOnly = false,
|
||||||
this.sharingOnly = false,
|
this.sharingOnly = false,
|
||||||
this.loading = true,
|
this.loading = true,
|
||||||
|
this.selectionMode = false,
|
||||||
|
this.selectedIds = const {},
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<VarietyListItem> items;
|
final List<VarietyListItem> items;
|
||||||
|
|
@ -49,6 +51,13 @@ class InventoryState extends Equatable {
|
||||||
|
|
||||||
final bool loading;
|
final bool loading;
|
||||||
|
|
||||||
|
/// Whether the list is in multi-select mode — used to pick a subset of the
|
||||||
|
/// inventory to print labels for.
|
||||||
|
final bool selectionMode;
|
||||||
|
|
||||||
|
/// Ids of the varieties currently selected in [selectionMode].
|
||||||
|
final Set<String> selectedIds;
|
||||||
|
|
||||||
/// Categories present across all items, in display order (deduped), so the
|
/// Categories present across all items, in display order (deduped), so the
|
||||||
/// UI can offer one chip per category actually in use.
|
/// UI can offer one chip per category actually in use.
|
||||||
List<String> get categories {
|
List<String> get categories {
|
||||||
|
|
@ -101,6 +110,8 @@ class InventoryState extends Equatable {
|
||||||
bool? needsReproductionOnly,
|
bool? needsReproductionOnly,
|
||||||
bool? sharingOnly,
|
bool? sharingOnly,
|
||||||
bool? loading,
|
bool? loading,
|
||||||
|
bool? selectionMode,
|
||||||
|
Set<String>? selectedIds,
|
||||||
}) {
|
}) {
|
||||||
return InventoryState(
|
return InventoryState(
|
||||||
items: items ?? this.items,
|
items: items ?? this.items,
|
||||||
|
|
@ -113,6 +124,8 @@ class InventoryState extends Equatable {
|
||||||
needsReproductionOnly ?? this.needsReproductionOnly,
|
needsReproductionOnly ?? this.needsReproductionOnly,
|
||||||
sharingOnly: sharingOnly ?? this.sharingOnly,
|
sharingOnly: sharingOnly ?? this.sharingOnly,
|
||||||
loading: loading ?? this.loading,
|
loading: loading ?? this.loading,
|
||||||
|
selectionMode: selectionMode ?? this.selectionMode,
|
||||||
|
selectedIds: selectedIds ?? this.selectedIds,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,6 +140,8 @@ class InventoryState extends Equatable {
|
||||||
needsReproductionOnly,
|
needsReproductionOnly,
|
||||||
sharingOnly,
|
sharingOnly,
|
||||||
loading,
|
loading,
|
||||||
|
selectionMode,
|
||||||
|
selectedIds,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,6 +192,36 @@ class InventoryCubit extends Cubit<InventoryState> {
|
||||||
void toggleSharingOnly() =>
|
void toggleSharingOnly() =>
|
||||||
emit(state.copyWith(sharingOnly: !state.sharingOnly));
|
emit(state.copyWith(sharingOnly: !state.sharingOnly));
|
||||||
|
|
||||||
|
/// Enters multi-select mode with an empty selection (from the toolbar).
|
||||||
|
void startSelection() =>
|
||||||
|
emit(state.copyWith(selectionMode: true, selectedIds: const {}));
|
||||||
|
|
||||||
|
/// Enters multi-select mode with [id] as the first selected variety
|
||||||
|
/// (triggered by a long-press on a tile).
|
||||||
|
void enterSelection(String id) =>
|
||||||
|
emit(state.copyWith(selectionMode: true, selectedIds: {id}));
|
||||||
|
|
||||||
|
/// Toggles a variety in the selection (add if absent, remove if present).
|
||||||
|
/// An empty selection stays in selection mode; the user exits explicitly.
|
||||||
|
void toggleSelection(String id) {
|
||||||
|
final next = Set<String>.of(state.selectedIds);
|
||||||
|
if (!next.remove(id)) next.add(id);
|
||||||
|
emit(state.copyWith(selectionMode: true, selectedIds: next));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects every currently visible variety, so "filter to a category, then
|
||||||
|
/// select all" is the way to pick a subset of the inventory to print.
|
||||||
|
void selectAllVisible() => emit(
|
||||||
|
state.copyWith(
|
||||||
|
selectionMode: true,
|
||||||
|
selectedIds: state.visibleItems.map((i) => i.id).toSet(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Leaves selection mode and clears the selection.
|
||||||
|
void exitSelection() =>
|
||||||
|
emit(state.copyWith(selectionMode: false, selectedIds: const {}));
|
||||||
|
|
||||||
/// Clears all filters (search is left untouched).
|
/// Clears all filters (search is left untouched).
|
||||||
void clearFilters() => emit(
|
void clearFilters() => emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import '../services/share_catalog_service.dart';
|
||||||
import '../state/inventory_cubit.dart';
|
import '../state/inventory_cubit.dart';
|
||||||
import 'app_drawer.dart';
|
import 'app_drawer.dart';
|
||||||
import 'draft_triage.dart';
|
import 'draft_triage.dart';
|
||||||
|
import 'label_print_sheet.dart';
|
||||||
import 'quantity_kind_l10n.dart';
|
import 'quantity_kind_l10n.dart';
|
||||||
import 'quantity_picker.dart';
|
import 'quantity_picker.dart';
|
||||||
import 'quick_add_sheet.dart';
|
import 'quick_add_sheet.dart';
|
||||||
|
|
@ -26,32 +27,18 @@ class InventoryListScreen extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
|
return BlocBuilder<InventoryCubit, InventoryState>(
|
||||||
|
builder: (context, state) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: state.selectionMode
|
||||||
title: Text(t.inventory.title),
|
? _selectionAppBar(context, state)
|
||||||
actions: [
|
: _defaultAppBar(context, state),
|
||||||
// The paper bridge: only offered once something is marked to share.
|
// In selection mode the bars are contextual: no drawer or add FAB, so
|
||||||
BlocSelector<InventoryCubit, InventoryState, bool>(
|
// the whole screen is about picking a subset to print.
|
||||||
selector: (state) => state.hasShared,
|
drawer: state.selectionMode ? null : const AppDrawer(),
|
||||||
builder: (context, hasShared) => hasShared
|
floatingActionButton: state.selectionMode
|
||||||
? IconButton(
|
? null
|
||||||
key: const Key('inventory.printCatalog'),
|
: FloatingActionButton(
|
||||||
icon: const Icon(Icons.print_outlined),
|
|
||||||
tooltip: t.share.printCatalog,
|
|
||||||
onPressed: () => _printCatalog(context),
|
|
||||||
)
|
|
||||||
: const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
key: const Key('inventory.captureBurst'),
|
|
||||||
icon: const Icon(Icons.add_a_photo_outlined),
|
|
||||||
tooltip: t.draft.capture,
|
|
||||||
onPressed: () => _captureBurst(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
drawer: const AppDrawer(),
|
|
||||||
floatingActionButton: FloatingActionButton(
|
|
||||||
key: const Key('inventory.addFab'),
|
key: const Key('inventory.addFab'),
|
||||||
tooltip: t.quickAdd.title,
|
tooltip: t.quickAdd.title,
|
||||||
onPressed: () => showQuickAddSheet(
|
onPressed: () => showQuickAddSheet(
|
||||||
|
|
@ -60,14 +47,11 @@ class InventoryListScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.add),
|
child: const Icon(Icons.add),
|
||||||
),
|
),
|
||||||
body: BlocBuilder<InventoryCubit, InventoryState>(
|
body: state.loading
|
||||||
builder: (context, state) {
|
? const Center(child: CircularProgressIndicator())
|
||||||
if (state.loading) {
|
: Column(
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
return Column(
|
|
||||||
children: [
|
children: [
|
||||||
if (state.drafts.isNotEmpty)
|
if (state.drafts.isNotEmpty && !state.selectionMode)
|
||||||
_TriageBanner(count: state.drafts.length),
|
_TriageBanner(count: state.drafts.length),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||||
|
|
@ -80,7 +64,9 @@ class InventoryListScreen extends StatelessWidget {
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white,
|
fillColor: Colors.white,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
contentPadding: const EdgeInsets.symmetric(vertical: 14),
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(28),
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
|
|
@ -93,7 +79,9 @@ class InventoryListScreen extends StatelessWidget {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _InventoryBody(
|
child: _InventoryBody(
|
||||||
items: state.visibleItems,
|
items: state.visibleItems,
|
||||||
// Distinguish "no seeds at all" from "filters hid them all".
|
selectionMode: state.selectionMode,
|
||||||
|
selectedIds: state.selectedIds,
|
||||||
|
// Distinguish "no seeds at all" from "filters hid them".
|
||||||
filtered:
|
filtered:
|
||||||
state.categoryFilter.isNotEmpty ||
|
state.categoryFilter.isNotEmpty ||
|
||||||
state.typeFilter.isNotEmpty ||
|
state.typeFilter.isNotEmpty ||
|
||||||
|
|
@ -103,10 +91,96 @@ class InventoryListScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The normal top bar: print-what-I-share, enter label selection, capture.
|
||||||
|
PreferredSizeWidget _defaultAppBar(
|
||||||
|
BuildContext context,
|
||||||
|
InventoryState state,
|
||||||
|
) {
|
||||||
|
final t = context.t;
|
||||||
|
return AppBar(
|
||||||
|
title: Text(t.inventory.title),
|
||||||
|
actions: [
|
||||||
|
// The paper bridge: only offered once something is marked to share.
|
||||||
|
if (state.hasShared)
|
||||||
|
IconButton(
|
||||||
|
key: const Key('inventory.printCatalog'),
|
||||||
|
icon: const Icon(Icons.print_outlined),
|
||||||
|
tooltip: t.share.printCatalog,
|
||||||
|
onPressed: () => _printCatalog(context),
|
||||||
|
),
|
||||||
|
// Enter multi-select to print labels — only once there's something to
|
||||||
|
// label.
|
||||||
|
if (state.items.isNotEmpty)
|
||||||
|
IconButton(
|
||||||
|
key: const Key('inventory.selectLabels'),
|
||||||
|
icon: const Icon(Icons.label_outline),
|
||||||
|
tooltip: t.printLabels.action,
|
||||||
|
onPressed: context.read<InventoryCubit>().startSelection,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
key: const Key('inventory.captureBurst'),
|
||||||
|
icon: const Icon(Icons.add_a_photo_outlined),
|
||||||
|
tooltip: t.draft.capture,
|
||||||
|
onPressed: () => _captureBurst(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The contextual bar shown while picking seeds to print labels for.
|
||||||
|
PreferredSizeWidget _selectionAppBar(
|
||||||
|
BuildContext context,
|
||||||
|
InventoryState state,
|
||||||
|
) {
|
||||||
|
final t = context.t;
|
||||||
|
final cubit = context.read<InventoryCubit>();
|
||||||
|
return AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
key: const Key('inventory.selection.close'),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
tooltip: t.common.cancel,
|
||||||
|
onPressed: cubit.exitSelection,
|
||||||
|
),
|
||||||
|
title: Text(t.printLabels.selected(n: state.selectedIds.length)),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
key: const Key('inventory.selection.selectAll'),
|
||||||
|
onPressed: cubit.selectAllVisible,
|
||||||
|
child: Text(t.printLabels.selectAll),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
key: const Key('inventory.selection.print'),
|
||||||
|
icon: const Icon(Icons.label_outline),
|
||||||
|
tooltip: t.printLabels.action,
|
||||||
|
onPressed: state.selectedIds.isEmpty
|
||||||
|
? null
|
||||||
|
: () => _printLabels(context, state.selectedIds),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gathers the label rows for the selection and opens the print sheet.
|
||||||
|
Future<void> _printLabels(BuildContext context, Set<String> ids) async {
|
||||||
|
final t = context.t;
|
||||||
|
final repository = context.read<VarietyRepository>();
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final entries = await repository.labelRows(
|
||||||
|
ids,
|
||||||
|
languageCode: LocaleSettings.currentLocale.languageCode,
|
||||||
|
);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (entries.isEmpty) {
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(t.printLabels.none)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await showLabelPrintSheet(context, entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assembles the localized rows of the "what I share" catalog and saves it
|
/// Assembles the localized rows of the "what I share" catalog and saves it
|
||||||
|
|
@ -303,7 +377,12 @@ class _FilterBar extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _InventoryBody extends StatelessWidget {
|
class _InventoryBody extends StatelessWidget {
|
||||||
const _InventoryBody({required this.items, this.filtered = false});
|
const _InventoryBody({
|
||||||
|
required this.items,
|
||||||
|
this.filtered = false,
|
||||||
|
this.selectionMode = false,
|
||||||
|
this.selectedIds = const {},
|
||||||
|
});
|
||||||
|
|
||||||
final List<VarietyListItem> items;
|
final List<VarietyListItem> items;
|
||||||
|
|
||||||
|
|
@ -311,6 +390,12 @@ class _InventoryBody extends StatelessWidget {
|
||||||
/// than "no seeds yet".
|
/// than "no seeds yet".
|
||||||
final bool filtered;
|
final bool filtered;
|
||||||
|
|
||||||
|
/// Whether the list is in multi-select mode (tiles show a checkbox).
|
||||||
|
final bool selectionMode;
|
||||||
|
|
||||||
|
/// Ids currently selected, so each tile can reflect its checkbox state.
|
||||||
|
final Set<String> selectedIds;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
|
|
@ -348,7 +433,13 @@ class _InventoryBody extends StatelessWidget {
|
||||||
currentCategory = category;
|
currentCategory = category;
|
||||||
rows.add(_CategoryHeader(title: category));
|
rows.add(_CategoryHeader(title: category));
|
||||||
}
|
}
|
||||||
rows.add(_VarietyTile(item: item));
|
rows.add(
|
||||||
|
_VarietyTile(
|
||||||
|
item: item,
|
||||||
|
selectionMode: selectionMode,
|
||||||
|
selected: selectedIds.contains(item.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return ListView(children: rows);
|
return ListView(children: rows);
|
||||||
}
|
}
|
||||||
|
|
@ -376,15 +467,35 @@ class _CategoryHeader extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _VarietyTile extends StatelessWidget {
|
class _VarietyTile extends StatelessWidget {
|
||||||
const _VarietyTile({required this.item});
|
const _VarietyTile({
|
||||||
|
required this.item,
|
||||||
|
this.selectionMode = false,
|
||||||
|
this.selected = false,
|
||||||
|
});
|
||||||
|
|
||||||
final VarietyListItem item;
|
final VarietyListItem item;
|
||||||
|
|
||||||
|
/// Whether the list is picking seeds to print labels for: tap toggles the
|
||||||
|
/// selection instead of opening the detail, and the tile shows a checkbox.
|
||||||
|
final bool selectionMode;
|
||||||
|
final bool selected;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final cubit = context.read<InventoryCubit>();
|
||||||
void open() => context.push('/variety/${item.id}');
|
void open() => context.push('/variety/${item.id}');
|
||||||
|
void toggle() => cubit.toggleSelection(item.id);
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: _Avatar(item: item),
|
key: Key('inventory.tile.${item.id}'),
|
||||||
|
selected: selectionMode && selected,
|
||||||
|
leading: selectionMode
|
||||||
|
? Checkbox(
|
||||||
|
key: Key('inventory.select.${item.id}'),
|
||||||
|
value: selected,
|
||||||
|
onChanged: (_) => toggle(),
|
||||||
|
)
|
||||||
|
: _Avatar(item: item),
|
||||||
title: Text(
|
title: Text(
|
||||||
item.label,
|
item.label,
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
|
@ -395,7 +506,11 @@ class _VarietyTile extends StatelessWidget {
|
||||||
item.scientificName!,
|
item.scientificName!,
|
||||||
style: const TextStyle(fontStyle: FontStyle.italic),
|
style: const TextStyle(fontStyle: FontStyle.italic),
|
||||||
),
|
),
|
||||||
trailing: Row(
|
// In selection mode the trailing actions (which navigate away) give way to
|
||||||
|
// the checkbox flow.
|
||||||
|
trailing: selectionMode
|
||||||
|
? null
|
||||||
|
: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (item.isShared)
|
if (item.isShared)
|
||||||
|
|
@ -430,7 +545,8 @@ class _VarietyTile extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
onTap: open,
|
onTap: selectionMode ? toggle : open,
|
||||||
|
onLongPress: selectionMode ? null : () => cubit.enterSelection(item.id),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
160
apps/app_seeds/lib/ui/label_print_sheet.dart
Normal file
160
apps/app_seeds/lib/ui/label_print_sheet.dart
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
import 'package:flutter/material.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): pick 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
@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: widget.entries.length),
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
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 ? null : _save,
|
||||||
|
icon: const Icon(Icons.save_alt_outlined),
|
||||||
|
label: Text(t.printLabels.save),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pick(LabelSheetFormat? value) {
|
||||||
|
if (value != null) setState(() => _format = value);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
final labels = [for (final e in widget.entries) _toLabel(t, e)];
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/data/export_import/seed_label_codec.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('SeedLabelCodec.encode', () {
|
||||||
|
test('encodes all fields as a tanemaki://seed URI', () {
|
||||||
|
final uri = SeedLabelCodec.encode(
|
||||||
|
const SeedLabelData(
|
||||||
|
varietyLabel: 'Acelga de Perales',
|
||||||
|
scientificName: 'Beta vulgaris',
|
||||||
|
commonName: 'Acelga',
|
||||||
|
year: 2006,
|
||||||
|
origin: 'Perales',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(uri, startsWith('tanemaki://seed?'));
|
||||||
|
final parsed = Uri.parse(uri);
|
||||||
|
expect(parsed.scheme, 'tanemaki');
|
||||||
|
expect(parsed.host, 'seed');
|
||||||
|
expect(parsed.queryParameters, {
|
||||||
|
'v': 'Acelga de Perales',
|
||||||
|
's': 'Beta vulgaris',
|
||||||
|
'c': 'Acelga',
|
||||||
|
'y': '2006',
|
||||||
|
'o': 'Perales',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omits absent fields, keeping the payload small', () {
|
||||||
|
final uri = SeedLabelCodec.encode(
|
||||||
|
const SeedLabelData(varietyLabel: 'Alubia de Tolosa'),
|
||||||
|
);
|
||||||
|
final parsed = Uri.parse(uri);
|
||||||
|
expect(parsed.queryParameters, {'v': 'Alubia de Tolosa'});
|
||||||
|
expect(uri, isNot(contains('&s=')));
|
||||||
|
expect(uri, isNot(contains('y=')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats blank optional fields as absent', () {
|
||||||
|
final uri = SeedLabelCodec.encode(
|
||||||
|
const SeedLabelData(
|
||||||
|
varietyLabel: 'Maíz',
|
||||||
|
scientificName: ' ',
|
||||||
|
origin: '',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(Uri.parse(uri).queryParameters, {'v': 'Maíz'});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('SeedLabelCodec round-trip', () {
|
||||||
|
test('decode reverses encode for full data', () {
|
||||||
|
const data = SeedLabelData(
|
||||||
|
varietyLabel: 'Tomate Rosa',
|
||||||
|
scientificName: 'Solanum lycopersicum',
|
||||||
|
commonName: 'Tomate',
|
||||||
|
year: 2024,
|
||||||
|
origin: 'Huerta de la abuela, León',
|
||||||
|
);
|
||||||
|
expect(SeedLabelCodec.decode(SeedLabelCodec.encode(data)), data);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('survives unicode, spaces and separators', () {
|
||||||
|
const data = SeedLabelData(
|
||||||
|
varietyLabel: 'Grelo & nabo "do país"',
|
||||||
|
origin: 'A Coruña / Galiza',
|
||||||
|
commonName: 'بامية', // RTL script
|
||||||
|
);
|
||||||
|
final decoded = SeedLabelCodec.decode(SeedLabelCodec.encode(data));
|
||||||
|
expect(decoded, data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('SeedLabelCodec.decode', () {
|
||||||
|
test('rejects a foreign scheme', () {
|
||||||
|
expect(SeedLabelCodec.decode('https://seed?v=x'), isNull);
|
||||||
|
expect(SeedLabelCodec.decode('tanemaki://other?v=x'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a payload without a variety', () {
|
||||||
|
expect(SeedLabelCodec.decode('tanemaki://seed?s=Beta%20vulgaris'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores an unparseable year rather than throwing', () {
|
||||||
|
final decoded = SeedLabelCodec.decode('tanemaki://seed?v=Maíz&y=old');
|
||||||
|
expect(decoded?.varietyLabel, 'Maíz');
|
||||||
|
expect(decoded?.year, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:commons_core/commons_core.dart';
|
import 'package:commons_core/commons_core.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/data/species_repository.dart';
|
||||||
import 'package:tane/data/variety_repository.dart';
|
import 'package:tane/data/variety_repository.dart';
|
||||||
import 'package:tane/db/database.dart';
|
import 'package:tane/db/database.dart';
|
||||||
import 'package:tane/db/enums.dart';
|
import 'package:tane/db/enums.dart';
|
||||||
|
|
@ -85,4 +86,51 @@ void main() {
|
||||||
expect(stamps.toSet().length, 2);
|
expect(stamps.toSet().length, 2);
|
||||||
expect(() => stamps.map(Hlc.parse).toList(), returnsNormally);
|
expect(() => stamps.map(Hlc.parse).toList(), returnsNormally);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('labelRows', () {
|
||||||
|
test('expands one entry per lot and resolves the locale common name', () async {
|
||||||
|
final speciesRepo = newTestSpeciesRepository(db);
|
||||||
|
await speciesRepo.seedBundled(const [
|
||||||
|
SpeciesSeed(
|
||||||
|
scientificName: 'Beta vulgaris',
|
||||||
|
family: 'Amaranthaceae',
|
||||||
|
commonNames: {
|
||||||
|
'en': ['Chard'],
|
||||||
|
'es': ['Acelga'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
final speciesId = (await db.select(db.species).getSingle()).id;
|
||||||
|
|
||||||
|
final v = await repo.addQuickVariety(
|
||||||
|
label: 'Acelga de Perales',
|
||||||
|
category: 'Amaranthaceae',
|
||||||
|
);
|
||||||
|
await repo.linkSpecies(v, speciesId);
|
||||||
|
await repo.addLot(varietyId: v, harvestYear: 2006, originName: 'Perales');
|
||||||
|
await repo.addLot(varietyId: v, harvestYear: 2008);
|
||||||
|
|
||||||
|
final rows = await repo.labelRows({v}, languageCode: 'es');
|
||||||
|
expect(rows.length, 2); // one per lot
|
||||||
|
expect(rows.first.commonName, 'Acelga'); // locale-picked, not 'Chard'
|
||||||
|
expect(rows.first.scientificName, 'Beta vulgaris');
|
||||||
|
expect(rows.map((r) => r.harvestYear), [2006, 2008]);
|
||||||
|
expect(rows.first.originName, 'Perales');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('yields a single name-only entry for a variety with no lots', () async {
|
||||||
|
final v = await repo.addQuickVariety(label: 'Alubia de Tolosa');
|
||||||
|
final rows = await repo.labelRows({v}, languageCode: 'en');
|
||||||
|
expect(rows.single.varietyLabel, 'Alubia de Tolosa');
|
||||||
|
expect(rows.single.harvestYear, isNull);
|
||||||
|
expect(rows.single.commonName, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('includes only varieties in the selection', () async {
|
||||||
|
final selected = await repo.addQuickVariety(label: 'Selected');
|
||||||
|
await repo.addQuickVariety(label: 'Not selected');
|
||||||
|
final rows = await repo.labelRows({selected}, languageCode: 'en');
|
||||||
|
expect(rows.map((r) => r.varietyLabel), ['Selected']);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
92
apps/app_seeds/test/services/label_sheet_service_test.dart
Normal file
92
apps/app_seeds/test/services/label_sheet_service_test.dart
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/services/file_service.dart';
|
||||||
|
import 'package:tane/services/label_sheet_service.dart';
|
||||||
|
|
||||||
|
/// Captures the bytes handed to the save dialog instead of touching disk.
|
||||||
|
class _RecordingFileService implements FileService {
|
||||||
|
Uint8List? saved;
|
||||||
|
String? savedName;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> saveFile({
|
||||||
|
required String suggestedName,
|
||||||
|
required Uint8List bytes,
|
||||||
|
}) async {
|
||||||
|
saved = bytes;
|
||||||
|
savedName = suggestedName;
|
||||||
|
return '/dev/null/$suggestedName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List?> pickFileBytes({List<String>? allowedExtensions}) async =>
|
||||||
|
null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _labels = [
|
||||||
|
LabelSheetLabel(
|
||||||
|
varietyLabel: 'Acelga de Perales',
|
||||||
|
commonName: 'Acelga',
|
||||||
|
scientificName: 'Beta vulgaris',
|
||||||
|
details: '2006 · Perales',
|
||||||
|
qrData: 'tanemaki://seed?v=Acelga+de+Perales&y=2006',
|
||||||
|
),
|
||||||
|
LabelSheetLabel(
|
||||||
|
varietyLabel: 'Alubia de Tolosa',
|
||||||
|
qrData: 'tanemaki://seed?v=Alubia+de+Tolosa',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
for (final format in LabelSheetFormat.values) {
|
||||||
|
test('buildPdf renders a well-formed PDF for $format', () async {
|
||||||
|
final service = LabelSheetService(files: _RecordingFileService());
|
||||||
|
final bytes = await service.buildPdf(labels: _labels, format: format);
|
||||||
|
|
||||||
|
// %PDF header + non-trivial body; text content is compressed, so the
|
||||||
|
// human-readable assertions live in the widget/data tests.
|
||||||
|
expect(String.fromCharCodes(bytes.take(5)), '%PDF-');
|
||||||
|
expect(bytes.length, greaterThan(1000));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('buildPdf renders a name-only label (no optional fields)', () async {
|
||||||
|
final service = LabelSheetService(files: _RecordingFileService());
|
||||||
|
final bytes = await service.buildPdf(
|
||||||
|
labels: const [
|
||||||
|
LabelSheetLabel(varietyLabel: 'Maíz', qrData: 'tanemaki://seed?v=Maíz'),
|
||||||
|
],
|
||||||
|
format: LabelSheetFormat.stickers,
|
||||||
|
);
|
||||||
|
expect(String.fromCharCodes(bytes.take(5)), '%PDF-');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildPdf honours an RTL text direction', () async {
|
||||||
|
final service = LabelSheetService(files: _RecordingFileService());
|
||||||
|
final bytes = await service.buildPdf(
|
||||||
|
labels: const [
|
||||||
|
LabelSheetLabel(varietyLabel: 'بامية', qrData: 'tanemaki://seed?v=x'),
|
||||||
|
],
|
||||||
|
format: LabelSheetFormat.cards,
|
||||||
|
rtl: true,
|
||||||
|
);
|
||||||
|
expect(String.fromCharCodes(bytes.take(5)), '%PDF-');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saveLabels hands the PDF to the save dialog', () async {
|
||||||
|
final files = _RecordingFileService();
|
||||||
|
final service = LabelSheetService(files: files);
|
||||||
|
final saved = await service.saveLabels(
|
||||||
|
labels: _labels,
|
||||||
|
format: LabelSheetFormat.cards,
|
||||||
|
suggestedName: 'tanemaki-labels-2026-07-10.pdf',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(saved, isTrue);
|
||||||
|
expect(files.savedName, 'tanemaki-labels-2026-07-10.pdf');
|
||||||
|
expect(String.fromCharCodes(files.saved!.take(5)), '%PDF-');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -147,4 +147,50 @@ void main() {
|
||||||
expect(state.categories, hasLength(2));
|
expect(state.categories, hasLength(2));
|
||||||
expect(state.categories.toSet(), {'Poaceae', 'Fabaceae'});
|
expect(state.categories.toSet(), {'Poaceae', 'Fabaceae'});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('selection mode', () {
|
||||||
|
test('enterSelection starts the mode with one id', () async {
|
||||||
|
await repo.addQuickVariety(label: 'Bean');
|
||||||
|
final state = await waitFor(cubit, (s) => s.items.isNotEmpty);
|
||||||
|
final id = state.items.single.id;
|
||||||
|
|
||||||
|
cubit.enterSelection(id);
|
||||||
|
expect(cubit.state.selectionMode, isTrue);
|
||||||
|
expect(cubit.state.selectedIds, {id});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggleSelection adds then removes, staying in mode', () async {
|
||||||
|
await repo.addQuickVariety(label: 'Bean');
|
||||||
|
final state = await waitFor(cubit, (s) => s.items.isNotEmpty);
|
||||||
|
final id = state.items.single.id;
|
||||||
|
|
||||||
|
cubit.toggleSelection(id);
|
||||||
|
expect(cubit.state.selectedIds, {id});
|
||||||
|
|
||||||
|
cubit.toggleSelection(id);
|
||||||
|
expect(cubit.state.selectedIds, isEmpty);
|
||||||
|
expect(cubit.state.selectionMode, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectAllVisible selects only the filtered subset', () async {
|
||||||
|
await repo.addQuickVariety(label: 'Bean', category: 'Fabaceae');
|
||||||
|
await repo.addQuickVariety(label: 'Tomato', category: 'Solanaceae');
|
||||||
|
final state = await waitFor(cubit, (s) => s.items.length == 2);
|
||||||
|
final beanId = state.items.firstWhere((i) => i.label == 'Bean').id;
|
||||||
|
|
||||||
|
cubit.toggleCategory('Fabaceae'); // hide the tomato
|
||||||
|
cubit.selectAllVisible();
|
||||||
|
expect(cubit.state.selectedIds, {beanId});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exitSelection leaves the mode and clears the selection', () async {
|
||||||
|
await repo.addQuickVariety(label: 'Bean');
|
||||||
|
final state = await waitFor(cubit, (s) => s.items.isNotEmpty);
|
||||||
|
cubit.enterSelection(state.items.single.id);
|
||||||
|
|
||||||
|
cubit.exitSelection();
|
||||||
|
expect(cubit.state.selectionMode, isFalse);
|
||||||
|
expect(cubit.state.selectedIds, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -219,4 +219,66 @@ void main() {
|
||||||
await disposeTree(tester);
|
await disposeTree(tester);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets('the label toolbar enters selection and Select all enables print', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final repo = newTestRepository(db);
|
||||||
|
await repo.addQuickVariety(label: 'Tomato', category: 'Solanaceae');
|
||||||
|
await repo.addQuickVariety(label: 'Bean', category: 'Fabaceae');
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrapScreen(repository: repo, child: const InventoryListScreen()),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('inventory.selectLabels')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Nothing picked yet: the count reads zero and print is disabled.
|
||||||
|
expect(find.text('0 selected'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<IconButton>(find.byKey(const Key('inventory.selection.print')))
|
||||||
|
.onPressed,
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('inventory.selection.selectAll')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('2 selected'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<IconButton>(find.byKey(const Key('inventory.selection.print')))
|
||||||
|
.onPressed,
|
||||||
|
isNotNull,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Closing the contextual bar leaves selection mode.
|
||||||
|
await tester.tap(find.byKey(const Key('inventory.selection.close')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('inventory.selection.print')), findsNothing);
|
||||||
|
await disposeTree(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('long-pressing a tile enters selection with it picked', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final repo = newTestRepository(db);
|
||||||
|
await repo.addQuickVariety(label: 'Tomato', category: 'Solanaceae');
|
||||||
|
await repo.addQuickVariety(label: 'Bean', category: 'Fabaceae');
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrapScreen(repository: repo, child: const InventoryListScreen()),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.longPress(find.text('Tomato'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('1 selected'), findsOneWidget);
|
||||||
|
expect(find.byType(Checkbox), findsNWidgets(2)); // one per tile
|
||||||
|
await disposeTree(tester);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue