diff --git a/apps/app_seeds/assets/ocr_fixtures/1783672939357.jpg b/apps/app_seeds/assets/ocr_fixtures/1783672939357.jpg new file mode 100644 index 0000000..630ae63 Binary files /dev/null and b/apps/app_seeds/assets/ocr_fixtures/1783672939357.jpg differ diff --git a/apps/app_seeds/assets/ocr_fixtures/1783672939375.jpg b/apps/app_seeds/assets/ocr_fixtures/1783672939375.jpg new file mode 100644 index 0000000..e62e138 Binary files /dev/null and b/apps/app_seeds/assets/ocr_fixtures/1783672939375.jpg differ diff --git a/apps/app_seeds/assets/ocr_fixtures/1783672939389.jpg b/apps/app_seeds/assets/ocr_fixtures/1783672939389.jpg new file mode 100644 index 0000000..a243756 Binary files /dev/null and b/apps/app_seeds/assets/ocr_fixtures/1783672939389.jpg differ diff --git a/apps/app_seeds/assets/ocr_fixtures/1783672939410.jpg b/apps/app_seeds/assets/ocr_fixtures/1783672939410.jpg new file mode 100644 index 0000000..09d16c2 Binary files /dev/null and b/apps/app_seeds/assets/ocr_fixtures/1783672939410.jpg differ diff --git a/apps/app_seeds/lib/data/export_import/seed_label_codec.dart b/apps/app_seeds/lib/data/export_import/seed_label_codec.dart new file mode 100644 index 0000000..6811667 --- /dev/null +++ b/apps/app_seeds/lib/data/export_import/seed_label_codec.dart @@ -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 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=&s=&c=&y=&o=` +/// 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 = { + '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; + } +} diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index f533ce8..4b21f0b 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -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 get props => [ + varietyLabel, + commonName, + scientificName, + category, + harvestYear, + quantity, + originName, + originPlace, + ]; +} + /// 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 /// needs. Distinct from [SharedCatalogEntry], which is for the printable local @@ -1243,6 +1286,123 @@ class VarietyRepository { 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> labelRows( + Set 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() + .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 = >{}; + for (final l in lots) { + (lotsByVariety[l.varietyId] ??= []).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 = []; + 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> _commonNamesFor( + Set 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 = >{}; + for (final n in rows) { + (bySpecies[n.speciesId] ??= []).add(( + name: n.name, + language: n.language, + )); + } + final result = {}; + 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 /// variety label — the input for publishing offers to the network (Block 2). Future> shareableLots() async { diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index c5edfa5..92e6dd6 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -26,6 +26,7 @@ import '../services/locale_store.dart'; import '../services/ocr/label_text_extractor.dart'; import '../services/ocr/ocr_language.dart'; import '../services/ocr/tesseract_label_extractor.dart'; +import '../services/label_sheet_service.dart'; import '../services/onboarding_store.dart'; import '../services/recovery_sheet_service.dart'; import '../services/share_catalog_service.dart'; @@ -151,6 +152,9 @@ Future configureDependencies() async { ) ..registerSingleton( RecoverySheetService(files: fileService), + ) + ..registerSingleton( + LabelSheetService(files: fileService), ); // Automatic silent backups need real file storage; the web build has none, so diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index b0c8fa5..07cff98 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -267,6 +267,23 @@ "catalogSaved": "Catálogu guardáu", "cancelled": "Encaboxáu" }, + "printLabels": { + "action": "Imprentar etiquetes", + "title": "Imprentar etiquetes", + "selectHint": "Escueyi les semientes pa imprentar les sos etiquetes", + "selectAll": "Seleicionar too", + "selected": "{n} seleicionaes", + "none": "Seleiciona semientes primero", + "format": "Tamañu d'etiqueta", + "formatStickers": "Pegatines pequeñes", + "formatStickersHint": "Munches etiquetes pequeñes per fueya — pa pegar en sobres", + "formatCards": "Tarxetes grandes", + "formatCardsHint": "Menos etiquetes, más grandes — pa botes y caxes", + "count": "{n} etiquetes", + "save": "Guardar etiquetes", + "saved": "Etiquetes guardaes", + "cancelled": "Encaboxáu" + }, "cropCalendar": { "add": "Calendariu de cultivu", "title": "Calendariu de cultivu", diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 966327b..ee69b30 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -268,6 +268,23 @@ "catalogSaved": "Catalog saved", "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": { "add": "Crop calendar", "title": "Crop calendar", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index fca1f2e..7c0e44a 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -267,6 +267,23 @@ "catalogSaved": "Catálogo guardado", "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": { "add": "Calendario de cultivo", "title": "Calendario de cultivo", diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 4d0c632..8df35d2 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -264,6 +264,23 @@ "catalogSaved": "Catálogo guardado", "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": { "add": "Calendário de cultivo", "title": "Calendário de cultivo", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index f21bdd8..fb59798 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 4 -/// Strings: 1488 (372 per locale) +/// Strings: 1548 (387 per locale) /// -/// Built on 2026-07-10 at 17:11 UTC +/// Built on 2026-07-10 at 17:18 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index da961c0..2f83eb6 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -63,6 +63,7 @@ class TranslationsAst extends Translations with BaseTranslations 'Encaboxáu'; } +// Path: printLabels +class _Translations$printLabels$ast extends Translations$printLabels$en { + _Translations$printLabels$ast._(TranslationsAst root) : this._root = root, super.internal(root); + + final TranslationsAst _root; // ignore: unused_field + + // Translations + @override String get action => 'Imprentar etiquetes'; + @override String get title => 'Imprentar etiquetes'; + @override String get selectHint => 'Escueyi les semientes pa imprentar les sos etiquetes'; + @override String get selectAll => 'Seleicionar too'; + @override String selected({required Object n}) => '${n} seleicionaes'; + @override String get none => 'Seleiciona semientes primero'; + @override String get format => 'Tamañu d\'etiqueta'; + @override String get formatStickers => 'Pegatines pequeñes'; + @override String get formatStickersHint => 'Munches etiquetes pequeñes per fueya — pa pegar en sobres'; + @override String get formatCards => 'Tarxetes grandes'; + @override String get formatCardsHint => 'Menos etiquetes, más grandes — pa botes y caxes'; + @override String count({required Object n}) => '${n} etiquetes'; + @override String get save => 'Guardar etiquetes'; + @override String get saved => 'Etiquetes guardaes'; + @override String get cancelled => 'Encaboxáu'; +} + // Path: cropCalendar class _Translations$cropCalendar$ast extends Translations$cropCalendar$en { _Translations$cropCalendar$ast._(TranslationsAst root) : this._root = root, super.internal(root); @@ -1288,6 +1313,21 @@ extension on TranslationsAst { 'share.catalogTitle' => 'Lo que comparto', 'share.catalogSaved' => 'Catálogu guardáu', 'share.cancelled' => 'Encaboxáu', + 'printLabels.action' => 'Imprentar etiquetes', + 'printLabels.title' => 'Imprentar etiquetes', + 'printLabels.selectHint' => 'Escueyi les semientes pa imprentar les sos etiquetes', + 'printLabels.selectAll' => 'Seleicionar too', + 'printLabels.selected' => ({required Object n}) => '${n} seleicionaes', + 'printLabels.none' => 'Seleiciona semientes primero', + 'printLabels.format' => 'Tamañu d\'etiqueta', + 'printLabels.formatStickers' => 'Pegatines pequeñes', + 'printLabels.formatStickersHint' => 'Munches etiquetes pequeñes per fueya — pa pegar en sobres', + 'printLabels.formatCards' => 'Tarxetes grandes', + 'printLabels.formatCardsHint' => 'Menos etiquetes, más grandes — pa botes y caxes', + 'printLabels.count' => ({required Object n}) => '${n} etiquetes', + 'printLabels.save' => 'Guardar etiquetes', + 'printLabels.saved' => 'Etiquetes guardaes', + 'printLabels.cancelled' => 'Encaboxáu', 'cropCalendar.add' => 'Calendariu de cultivu', 'cropCalendar.title' => 'Calendariu de cultivu', 'cropCalendar.sow' => 'Sema', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index b8e3b23..7785576 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -64,6 +64,7 @@ class Translations with BaseTranslations { 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$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$needsReproduction$en needsReproduction = Translations$needsReproduction$en.internal(_root); late final Translations$preservation$en preservation = Translations$preservation$en.internal(_root); @@ -895,6 +896,60 @@ class Translations$share$en { 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 class Translations$cropCalendar$en { Translations$cropCalendar$en.internal(this._root); @@ -2020,6 +2075,21 @@ extension on Translations { 'share.catalogTitle' => 'What I share', 'share.catalogSaved' => 'Catalog saved', '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.title' => 'Crop calendar', 'cropCalendar.sow' => 'Sow', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 8bd010d..98d6f12 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -63,6 +63,7 @@ class TranslationsEs extends Translations with BaseTranslations '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 class _Translations$cropCalendar$es extends Translations$cropCalendar$en { _Translations$cropCalendar$es._(TranslationsEs root) : this._root = root, super.internal(root); @@ -1290,6 +1315,21 @@ extension on TranslationsEs { 'share.catalogTitle' => 'Lo que comparto', 'share.catalogSaved' => 'Catálogo guardado', '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.title' => 'Calendario de cultivo', 'cropCalendar.sow' => 'Siembra', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 94b9f92..22249a5 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -63,6 +63,7 @@ class TranslationsPt extends Translations with BaseTranslations '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 class _Translations$cropCalendar$pt extends Translations$cropCalendar$en { _Translations$cropCalendar$pt._(TranslationsPt root) : this._root = root, super.internal(root); @@ -1284,6 +1309,21 @@ extension on TranslationsPt { 'share.catalogTitle' => 'O que partilho', 'share.catalogSaved' => 'Catálogo guardado', '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.title' => 'Calendário de cultivo', 'cropCalendar.sow' => 'Sementeira', diff --git a/apps/app_seeds/lib/services/label_sheet_service.dart b/apps/app_seeds/lib/services/label_sheet_service.dart new file mode 100644 index 0000000..8c55444 --- /dev/null +++ b/apps/app_seeds/lib/services/label_sheet_service.dart @@ -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 buildPdf({ + required List 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 = []; + 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 saveLabels({ + required List 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; + } +} diff --git a/apps/app_seeds/lib/state/inventory_cubit.dart b/apps/app_seeds/lib/state/inventory_cubit.dart index f3dc365..8d55ba5 100644 --- a/apps/app_seeds/lib/state/inventory_cubit.dart +++ b/apps/app_seeds/lib/state/inventory_cubit.dart @@ -20,6 +20,8 @@ class InventoryState extends Equatable { this.needsReproductionOnly = false, this.sharingOnly = false, this.loading = true, + this.selectionMode = false, + this.selectedIds = const {}, }); final List items; @@ -49,6 +51,13 @@ class InventoryState extends Equatable { 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 selectedIds; + /// Categories present across all items, in display order (deduped), so the /// UI can offer one chip per category actually in use. List get categories { @@ -101,6 +110,8 @@ class InventoryState extends Equatable { bool? needsReproductionOnly, bool? sharingOnly, bool? loading, + bool? selectionMode, + Set? selectedIds, }) { return InventoryState( items: items ?? this.items, @@ -113,6 +124,8 @@ class InventoryState extends Equatable { needsReproductionOnly ?? this.needsReproductionOnly, sharingOnly: sharingOnly ?? this.sharingOnly, loading: loading ?? this.loading, + selectionMode: selectionMode ?? this.selectionMode, + selectedIds: selectedIds ?? this.selectedIds, ); } @@ -127,6 +140,8 @@ class InventoryState extends Equatable { needsReproductionOnly, sharingOnly, loading, + selectionMode, + selectedIds, ]; } @@ -177,6 +192,36 @@ class InventoryCubit extends Cubit { void toggleSharingOnly() => 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.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). void clearFilters() => emit( state.copyWith( diff --git a/apps/app_seeds/lib/ui/inventory_list_screen.dart b/apps/app_seeds/lib/ui/inventory_list_screen.dart index 7733dfd..aaddcb4 100644 --- a/apps/app_seeds/lib/ui/inventory_list_screen.dart +++ b/apps/app_seeds/lib/ui/inventory_list_screen.dart @@ -10,6 +10,7 @@ import '../i18n/strings.g.dart'; import '../services/share_catalog_service.dart'; import '../state/inventory_cubit.dart'; import 'draft_triage.dart'; +import 'label_print_sheet.dart'; import 'quantity_kind_l10n.dart'; import 'quantity_picker.dart'; import 'quick_add_sheet.dart'; @@ -25,88 +26,162 @@ class InventoryListScreen extends StatelessWidget { @override Widget build(BuildContext context) { final t = context.t; - return Scaffold( - appBar: AppBar( - title: Text(t.inventory.title), - actions: [ - // The paper bridge: only offered once something is marked to share. - BlocSelector( - selector: (state) => state.hasShared, - builder: (context, hasShared) => hasShared - ? IconButton( - key: const Key('inventory.printCatalog'), - 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), - ), - ], - ), - floatingActionButton: FloatingActionButton( - key: const Key('inventory.addFab'), - tooltip: t.quickAdd.title, - onPressed: () => showQuickAddSheet( - context, - repository: context.read(), - ), - child: const Icon(Icons.add), - ), - body: BlocBuilder( - builder: (context, state) { - if (state.loading) { - return const Center(child: CircularProgressIndicator()); - } - return Column( - children: [ - if (state.drafts.isNotEmpty) - _TriageBanner(count: state.drafts.length), - Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), - child: TextField( - key: const Key('inventory.search'), - decoration: InputDecoration( - hintText: t.inventory.searchHint, - prefixIcon: const Icon(Icons.search, color: seedMuted), - hintStyle: const TextStyle(color: seedMuted), - filled: true, - fillColor: Colors.white, - isDense: true, - contentPadding: const EdgeInsets.symmetric(vertical: 14), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(28), - borderSide: BorderSide.none, - ), + return BlocBuilder( + builder: (context, state) { + return Scaffold( + appBar: state.selectionMode + ? _selectionAppBar(context, state) + : _defaultAppBar(context, state), + // Inventory is a spoke off the home hub (no drawer — the app bar's + // back arrow returns there). In selection mode the bars are + // contextual and the add FAB steps aside. + floatingActionButton: state.selectionMode + ? null + : FloatingActionButton( + key: const Key('inventory.addFab'), + tooltip: t.quickAdd.title, + onPressed: () => showQuickAddSheet( + context, + repository: context.read(), ), - onChanged: context.read().search, + child: const Icon(Icons.add), ), - ), - _FilterBar(state: state), - Expanded( - child: _InventoryBody( - items: state.visibleItems, - // Distinguish "no seeds at all" from "filters hid them all". - filtered: - state.categoryFilter.isNotEmpty || - state.typeFilter.isNotEmpty || - state.organicOnly || - state.needsReproductionOnly || - state.sharingOnly, + body: state.loading + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + if (state.drafts.isNotEmpty && !state.selectionMode) + _TriageBanner(count: state.drafts.length), + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), + child: TextField( + key: const Key('inventory.search'), + decoration: InputDecoration( + hintText: t.inventory.searchHint, + prefixIcon: const Icon(Icons.search, color: seedMuted), + hintStyle: const TextStyle(color: seedMuted), + filled: true, + fillColor: Colors.white, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + vertical: 14, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide.none, + ), + ), + onChanged: context.read().search, + ), + ), + _FilterBar(state: state), + Expanded( + child: _InventoryBody( + items: state.visibleItems, + selectionMode: state.selectionMode, + selectedIds: state.selectedIds, + // Distinguish "no seeds at all" from "filters hid them". + filtered: + state.categoryFilter.isNotEmpty || + state.typeFilter.isNotEmpty || + state.organicOnly || + state.needsReproductionOnly || + state.sharingOnly, + ), + ), + ], ), - ), - ], - ); - }, - ), + ); + }, ); } + /// 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().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(); + 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 _printLabels(BuildContext context, Set ids) async { + final t = context.t; + final repository = context.read(); + 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 /// as a PDF wherever the user chooses. Future _printCatalog(BuildContext context) async { @@ -301,7 +376,12 @@ class _FilterBar 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 items; @@ -309,6 +389,12 @@ class _InventoryBody extends StatelessWidget { /// than "no seeds yet". 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 selectedIds; + @override Widget build(BuildContext context) { final t = context.t; @@ -346,7 +432,13 @@ class _InventoryBody extends StatelessWidget { currentCategory = 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); } @@ -374,15 +466,35 @@ class _CategoryHeader 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; + /// 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 Widget build(BuildContext context) { + final cubit = context.read(); void open() => context.push('/variety/${item.id}'); + void toggle() => cubit.toggleSelection(item.id); + 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( item.label, style: const TextStyle(fontWeight: FontWeight.bold), @@ -393,42 +505,47 @@ class _VarietyTile extends StatelessWidget { item.scientificName!, style: const TextStyle(fontStyle: FontStyle.italic), ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (item.isShared) - Padding( - padding: const EdgeInsetsDirectional.only(end: 4), - child: Tooltip( - key: Key('inventory.shared.${item.id}'), - message: context.t.share.filterChip, - child: const Icon( - Icons.volunteer_activism_outlined, - size: 20, + // In selection mode the trailing actions (which navigate away) give way to + // the checkbox flow. + trailing: selectionMode + ? null + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (item.isShared) + Padding( + padding: const EdgeInsetsDirectional.only(end: 4), + child: Tooltip( + key: Key('inventory.shared.${item.id}'), + message: context.t.share.filterChip, + child: const Icon( + Icons.volunteer_activism_outlined, + size: 20, + color: seedGreen, + ), + ), + ), + if (item.isOrganic) + Padding( + padding: const EdgeInsetsDirectional.only(end: 4), + child: Tooltip( + key: Key('inventory.organic.${item.id}'), + message: context.t.editVariety.organic, + child: const Icon(Icons.eco, size: 20, color: seedGreen), + ), + ), + _ViabilityDot(item.viability), + IconButton( + icon: const Icon(Icons.edit_outlined), + // Action colour: this is a tap target, not secondary text. color: seedGreen, + tooltip: context.t.common.edit, + onPressed: open, ), - ), + ], ), - if (item.isOrganic) - Padding( - padding: const EdgeInsetsDirectional.only(end: 4), - child: Tooltip( - key: Key('inventory.organic.${item.id}'), - message: context.t.editVariety.organic, - child: const Icon(Icons.eco, size: 20, color: seedGreen), - ), - ), - _ViabilityDot(item.viability), - IconButton( - icon: const Icon(Icons.edit_outlined), - // Action colour: this is a tap target, not secondary text. - color: seedGreen, - tooltip: context.t.common.edit, - onPressed: open, - ), - ], - ), - onTap: open, + onTap: selectionMode ? toggle : open, + onLongPress: selectionMode ? null : () => cubit.enterSelection(item.id), ); } } diff --git a/apps/app_seeds/lib/ui/label_print_sheet.dart b/apps/app_seeds/lib/ui/label_print_sheet.dart new file mode 100644 index 0000000..d1f1f0b --- /dev/null +++ b/apps/app_seeds/lib/ui/label_print_sheet.dart @@ -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 showLabelPrintSheet( + BuildContext context, + List entries, +) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _LabelPrintSheet(entries: entries), + ); +} + +class _LabelPrintSheet extends StatefulWidget { + const _LabelPrintSheet({required this.entries}); + + final List 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( + groupValue: _format, + onChanged: _pick, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RadioListTile( + key: const Key('printLabels.format.stickers'), + value: LabelSheetFormat.stickers, + contentPadding: EdgeInsets.zero, + title: Text(t.printLabels.formatStickers), + subtitle: Text(t.printLabels.formatStickersHint), + ), + RadioListTile( + 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 _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().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 = [ + 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 = [ + 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); +} diff --git a/apps/app_seeds/test/data/export_import/seed_label_codec_test.dart b/apps/app_seeds/test/data/export_import/seed_label_codec_test.dart new file mode 100644 index 0000000..07872c6 --- /dev/null +++ b/apps/app_seeds/test/data/export_import/seed_label_codec_test.dart @@ -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); + }); + }); +} diff --git a/apps/app_seeds/test/data/variety_repository_test.dart b/apps/app_seeds/test/data/variety_repository_test.dart index d9903ce..a852fe8 100644 --- a/apps/app_seeds/test/data/variety_repository_test.dart +++ b/apps/app_seeds/test/data/variety_repository_test.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'package:commons_core/commons_core.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/db/database.dart'; import 'package:tane/db/enums.dart'; @@ -85,4 +86,51 @@ void main() { expect(stamps.toSet().length, 2); 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']); + }); + }); } diff --git a/apps/app_seeds/test/services/label_sheet_service_test.dart b/apps/app_seeds/test/services/label_sheet_service_test.dart new file mode 100644 index 0000000..e2b4dcd --- /dev/null +++ b/apps/app_seeds/test/services/label_sheet_service_test.dart @@ -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 saveFile({ + required String suggestedName, + required Uint8List bytes, + }) async { + saved = bytes; + savedName = suggestedName; + return '/dev/null/$suggestedName'; + } + + @override + Future pickFileBytes({List? 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-'); + }); +} diff --git a/apps/app_seeds/test/state/inventory_cubit_test.dart b/apps/app_seeds/test/state/inventory_cubit_test.dart index 3ce4ad9..da74a88 100644 --- a/apps/app_seeds/test/state/inventory_cubit_test.dart +++ b/apps/app_seeds/test/state/inventory_cubit_test.dart @@ -147,4 +147,50 @@ void main() { expect(state.categories, hasLength(2)); 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); + }); + }); } diff --git a/apps/app_seeds/test/ui/inventory_list_screen_test.dart b/apps/app_seeds/test/ui/inventory_list_screen_test.dart index 4473f13..236c2d7 100644 --- a/apps/app_seeds/test/ui/inventory_list_screen_test.dart +++ b/apps/app_seeds/test/ui/inventory_list_screen_test.dart @@ -219,4 +219,66 @@ void main() { 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(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(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); + }); }