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
4d110e8082
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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue