diff --git a/apps/app_seeds/assets/fonts/DejaVuSans-Bold.ttf b/apps/app_seeds/assets/fonts/DejaVuSans-Bold.ttf new file mode 100644 index 0000000..0a7fb3f Binary files /dev/null and b/apps/app_seeds/assets/fonts/DejaVuSans-Bold.ttf differ diff --git a/apps/app_seeds/assets/fonts/DejaVuSans.ttf b/apps/app_seeds/assets/fonts/DejaVuSans.ttf new file mode 100644 index 0000000..1728e9e Binary files /dev/null and b/apps/app_seeds/assets/fonts/DejaVuSans.ttf differ diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index b043057..496983b 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -22,6 +22,7 @@ class VarietyListItem extends Equatable { this.isDraft = false, this.isOrganic = false, this.needsReproduction = false, + this.isShared = false, this.viability = SeedViability.unknown, }); @@ -50,6 +51,10 @@ class VarietyListItem extends Equatable { /// filterable, alongside the automatic viability warning. final bool needsReproduction; + /// True when at least one lot is marked to give away, swap or sell — the + /// "what I share" view filters on this. + final bool isShared; + /// The most-urgent viability status across this variety's seed lots /// (expired ranks above expiringSoon), so the list can flag what to sow or /// reproduce before it lapses. [SeedViability.unknown] when nothing is aging @@ -67,10 +72,50 @@ class VarietyListItem extends Equatable { isDraft, isOrganic, needsReproduction, + isShared, viability, ]; } +/// One shared lot for the printable catalog: the variety it belongs to plus +/// the batch facts a fair-goer needs (what, from when, how much, on what +/// terms). Built by [VarietyRepository.sharedCatalog]. +class SharedCatalogEntry extends Equatable { + const SharedCatalogEntry({ + required this.varietyLabel, + required this.offerStatus, + this.scientificName, + this.category, + this.type = LotType.seed, + this.harvestYear, + this.quantity, + this.abundance, + }); + + final String varietyLabel; + final String? scientificName; + final String? category; + final LotType type; + final int? harvestYear; + final Quantity? quantity; + final Abundance? abundance; + + /// Never [OfferStatus.private] — private lots don't enter the catalog. + final OfferStatus offerStatus; + + @override + List get props => [ + varietyLabel, + scientificName, + category, + type, + harvestYear, + quantity, + abundance, + offerStatus, + ]; +} + /// One germination test on a lot; [rate] is derived (0..1), null when it can't /// be computed. class GerminationEntry extends Equatable { @@ -141,6 +186,7 @@ class VarietyLot extends Equatable { this.originPlace, this.abundance, this.preservationFormat, + this.offerStatus = OfferStatus.private, this.germinationTests = const [], this.conditionChecks = const [], }); @@ -168,6 +214,11 @@ class VarietyLot extends Equatable { /// How the (seed) lot is physically conserved; distinct from storage location. final PreservationFormat? preservationFormat; + /// Whether (and how) this batch is offered to others: kept private, to give + /// away, to swap, or for sale. Local-only for now — publishing an offer to + /// the network is Block 2. + final OfferStatus offerStatus; + final List germinationTests; /// Storage-condition checks, most-recent first (`conditionChecks.first` = last). @@ -190,6 +241,7 @@ class VarietyLot extends Equatable { originPlace, abundance, preservationFormat, + offerStatus, germinationTests, conditionChecks, ]; @@ -390,7 +442,9 @@ class VarietyRepository { final rows = await (_db.select(_db.varieties) // Drafts live in the "to catalogue" tray, not the main list. - ..where((v) => v.isDeleted.equals(false) & v.isDraft.equals(false)) + ..where( + (v) => v.isDeleted.equals(false) & v.isDraft.equals(false), + ) ..orderBy([ (v) => OrderingTerm(expression: v.category), (v) => OrderingTerm(expression: v.label), @@ -404,9 +458,9 @@ class VarietyRepository { /// photo changes. Stream> watchDrafts() { final triggers = StreamGroup.merge([ - (_db.select(_db.varieties)..where((v) => v.isDraft.equals(true))) - .watch() - .map((_) {}), + (_db.select( + _db.varieties, + )..where((v) => v.isDraft.equals(true))).watch().map((_) {}), _db.select(_db.attachments).watch().map((_) {}), ]); return triggers.asyncMap((_) => _loadDrafts()); @@ -415,9 +469,7 @@ class VarietyRepository { Future> _loadDrafts() async { final rows = await (_db.select(_db.varieties) - ..where( - (v) => v.isDeleted.equals(false) & v.isDraft.equals(true), - ) + ..where((v) => v.isDeleted.equals(false) & v.isDraft.equals(true)) // Packed HLC sorts monotonically, so newest capture is first even // when two are stamped within the same wall-clock millisecond. ..orderBy([(v) => OrderingTerm.desc(v.updatedAt)])) @@ -432,6 +484,7 @@ class VarietyRepository { final sciNames = await _scientificNamesFor(speciesIds); final speciesViability = await _speciesViabilityFor(speciesIds); final lotTypes = await _lotTypesFor(varietyIds); + final shared = await _sharedVarietyIdsFor(varietyIds); final seedYears = await _seedHarvestYearsFor(varietyIds); final currentYear = DateTime.fromMillisecondsSinceEpoch(_now()).year; return rows @@ -446,6 +499,7 @@ class VarietyRepository { isDraft: v.isDraft, isOrganic: v.isOrganic, needsReproduction: v.needsReproduction, + isShared: shared.contains(v.id), viability: _worstViability( harvestYears: seedYears[v.id] ?? const [], viabilityYears: v.speciesId == null @@ -537,6 +591,21 @@ class VarietyRepository { return byVariety; } + /// The subset of [varietyIds] holding at least one lot offered to others + /// (to give away, swap or sell) — one query. + Future> _sharedVarietyIdsFor(List varietyIds) async { + if (varietyIds.isEmpty) return const {}; + final rows = + await (_db.select(_db.lots)..where( + (l) => + l.varietyId.isIn(varietyIds) & + l.isDeleted.equals(false) & + l.offerStatus.equalsValue(OfferStatus.private).not(), + )) + .get(); + return rows.map((l) => l.varietyId).toSet(); + } + /// Loads the first photo BLOB for each of [varietyIds] (one query). Future> _firstPhotosFor( List varietyIds, @@ -942,6 +1011,7 @@ class VarietyRepository { String? originPlace, Abundance? abundance, PreservationFormat? preservationFormat, + OfferStatus offerStatus = OfferStatus.private, }) async { final (created, updated) = _stamp(); final id = idGen.newId(); @@ -966,6 +1036,7 @@ class VarietyRepository { originPlace: Value(originPlace), abundance: Value(abundance), preservationFormat: Value(preservationFormat), + offerStatus: Value(offerStatus), ), ); return id; @@ -985,6 +1056,7 @@ class VarietyRepository { String? originPlace, Abundance? abundance, PreservationFormat? preservationFormat, + OfferStatus offerStatus = OfferStatus.private, }) async { final (_, updated) = _stamp(); await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write( @@ -1001,12 +1073,70 @@ class VarietyRepository { originPlace: Value(originPlace), abundance: Value(abundance), preservationFormat: Value(preservationFormat), + offerStatus: Value(offerStatus), updatedAt: Value(updated), lastAuthor: Value(nodeId), ), ); } + /// Every lot offered to others (to give away, swap or sell), joined with its + /// variety and ordered by label then harvest year — the content of the + /// printable "what I share" catalog. + Future> sharedCatalog() async { + final lots = + await (_db.select(_db.lots)..where( + (l) => + l.isDeleted.equals(false) & + l.offerStatus.equalsValue(OfferStatus.private).not(), + )) + .get(); + if (lots.isEmpty) return const []; + + final varietyIds = lots.map((l) => l.varietyId).toSet(); + final varieties = + await (_db.select(_db.varieties)..where( + (v) => + v.id.isIn(varietyIds) & + v.isDeleted.equals(false) & + v.isDraft.equals(false), + )) + .get(); + final byId = {for (final v in varieties) v.id: v}; + final sciNames = await _scientificNamesFor( + varieties.map((v) => v.speciesId).whereType().toSet(), + ); + + final entries = [ + for (final l in lots) + if (byId[l.varietyId] case final v?) + SharedCatalogEntry( + varietyLabel: v.label, + scientificName: v.speciesId == null ? null : sciNames[v.speciesId], + category: v.category, + type: l.type, + harvestYear: l.harvestYear, + quantity: + l.quantityKind != null || + l.quantityPrecise != null || + l.quantityLabel != null + ? Quantity( + kind: _parseKind(l.quantityKind), + count: l.quantityPrecise, + label: l.quantityLabel, + ) + : null, + abundance: l.abundance, + offerStatus: l.offerStatus, + ), + ]; + entries.sort( + (a, b) => + a.varietyLabel.toLowerCase().compareTo(b.varietyLabel.toLowerCase()), + ); + return entries; + } + /// Soft-deletes a lot (tombstone); it leaves the variety's lot list. Future softDeleteLot(String lotId) async { final (_, updated) = _stamp(); @@ -1487,10 +1617,11 @@ class VarietyRepository { Future _resolveSpeciesByName(String? scientificName) async { final name = scientificName?.trim(); if (name == null || name.isEmpty) return null; - final local = await (_db.select(_db.species)..where( - (s) => s.scientificName.equals(name) & s.isDeleted.equals(false), - )) - .getSingleOrNull(); + final local = + await (_db.select(_db.species)..where( + (s) => s.scientificName.equals(name) & s.isDeleted.equals(false), + )) + .getSingleOrNull(); return local?.id; } @@ -1501,12 +1632,11 @@ class VarietyRepository { final resolved = {}; for (final entry in speciesNamesById.entries) { final local = - await (_db.select(_db.species) - ..where( - (s) => - s.scientificName.equals(entry.value) & - s.isDeleted.equals(false), - )) + await (_db.select(_db.species)..where( + (s) => + s.scientificName.equals(entry.value) & + s.isDeleted.equals(false), + )) .getSingleOrNull(); resolved[entry.key] = local?.id; } @@ -1529,7 +1659,9 @@ class VarietyRepository { final existing = await (_db.select( table, )..where((_) => idColumn.isIn(byId.keys))).get(); - final existingUpdatedAt = {for (final r in existing) idOf(r): updatedAtOf(r)}; + final existingUpdatedAt = { + for (final r in existing) idOf(r): updatedAtOf(r), + }; var inserted = 0, updated = 0, skipped = 0; for (final row in byId.values) { @@ -1548,7 +1680,11 @@ class VarietyRepository { skipped++; } } - return ImportSummary(inserted: inserted, updated: updated, skipped: skipped); + return ImportSummary( + inserted: inserted, + updated: updated, + skipped: skipped, + ); } /// Movements are append-only: insert unknown ids, never touch known ones. @@ -1598,6 +1734,7 @@ class VarietyRepository { originPlace: l.originPlace, abundance: l.abundance, preservationFormat: l.preservationFormat, + offerStatus: l.offerStatus, germinationTests: germinationTests, conditionChecks: conditionChecks, quantity: hasQuantity diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index 96e67f5..5165550 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -22,6 +22,7 @@ import '../services/ocr/label_text_extractor.dart'; import '../services/ocr/ocr_language.dart'; import '../services/ocr/tesseract_label_extractor.dart'; import '../services/onboarding_store.dart'; +import '../services/share_catalog_service.dart'; /// The app's service locator. Kept to the composition root — widgets get their /// repositories from here (or via BlocProvider), never by reaching into it deep @@ -75,6 +76,9 @@ Future configureDependencies() async { ..registerSingleton(OnboardingStore(secretStore)) ..registerSingleton( ExportImportService(repository: varietyRepository, files: fileService), + ) + ..registerSingleton( + ShareCatalogService(files: fileService), ); } diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 98c155a..ba71a1d 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -229,6 +229,20 @@ "enoughForMe": "Enough for me", "runningLow": "Running low" }, + "share": { + "add": "Do you share it?", + "title": "Do you share it?", + "nudge": "You have plenty — you could share some.", + "private": "Just for me", + "gift": "To give away", + "exchange": "To swap", + "sell": "For sale", + "filterChip": "I share", + "printCatalog": "Print what I share", + "catalogTitle": "What I share", + "catalogSaved": "Catalog 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 7645894..8169033 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -229,6 +229,20 @@ "enoughForMe": "Suficiente para mí", "runningLow": "Queda poca" }, + "share": { + "add": "¿La compartes?", + "title": "¿La compartes?", + "nudge": "Tienes de sobra: podrías compartir un poco.", + "private": "Solo para mí", + "gift": "Para regalar", + "exchange": "Para intercambiar", + "sell": "En venta", + "filterChip": "Comparto", + "printCatalog": "Imprimir lo que comparto", + "catalogTitle": "Lo que comparto", + "catalogSaved": "Catálogo guardado", + "cancelled": "Cancelado" + }, "cropCalendar": { "add": "Calendario de cultivo", "title": "Calendario de cultivo", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 48268b8..0ee27f7 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: 2 -/// Strings: 530 (265 per locale) +/// Strings: 554 (277 per locale) /// -/// Built on 2026-07-09 at 17:18 UTC +/// Built on 2026-07-09 at 20:14 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index cd98732..58b893e 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -62,6 +62,7 @@ class Translations with BaseTranslations { late final Translations$presentation$en presentation = Translations$presentation$en.internal(_root); late final Translations$provenance$en provenance = Translations$provenance$en.internal(_root); late final Translations$abundance$en abundance = Translations$abundance$en.internal(_root); + late final Translations$share$en share = Translations$share$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); @@ -765,6 +766,51 @@ class Translations$abundance$en { String get runningLow => 'Running low'; } +// Path: share +class Translations$share$en { + Translations$share$en.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Do you share it?' + String get add => 'Do you share it?'; + + /// en: 'Do you share it?' + String get title => 'Do you share it?'; + + /// en: 'You have plenty — you could share some.' + String get nudge => 'You have plenty — you could share some.'; + + /// en: 'Just for me' + String get private => 'Just for me'; + + /// en: 'To give away' + String get gift => 'To give away'; + + /// en: 'To swap' + String get exchange => 'To swap'; + + /// en: 'For sale' + String get sell => 'For sale'; + + /// en: 'I share' + String get filterChip => 'I share'; + + /// en: 'Print what I share' + String get printCatalog => 'Print what I share'; + + /// en: 'What I share' + String get catalogTitle => 'What I share'; + + /// en: 'Catalog saved' + String get catalogSaved => 'Catalog saved'; + + /// en: 'Cancelled' + String get cancelled => 'Cancelled'; +} + // Path: cropCalendar class Translations$cropCalendar$en { Translations$cropCalendar$en.internal(this._root); @@ -1588,6 +1634,18 @@ extension on Translations { 'abundance.enoughToShare' => 'Enough to share a little', 'abundance.enoughForMe' => 'Enough for me', 'abundance.runningLow' => 'Running low', + 'share.add' => 'Do you share it?', + 'share.title' => 'Do you share it?', + 'share.nudge' => 'You have plenty — you could share some.', + 'share.private' => 'Just for me', + 'share.gift' => 'To give away', + 'share.exchange' => 'To swap', + 'share.sell' => 'For sale', + 'share.filterChip' => 'I share', + 'share.printCatalog' => 'Print what I share', + 'share.catalogTitle' => 'What I share', + 'share.catalogSaved' => 'Catalog saved', + 'share.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 5ccb0fe..96283b7 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -61,6 +61,7 @@ class TranslationsEs extends Translations with BaseTranslations 'Queda poca'; } +// Path: share +class _Translations$share$es extends Translations$share$en { + _Translations$share$es._(TranslationsEs root) : this._root = root, super.internal(root); + + final TranslationsEs _root; // ignore: unused_field + + // Translations + @override String get add => '¿La compartes?'; + @override String get title => '¿La compartes?'; + @override String get nudge => 'Tienes de sobra: podrías compartir un poco.'; + @override String get private => 'Solo para mí'; + @override String get gift => 'Para regalar'; + @override String get exchange => 'Para intercambiar'; + @override String get sell => 'En venta'; + @override String get filterChip => 'Comparto'; + @override String get printCatalog => 'Imprimir lo que comparto'; + @override String get catalogTitle => 'Lo que comparto'; + @override String get catalogSaved => 'Catálogo guardado'; + @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); @@ -1078,6 +1100,18 @@ extension on TranslationsEs { 'abundance.enoughToShare' => 'Bastante, para compartir con moderación', 'abundance.enoughForMe' => 'Suficiente para mí', 'abundance.runningLow' => 'Queda poca', + 'share.add' => '¿La compartes?', + 'share.title' => '¿La compartes?', + 'share.nudge' => 'Tienes de sobra: podrías compartir un poco.', + 'share.private' => 'Solo para mí', + 'share.gift' => 'Para regalar', + 'share.exchange' => 'Para intercambiar', + 'share.sell' => 'En venta', + 'share.filterChip' => 'Comparto', + 'share.printCatalog' => 'Imprimir lo que comparto', + 'share.catalogTitle' => 'Lo que comparto', + 'share.catalogSaved' => 'Catálogo guardado', + 'share.cancelled' => 'Cancelado', 'cropCalendar.add' => 'Calendario de cultivo', 'cropCalendar.title' => 'Calendario de cultivo', 'cropCalendar.sow' => 'Siembra', diff --git a/apps/app_seeds/lib/services/share_catalog_service.dart b/apps/app_seeds/lib/services/share_catalog_service.dart new file mode 100644 index 0000000..469a7a3 --- /dev/null +++ b/apps/app_seeds/lib/services/share_catalog_service.dart @@ -0,0 +1,132 @@ +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'; + +/// One printable line of the "what I share" catalog. All strings arrive +/// already localized — this service knows nothing about i18n or the domain. +class ShareCatalogRow { + const ShareCatalogRow({ + required this.name, + required this.mode, + this.scientificName, + this.details, + }); + + /// The variety's label, as its keeper writes it. + final String name; + + /// Scientific name of the linked species, if any (rendered in italics). + final String? scientificName; + + /// The batch facts line ("Seeds · 2024 · 2 cobs"), if any. + final String? details; + + /// On what terms it is offered ("To give away" / "To swap" / "For sale"). + final String mode; +} + +/// Renders the "what I share" catalog as a PDF and hands it to the user via +/// the save dialog — a paper bridge for fairs and meet-ups, no network needed. +class ShareCatalogService { + ShareCatalogService({required FileService files}) : _files = files; + + final FileService _files; + + /// Builds the catalog PDF. 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. + Future buildPdf({ + required String title, + required String date, + required List rows, + }) async { + 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), + ); + doc.addPage( + pw.MultiPage( + pageFormat: PdfPageFormat.a4, + build: (context) => [ + pw.Text(title, style: pw.TextStyle(font: bold, fontSize: 20)), + pw.SizedBox(height: 2), + pw.Text( + date, + style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey700), + ), + pw.SizedBox(height: 12), + for (final row in rows) + pw.Container( + padding: const pw.EdgeInsets.symmetric(vertical: 6), + decoration: const pw.BoxDecoration( + border: pw.Border( + bottom: pw.BorderSide(color: PdfColors.grey300, width: 0.5), + ), + ), + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + row.name, + style: pw.TextStyle(font: bold, fontSize: 12), + ), + if (row.scientificName != null) + pw.Text( + row.scientificName!, + style: pw.TextStyle( + fontSize: 10, + fontStyle: pw.FontStyle.italic, + color: PdfColors.grey700, + ), + ), + if (row.details != null) + pw.Text( + row.details!, + style: const pw.TextStyle(fontSize: 10), + ), + ], + ), + ), + pw.SizedBox(width: 8), + pw.Text(row.mode, style: const pw.TextStyle(fontSize: 10)), + ], + ), + ), + ], + ), + ); + return doc.save(); + } + + /// Builds the PDF and asks the user where to keep it. Returns true when + /// saved, false when they cancelled the dialog. + Future saveCatalog({ + required String title, + required String date, + required String suggestedName, + required List rows, + }) async { + final bytes = await buildPdf(title: title, date: date, rows: rows); + 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 4908312..f3dc365 100644 --- a/apps/app_seeds/lib/state/inventory_cubit.dart +++ b/apps/app_seeds/lib/state/inventory_cubit.dart @@ -18,6 +18,7 @@ class InventoryState extends Equatable { this.typeFilter = const {}, this.organicOnly = false, this.needsReproductionOnly = false, + this.sharingOnly = false, this.loading = true, }); @@ -42,6 +43,10 @@ class InventoryState extends Equatable { /// When true, keep only varieties flagged "needs reproducing this season". final bool needsReproductionOnly; + /// When true, keep only varieties with some lot offered to others — the + /// "what I share" view. + final bool sharingOnly; + final bool loading; /// Categories present across all items, in display order (deduped), so the @@ -60,15 +65,16 @@ class InventoryState extends Equatable { final q = query.trim().toLowerCase(); return items.where((i) { if (q.isNotEmpty && !i.label.toLowerCase().contains(q)) return false; - if (categoryFilter.isNotEmpty && - !categoryFilter.contains(i.category)) { + if (categoryFilter.isNotEmpty && !categoryFilter.contains(i.category)) { return false; } - if (typeFilter.isNotEmpty && i.lotTypes.intersection(typeFilter).isEmpty) { + if (typeFilter.isNotEmpty && + i.lotTypes.intersection(typeFilter).isEmpty) { return false; } if (organicOnly && !i.isOrganic) return false; if (needsReproductionOnly && !i.needsReproduction) return false; + if (sharingOnly && !i.isShared) return false; return true; }).toList(); } @@ -81,6 +87,10 @@ class InventoryState extends Equatable { /// that filter chip when it would match something. bool get hasNeedsReproduction => items.any((i) => i.needsReproduction); + /// Whether any variety has a lot offered to others, so the UI only offers + /// the "sharing" chip and the printable catalog when they'd show something. + bool get hasShared => items.any((i) => i.isShared); + InventoryState copyWith({ List? items, List? drafts, @@ -89,6 +99,7 @@ class InventoryState extends Equatable { Set? typeFilter, bool? organicOnly, bool? needsReproductionOnly, + bool? sharingOnly, bool? loading, }) { return InventoryState( @@ -100,6 +111,7 @@ class InventoryState extends Equatable { organicOnly: organicOnly ?? this.organicOnly, needsReproductionOnly: needsReproductionOnly ?? this.needsReproductionOnly, + sharingOnly: sharingOnly ?? this.sharingOnly, loading: loading ?? this.loading, ); } @@ -113,6 +125,7 @@ class InventoryState extends Equatable { typeFilter, organicOnly, needsReproductionOnly, + sharingOnly, loading, ]; } @@ -157,9 +170,12 @@ class InventoryCubit extends Cubit { emit(state.copyWith(organicOnly: !state.organicOnly)); /// Toggles the "needs reproducing only" filter. - void toggleNeedsReproductionOnly() => emit( - state.copyWith(needsReproductionOnly: !state.needsReproductionOnly), - ); + void toggleNeedsReproductionOnly() => + emit(state.copyWith(needsReproductionOnly: !state.needsReproductionOnly)); + + /// Toggles the "what I share" filter. + void toggleSharingOnly() => + emit(state.copyWith(sharingOnly: !state.sharingOnly)); /// Clears all filters (search is left untouched). void clearFilters() => emit( @@ -168,6 +184,7 @@ class InventoryCubit extends Cubit { typeFilter: const {}, organicOnly: false, needsReproductionOnly: false, + sharingOnly: false, ), ); diff --git a/apps/app_seeds/lib/state/variety_detail_cubit.dart b/apps/app_seeds/lib/state/variety_detail_cubit.dart index b39c687..292c07f 100644 --- a/apps/app_seeds/lib/state/variety_detail_cubit.dart +++ b/apps/app_seeds/lib/state/variety_detail_cubit.dart @@ -80,6 +80,7 @@ class VarietyDetailCubit extends Cubit { String? originPlace, Abundance? abundance, PreservationFormat? preservationFormat, + OfferStatus offerStatus = OfferStatus.private, }) => _repo.addLot( varietyId: varietyId, type: type, @@ -91,6 +92,7 @@ class VarietyDetailCubit extends Cubit { originPlace: originPlace, abundance: abundance, preservationFormat: preservationFormat, + offerStatus: offerStatus, ); Future updateLot({ @@ -104,6 +106,7 @@ class VarietyDetailCubit extends Cubit { String? originPlace, Abundance? abundance, PreservationFormat? preservationFormat, + OfferStatus offerStatus = OfferStatus.private, }) => _repo.updateLot( lotId: lotId, type: type, @@ -115,6 +118,7 @@ class VarietyDetailCubit extends Cubit { originPlace: originPlace, abundance: abundance, preservationFormat: preservationFormat, + offerStatus: offerStatus, ); Future deleteLot(String lotId) => _repo.softDeleteLot(lotId); diff --git a/apps/app_seeds/lib/ui/inventory_list_screen.dart b/apps/app_seeds/lib/ui/inventory_list_screen.dart index 7c421d2..ac8282e 100644 --- a/apps/app_seeds/lib/ui/inventory_list_screen.dart +++ b/apps/app_seeds/lib/ui/inventory_list_screen.dart @@ -4,15 +4,19 @@ import 'package:go_router/go_router.dart'; import '../data/variety_repository.dart'; import '../db/enums.dart'; +import '../di/injector.dart'; import '../domain/seed_viability.dart'; import '../i18n/strings.g.dart'; +import '../services/share_catalog_service.dart'; import '../state/inventory_cubit.dart'; import 'app_drawer.dart'; import 'draft_triage.dart'; +import 'quantity_kind_l10n.dart'; import 'quantity_picker.dart'; import 'quick_add_sheet.dart'; import 'seed_glyph.dart'; import 'theme.dart'; +import 'variety_detail_screen.dart' show shareStatusLabel; /// The inventory home: a searchable list of seeds grouped by category, with a /// quick-add FAB. Driven by [InventoryCubit] over the encrypted DB stream. @@ -26,6 +30,18 @@ class InventoryListScreen extends StatelessWidget { 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), @@ -80,7 +96,10 @@ class InventoryListScreen extends StatelessWidget { // Distinguish "no seeds at all" from "filters hid them all". filtered: state.categoryFilter.isNotEmpty || - state.typeFilter.isNotEmpty, + state.typeFilter.isNotEmpty || + state.organicOnly || + state.needsReproductionOnly || + state.sharingOnly, ), ), ], @@ -90,6 +109,46 @@ class InventoryListScreen extends StatelessWidget { ); } + /// 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 { + final t = context.t; + final repository = context.read(); + final messenger = ScaffoldMessenger.of(context); + final date = DateTime.now(); + final dateLabel = MaterialLocalizations.of(context).formatFullDate(date); + final entries = await repository.sharedCatalog(); + final rows = [ + for (final e in entries) + ShareCatalogRow( + name: e.varietyLabel, + scientificName: e.scientificName, + mode: shareStatusLabel(t, e.offerStatus), + details: _catalogDetails(t, e), + ), + ]; + final saved = await getIt().saveCatalog( + title: t.share.catalogTitle, + date: dateLabel, + suggestedName: + 'tanemaki-catalog-${date.toIso8601String().substring(0, 10)}.pdf', + rows: rows, + ); + messenger.showSnackBar( + SnackBar(content: Text(saved ? t.share.catalogSaved : t.share.cancelled)), + ); + } + + /// The batch facts line of one catalog entry ("Seeds · Year 2024 · 2 cobs"). + String? _catalogDetails(Translations t, SharedCatalogEntry e) { + final parts = [ + lotTypeLabel(t, e.type), + if (e.harvestYear != null) t.detail.year(year: e.harvestYear!), + if (e.quantity != null) quantityDisplay(t, e.quantity!), + ]; + return parts.isEmpty ? null : parts.join(' · '); + } + Future _captureBurst(BuildContext context) async { final t = context.t; final repository = context.read(); @@ -151,14 +210,29 @@ class _FilterBar extends StatelessWidget { final hasOrganic = state.hasOrganic; // Only offer the "to regrow" chip when something is flagged for it. final hasNeedsReproduction = state.hasNeedsReproduction; + // Only offer the "I share" chip when some lot is marked to share. + final hasShared = state.hasShared; if (categories.isEmpty && forms.isEmpty && !hasOrganic && - !hasNeedsReproduction) { + !hasNeedsReproduction && + !hasShared) { return const SizedBox.shrink(); } final chips = [ + if (hasShared) + FilterChip( + key: const Key('inventory.filter.sharing'), + avatar: Icon( + Icons.volunteer_activism_outlined, + size: 18, + color: state.sharingOnly ? null : seedGreen, + ), + label: Text(t.share.filterChip), + selected: state.sharingOnly, + onSelected: (_) => cubit.toggleSharingOnly(), + ), if (hasOrganic) FilterChip( key: const Key('inventory.filter.organic'), @@ -202,7 +276,8 @@ class _FilterBar extends StatelessWidget { state.categoryFilter.isNotEmpty || state.typeFilter.isNotEmpty || state.organicOnly || - state.needsReproductionOnly; + state.needsReproductionOnly || + state.sharingOnly; return SingleChildScrollView( scrollDirection: Axis.horizontal, @@ -323,6 +398,19 @@ class _VarietyTile extends StatelessWidget { 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, + color: seedGreen, + ), + ), + ), if (item.isOrganic) Padding( padding: const EdgeInsetsDirectional.only(end: 4), diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index 08abdc7..bba1e62 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -179,6 +179,8 @@ String _lotSubtitle(Translations t, VarietyLot lot) { else if (lot.abundance != null) abundanceLabel(t, lot.abundance!), if (lot.presentation != null) presentationLabel(t, lot.presentation!), + if (lot.offerStatus != OfferStatus.private) + shareStatusLabel(t, lot.offerStatus), ]; return parts.join(' · '); } @@ -885,6 +887,14 @@ String abundanceLabel(Translations t, Abundance a) => switch (a) { Abundance.runningLow => t.abundance.runningLow, }; +/// Human label for how (whether) a lot is offered to others. +String shareStatusLabel(Translations t, OfferStatus s) => switch (s) { + OfferStatus.private => t.share.private, + OfferStatus.shared => t.share.gift, + OfferStatus.exchange => t.share.exchange, + OfferStatus.sell => t.share.sell, +}; + /// Human label for a seed preservation format. String preservationLabel(Translations t, PreservationFormat p) => switch (p) { PreservationFormat.jarWithDesiccant => t.preservation.jarWithDesiccant, @@ -930,6 +940,41 @@ class _AbundanceSelector extends StatelessWidget { } } +/// Single-select chips for the share terms. Gift and swap come first and sale +/// last, never preselected — the gift is first-class, the sale a choice +/// (sharing-model §4.1). There is always a selection ("just for me" default). +class _ShareSelector extends StatelessWidget { + const _ShareSelector({required this.value, required this.onChanged}); + + final OfferStatus value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final t = context.t; + return Wrap( + spacing: 8, + runSpacing: 4, + children: [ + for (final s in const [ + OfferStatus.private, + OfferStatus.shared, + OfferStatus.exchange, + OfferStatus.sell, + ]) + ChoiceChip( + key: Key('share.${s.name}'), + label: Text(shareStatusLabel(t, s)), + selected: value == s, + onSelected: (sel) { + if (sel) onChanged(s); + }, + ), + ], + ); + } +} + /// Single-select chips for the preservation format; tapping the current one /// clears it (null). class _PreservationSelector extends StatelessWidget { @@ -1113,15 +1158,19 @@ Future _showLotSheet( final editing = existing != null; // Provenance + abundance + preservation: hidden behind reveal-on-tap chips so // the default form stays small (progressive disclosure). - final originNameCtrl = TextEditingController(text: existing?.originName ?? ''); + final originNameCtrl = TextEditingController( + text: existing?.originName ?? '', + ); final originPlaceCtrl = TextEditingController( text: existing?.originPlace ?? '', ); var selectedAbundance = existing?.abundance; var selectedPreservation = existing?.preservationFormat; + var selectedShare = existing?.offerStatus ?? OfferStatus.private; var showOrigin = existing?.originName != null || existing?.originPlace != null; var showAbundance = existing?.abundance != null; + var showShare = selectedShare != OfferStatus.private; return showModalBottomSheet( context: context, isScrollControlled: true, @@ -1227,11 +1276,24 @@ Future _showLotSheet( if (!showAbundance) ActionChip( key: const Key('lot.addAbundance'), - avatar: const Icon(Icons.inventory_2_outlined, size: 18), + avatar: const Icon( + Icons.inventory_2_outlined, + size: 18, + ), label: Text(t.abundance.add), onPressed: () => setState(() => showAbundance = true), ), + if (!showShare) + ActionChip( + key: const Key('lot.addShare'), + avatar: const Icon( + Icons.volunteer_activism_outlined, + size: 18, + ), + label: Text(t.share.add), + onPressed: () => setState(() => showShare = true), + ), ], ), if (showOrigin) ...[ @@ -1271,9 +1333,41 @@ Future _showLotSheet( const SizedBox(height: 8), _AbundanceSelector( value: selectedAbundance, - onChanged: (a) => setState(() => selectedAbundance = a), + onChanged: (a) => setState(() { + selectedAbundance = a; + // The bridge from "how much" to "do you share it": + // declaring plenty reveals the sharing choice. A + // nudge, never a change of the choice itself. + if (a == Abundance.plentyToShare || + a == Abundance.enoughToShare) { + showShare = true; + } + }), ), ], + if (showShare) ...[ + const SizedBox(height: 16), + Text( + t.share.title, + style: Theme.of(sheetContext).textTheme.labelLarge, + ), + const SizedBox(height: 8), + _ShareSelector( + value: selectedShare, + onChanged: (s) => setState(() => selectedShare = s), + ), + if (selectedShare == OfferStatus.private && + (selectedAbundance == Abundance.plentyToShare || + selectedAbundance == Abundance.enoughToShare)) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + t.share.nudge, + key: const Key('share.nudge'), + style: Theme.of(sheetContext).textTheme.bodySmall, + ), + ), + ], // Layer 2: advanced seed-bank details, collapsed by default. // Only for seed lots, and (condition checks) an existing lot. if (selectedType == LotType.seed) @@ -1289,8 +1383,9 @@ Future _showLotSheet( alignment: Alignment.centerLeft, child: Text( t.preservation.title, - style: - Theme.of(sheetContext).textTheme.labelLarge, + style: Theme.of( + sheetContext, + ).textTheme.labelLarge, ), ), const SizedBox(height: 8), @@ -1334,6 +1429,7 @@ Future _showLotSheet( originPlace: originPlace, abundance: selectedAbundance, preservationFormat: selectedPreservation, + offerStatus: selectedShare, ); } else { cubit.addLot( @@ -1346,6 +1442,7 @@ Future _showLotSheet( originPlace: originPlace, abundance: selectedAbundance, preservationFormat: selectedPreservation, + offerStatus: selectedShare, ); } Navigator.of(sheetContext).pop(); diff --git a/apps/app_seeds/pubspec.yaml b/apps/app_seeds/pubspec.yaml index 20e28ce..e722c3b 100644 --- a/apps/app_seeds/pubspec.yaml +++ b/apps/app_seeds/pubspec.yaml @@ -52,6 +52,9 @@ dependencies: image: ^4.3.0 # Save/open dialogs for the inventory export/import (MIT license). file_picker: ^10.1.2 + # Pure-Dart PDF generation (Apache-2.0) for the printable "what I share" + # catalog. No platform channels, so the whole flow is host-testable. + pdf: ^3.11.3 path: ^1.9.0 path_provider: ^2.1.5 @@ -119,6 +122,12 @@ flutter: # Real packet photos for the on-device OCR validation integration test. # Remove before shipping if you don't want them in the bundle. - assets/ocr_fixtures/ + # Embedded in generated PDFs (printable catalog). DejaVu covers Latin + # (extended), Cyrillic and Greek; like the OCR packs, PDF fonts are a + # per-locale resource — swap in wider coverage (Arabic, CJK) as locales + # land. Free license (Bitstream Vera / public domain additions). + - assets/fonts/DejaVuSans.ttf + - assets/fonts/DejaVuSans-Bold.ttf fonts: # Custom seed-themed icon glyphs (chars a–k). See docs/mockups/seedks-glyphs.png. - family: seedks diff --git a/apps/app_seeds/test/data/shared_catalog_test.dart b/apps/app_seeds/test/data/shared_catalog_test.dart new file mode 100644 index 0000000..979ce2c --- /dev/null +++ b/apps/app_seeds/test/data/shared_catalog_test.dart @@ -0,0 +1,104 @@ +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'; + +import '../support/test_support.dart'; + +void main() { + late AppDatabase db; + late VarietyRepository repo; + + setUp(() { + db = newTestDatabase(); + repo = newTestRepository(db); + }); + tearDown(() => db.close()); + + test('addLot and updateLot persist the share terms', () async { + final id = await repo.addQuickVariety(label: 'Maize'); + final lotId = await repo.addLot( + varietyId: id, + offerStatus: OfferStatus.shared, + ); + + var detail = await repo.watchVariety(id).first; + expect(detail!.lots.single.offerStatus, OfferStatus.shared); + + await repo.updateLot( + lotId: lotId, + type: LotType.seed, + offerStatus: OfferStatus.exchange, + ); + detail = await repo.watchVariety(id).first; + expect(detail!.lots.single.offerStatus, OfferStatus.exchange); + }); + + test('list items flag varieties with some lot offered', () async { + final sharedId = await repo.addQuickVariety(label: 'Shared bean'); + await repo.addLot(varietyId: sharedId, offerStatus: OfferStatus.sell); + final privateId = await repo.addQuickVariety(label: 'Private bean'); + await repo.addLot(varietyId: privateId); + + final items = await repo.watchInventoryView().first; + final byLabel = {for (final i in items.items) i.label: i}; + expect(byLabel['Shared bean']!.isShared, isTrue); + expect(byLabel['Private bean']!.isShared, isFalse); + }); + + test('sharedCatalog lists only offered lots, sorted and joined', () async { + final species = newTestSpeciesRepository(db); + await species.seedBundled(const [ + SpeciesSeed( + scientificName: 'Zea mays', + family: 'Poaceae', + commonNames: { + 'en': ['Maize'], + }, + ), + ]); + + final maizeId = await repo.addQuickVariety(label: 'Rebordanes maize'); + final matches = await species.search('Zea', languageCode: 'en'); + await repo.linkSpecies(maizeId, matches.single.id); + await repo.addLot( + varietyId: maizeId, + harvestYear: 2024, + quantity: const Quantity(kind: QuantityKind.cob, count: 2), + offerStatus: OfferStatus.shared, + ); + // A private lot of the same variety stays out of the catalog. + await repo.addLot(varietyId: maizeId, harvestYear: 2023); + + final beanId = await repo.addQuickVariety(label: 'Alubia de Tolosa'); + await repo.addLot( + varietyId: beanId, + abundance: Abundance.plentyToShare, + offerStatus: OfferStatus.exchange, + ); + + // A deleted shared lot disappears with its tombstone. + final goneId = await repo.addQuickVariety(label: 'Gone'); + final goneLot = await repo.addLot( + varietyId: goneId, + offerStatus: OfferStatus.shared, + ); + await repo.softDeleteLot(goneLot); + + final catalog = await repo.sharedCatalog(); + expect(catalog, hasLength(2)); + // Sorted by label, case-insensitively. + expect(catalog.first.varietyLabel, 'Alubia de Tolosa'); + expect(catalog.first.offerStatus, OfferStatus.exchange); + expect(catalog.first.abundance, Abundance.plentyToShare); + + final maize = catalog.last; + expect(maize.varietyLabel, 'Rebordanes maize'); + expect(maize.scientificName, 'Zea mays'); + expect(maize.category, 'Poaceae'); + expect(maize.harvestYear, 2024); + expect(maize.quantity!.count, 2); + }); +} diff --git a/apps/app_seeds/test/services/share_catalog_service_test.dart b/apps/app_seeds/test/services/share_catalog_service_test.dart new file mode 100644 index 0000000..2484664 --- /dev/null +++ b/apps/app_seeds/test/services/share_catalog_service_test.dart @@ -0,0 +1,66 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/file_service.dart'; +import 'package:tane/services/share_catalog_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; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('buildPdf renders a well-formed PDF with the given rows', () async { + final service = ShareCatalogService(files: _RecordingFileService()); + final bytes = await service.buildPdf( + title: 'What I share', + date: 'July 9, 2026', + rows: const [ + ShareCatalogRow( + name: 'Rebordanes maize', + scientificName: 'Zea mays', + details: 'Seeds · Year 2024 · 2 cobs', + mode: 'To give away', + ), + ShareCatalogRow(name: 'Alubia de Tolosa', mode: 'To swap'), + ], + ); + + // %PDF header + non-trivial body; text content is compressed, so the + // human-readable row assertions live in the widget/data tests. + expect(String.fromCharCodes(bytes.take(5)), '%PDF-'); + expect(bytes.length, greaterThan(1000)); + }); + + test('saveCatalog hands the PDF to the save dialog', () async { + final files = _RecordingFileService(); + final service = ShareCatalogService(files: files); + final saved = await service.saveCatalog( + title: 'What I share', + date: 'July 9, 2026', + suggestedName: 'tanemaki-catalog-2026-07-09.pdf', + rows: const [ShareCatalogRow(name: 'Maize', mode: 'To give away')], + ); + + expect(saved, isTrue); + expect(files.savedName, 'tanemaki-catalog-2026-07-09.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 483aed8..3ce4ad9 100644 --- a/apps/app_seeds/test/state/inventory_cubit_test.dart +++ b/apps/app_seeds/test/state/inventory_cubit_test.dart @@ -103,6 +103,20 @@ void main() { expect(cubit.state.visibleItems.single.label, 'Tired'); }); + test('sharing filter keeps only varieties with an offered lot', () async { + final sharedId = await repo.addQuickVariety(label: 'Shared'); + await repo.addLot(varietyId: sharedId, offerStatus: OfferStatus.shared); + final privateId = await repo.addQuickVariety(label: 'Private'); + await repo.addLot(varietyId: privateId); + await waitFor(cubit, (s) => s.hasShared && s.items.length == 2); + + cubit.toggleSharingOnly(); + expect(cubit.state.visibleItems.single.label, 'Shared'); + + cubit.toggleSharingOnly(); + expect(cubit.state.visibleItems, hasLength(2)); + }); + test('clearFilters resets every filter but keeps the search query', () async { await repo.addQuickVariety(label: 'Maize', category: 'Poaceae'); await waitFor(cubit, (s) => s.items.isNotEmpty); @@ -112,13 +126,15 @@ void main() { ..toggleCategory('Poaceae') ..toggleType(LotType.seed) ..toggleOrganicOnly() - ..toggleNeedsReproductionOnly(); + ..toggleNeedsReproductionOnly() + ..toggleSharingOnly(); cubit.clearFilters(); expect(cubit.state.categoryFilter, isEmpty); expect(cubit.state.typeFilter, isEmpty); expect(cubit.state.organicOnly, isFalse); expect(cubit.state.needsReproductionOnly, isFalse); + expect(cubit.state.sharingOnly, isFalse); expect(cubit.state.query, 'mai'); }); diff --git a/apps/app_seeds/test/ui/share_flow_test.dart b/apps/app_seeds/test/ui/share_flow_test.dart new file mode 100644 index 0000000..f0c1a69 --- /dev/null +++ b/apps/app_seeds/test/ui/share_flow_test.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; +import 'package:tane/ui/inventory_list_screen.dart'; + +import '../support/test_support.dart'; + +void main() { + late AppDatabase db; + + setUp(() => db = newTestDatabase()); + tearDown(() => db.close()); + + testWidgets('marking a lot to give away shows on its tile', (tester) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Maize'); + await repo.addLot(varietyId: id, harvestYear: 2024); + + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.textContaining('Year 2024')); // open the lot + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.byKey(const Key('lot.addShare'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('lot.addShare'))); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.byKey(const Key('share.shared'))); + await tester.tap(find.byKey(const Key('share.shared'))); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.byKey(const Key('addLot.save'))); + await tester.tap(find.byKey(const Key('addLot.save'))); + await tester.pumpAndSettle(); + + // The lot line now carries the share terms. + expect(find.textContaining('To give away'), findsOneWidget); + await disposeTree(tester); + }); + + testWidgets('declaring plenty reveals the share choice with a nudge', ( + tester, + ) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Maize'); + + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('detail.addLot'))); + await tester.pumpAndSettle(); + + // Reveal abundance and declare plenty. + await tester.ensureVisible(find.byKey(const Key('lot.addAbundance'))); + await tester.tap(find.byKey(const Key('lot.addAbundance'))); + await tester.pumpAndSettle(); + await tester.ensureVisible( + find.byKey(const Key('abundance.plentyToShare')), + ); + await tester.tap(find.byKey(const Key('abundance.plentyToShare'))); + await tester.pumpAndSettle(); + + // The share section reveals itself, still private, with a soft nudge. + expect(find.byKey(const Key('share.private')), findsOneWidget); + expect(find.byKey(const Key('share.nudge')), findsOneWidget); + await disposeTree(tester); + }); + + testWidgets('the sharing filter narrows the list to what I share', ( + tester, + ) async { + final repo = newTestRepository(db); + final sharedId = await repo.addQuickVariety(label: 'Shared bean'); + await repo.addLot(varietyId: sharedId, offerStatus: OfferStatus.exchange); + final privateId = await repo.addQuickVariety(label: 'Private bean'); + await repo.addLot(varietyId: privateId); + + await tester.pumpWidget( + wrapScreen(repository: repo, child: const InventoryListScreen()), + ); + await tester.pumpAndSettle(); + + // Both listed; the shared one carries its badge, and the print action and + // filter chip are offered. + expect(find.text('Shared bean'), findsOneWidget); + expect(find.text('Private bean'), findsOneWidget); + expect(find.byKey(Key('inventory.shared.$sharedId')), findsOneWidget); + expect(find.byKey(const Key('inventory.printCatalog')), findsOneWidget); + + await tester.tap(find.byKey(const Key('inventory.filter.sharing'))); + await tester.pumpAndSettle(); + + expect(find.text('Shared bean'), findsOneWidget); + expect(find.text('Private bean'), findsNothing); + await disposeTree(tester); + }); +} diff --git a/pubspec.lock b/pubspec.lock index 54b329d..dd990f8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" bloc: dependency: transitive description: @@ -812,6 +828,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: transitive description: @@ -860,6 +884,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pdf: + dependency: transitive + description: + name: pdf + sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b + url: "https://pub.dev" + source: hosted + version: "3.12.0" petitparser: dependency: transitive description: @@ -932,6 +964,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" recase: dependency: transitive description: