Merge branch 'worktree-print-labels': print seed labels with QR
# Conflicts: # apps/app_seeds/lib/i18n/strings.g.dart # apps/app_seeds/lib/ui/inventory_list_screen.dart
This commit is contained in:
commit
63f48db5c2
21 changed files with 1522 additions and 115 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
|
||||
/// 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<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
|
||||
/// variety label — the input for publishing offers to the network (Block 2).
|
||||
Future<List<ShareableLot>> shareableLots() async {
|
||||
|
|
|
|||
|
|
@ -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<void> configureDependencies() async {
|
|||
)
|
||||
..registerSingleton<RecoverySheetService>(
|
||||
RecoverySheetService(files: fileService),
|
||||
)
|
||||
..registerSingleton<LabelSheetService>(
|
||||
LabelSheetService(files: fileService),
|
||||
);
|
||||
|
||||
// Automatic silent backups need real file storage; the web build has none, so
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ class TranslationsAst extends Translations with BaseTranslations<AppLocale, Tran
|
|||
@override late final _Translations$provenance$ast provenance = _Translations$provenance$ast._(_root);
|
||||
@override late final _Translations$abundance$ast abundance = _Translations$abundance$ast._(_root);
|
||||
@override late final _Translations$share$ast share = _Translations$share$ast._(_root);
|
||||
@override late final _Translations$printLabels$ast printLabels = _Translations$printLabels$ast._(_root);
|
||||
@override late final _Translations$cropCalendar$ast cropCalendar = _Translations$cropCalendar$ast._(_root);
|
||||
@override late final _Translations$needsReproduction$ast needsReproduction = _Translations$needsReproduction$ast._(_root);
|
||||
@override late final _Translations$preservation$ast preservation = _Translations$preservation$ast._(_root);
|
||||
|
|
@ -501,6 +502,30 @@ class _Translations$share$ast extends Translations$share$en {
|
|||
@override String get cancelled => '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',
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
|||
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',
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
|||
@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$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$needsReproduction$es needsReproduction = _Translations$needsReproduction$es._(_root);
|
||||
@override late final _Translations$preservation$es preservation = _Translations$preservation$es._(_root);
|
||||
|
|
@ -501,6 +502,30 @@ class _Translations$share$es extends Translations$share$en {
|
|||
@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
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ class TranslationsPt extends Translations with BaseTranslations<AppLocale, Trans
|
|||
@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$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$needsReproduction$pt needsReproduction = _Translations$needsReproduction$pt._(_root);
|
||||
@override late final _Translations$preservation$pt preservation = _Translations$preservation$pt._(_root);
|
||||
|
|
@ -498,6 +499,30 @@ class _Translations$share$pt extends Translations$share$en {
|
|||
@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
|
||||
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',
|
||||
|
|
|
|||
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.sharingOnly = false,
|
||||
this.loading = true,
|
||||
this.selectionMode = false,
|
||||
this.selectedIds = const {},
|
||||
});
|
||||
|
||||
final List<VarietyListItem> 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<String> selectedIds;
|
||||
|
||||
/// Categories present across all items, in display order (deduped), so the
|
||||
/// UI can offer one chip per category actually in use.
|
||||
List<String> get categories {
|
||||
|
|
@ -101,6 +110,8 @@ class InventoryState extends Equatable {
|
|||
bool? needsReproductionOnly,
|
||||
bool? sharingOnly,
|
||||
bool? loading,
|
||||
bool? selectionMode,
|
||||
Set<String>? 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<InventoryState> {
|
|||
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<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).
|
||||
void clearFilters() => emit(
|
||||
state.copyWith(
|
||||
|
|
|
|||
|
|
@ -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<InventoryCubit, InventoryState, bool>(
|
||||
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<VarietyRepository>(),
|
||||
),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: BlocBuilder<InventoryCubit, InventoryState>(
|
||||
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<InventoryCubit, InventoryState>(
|
||||
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<VarietyRepository>(),
|
||||
),
|
||||
onChanged: context.read<InventoryCubit>().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<InventoryCubit>().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<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
|
||||
/// as a PDF wherever the user chooses.
|
||||
Future<void> _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<VarietyListItem> 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<String> 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<InventoryCubit>();
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue