feat(inventory): local sharing — per-lot share terms, filter and printable catalog
The local half of the original Fase 2 (no network, no Offer entity yet): - Lot sheet gains a 'do you share it?' choice (gift/swap first, sale last, never the default); declaring plenty of abundance reveals it with a nudge. - Lot lines and list tiles badge shared batches; an 'I share' filter and a printable PDF catalog (paper bridge for fairs) round out the view. - Catalog PDF embeds DejaVu (Latin-ext/Cyrillic/Greek); fonts are a per-locale resource like the OCR packs.
This commit is contained in:
parent
e3ec855630
commit
ba87bf2719
20 changed files with 982 additions and 36 deletions
BIN
apps/app_seeds/assets/fonts/DejaVuSans-Bold.ttf
Normal file
BIN
apps/app_seeds/assets/fonts/DejaVuSans-Bold.ttf
Normal file
Binary file not shown.
BIN
apps/app_seeds/assets/fonts/DejaVuSans.ttf
Normal file
BIN
apps/app_seeds/assets/fonts/DejaVuSans.ttf
Normal file
Binary file not shown.
|
|
@ -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<Object?> 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<GerminationEntry> 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<List<VarietyListItem>> watchDrafts() {
|
||||
final triggers = StreamGroup.merge<void>([
|
||||
(_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<List<VarietyListItem>> _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<Set<String>> _sharedVarietyIdsFor(List<String> 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<Map<String, Uint8List>> _firstPhotosFor(
|
||||
List<String> 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<List<SharedCatalogEntry>> 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<String>().toSet(),
|
||||
);
|
||||
|
||||
final entries = <SharedCatalogEntry>[
|
||||
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<void> softDeleteLot(String lotId) async {
|
||||
final (_, updated) = _stamp();
|
||||
|
|
@ -1487,10 +1617,11 @@ class VarietyRepository {
|
|||
Future<String?> _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 = <String, String?>{};
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<void> configureDependencies() async {
|
|||
..registerSingleton<OnboardingStore>(OnboardingStore(secretStore))
|
||||
..registerSingleton<ExportImportService>(
|
||||
ExportImportService(repository: varietyRepository, files: fileService),
|
||||
)
|
||||
..registerSingleton<ShareCatalogService>(
|
||||
ShareCatalogService(files: fileService),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
|||
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',
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
|||
@override late final _Translations$presentation$es presentation = _Translations$presentation$es._(_root);
|
||||
@override late final _Translations$provenance$es provenance = _Translations$provenance$es._(_root);
|
||||
@override late final _Translations$abundance$es abundance = _Translations$abundance$es._(_root);
|
||||
@override late final _Translations$share$es share = _Translations$share$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);
|
||||
|
|
@ -442,6 +443,27 @@ class _Translations$abundance$es extends Translations$abundance$en {
|
|||
@override String get runningLow => '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',
|
||||
|
|
|
|||
132
apps/app_seeds/lib/services/share_catalog_service.dart
Normal file
132
apps/app_seeds/lib/services/share_catalog_service.dart
Normal file
|
|
@ -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<Uint8List> buildPdf({
|
||||
required String title,
|
||||
required String date,
|
||||
required List<ShareCatalogRow> 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<bool> saveCatalog({
|
||||
required String title,
|
||||
required String date,
|
||||
required String suggestedName,
|
||||
required List<ShareCatalogRow> rows,
|
||||
}) async {
|
||||
final bytes = await buildPdf(title: title, date: date, rows: rows);
|
||||
final path = await _files.saveFile(
|
||||
suggestedName: suggestedName,
|
||||
bytes: bytes,
|
||||
);
|
||||
return path != null;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<VarietyListItem>? items,
|
||||
List<VarietyListItem>? drafts,
|
||||
|
|
@ -89,6 +99,7 @@ class InventoryState extends Equatable {
|
|||
Set<LotType>? 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<InventoryState> {
|
|||
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<InventoryState> {
|
|||
typeFilter: const {},
|
||||
organicOnly: false,
|
||||
needsReproductionOnly: false,
|
||||
sharingOnly: false,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
|
|||
String? originPlace,
|
||||
Abundance? abundance,
|
||||
PreservationFormat? preservationFormat,
|
||||
OfferStatus offerStatus = OfferStatus.private,
|
||||
}) => _repo.addLot(
|
||||
varietyId: varietyId,
|
||||
type: type,
|
||||
|
|
@ -91,6 +92,7 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
|
|||
originPlace: originPlace,
|
||||
abundance: abundance,
|
||||
preservationFormat: preservationFormat,
|
||||
offerStatus: offerStatus,
|
||||
);
|
||||
|
||||
Future<void> updateLot({
|
||||
|
|
@ -104,6 +106,7 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
|
|||
String? originPlace,
|
||||
Abundance? abundance,
|
||||
PreservationFormat? preservationFormat,
|
||||
OfferStatus offerStatus = OfferStatus.private,
|
||||
}) => _repo.updateLot(
|
||||
lotId: lotId,
|
||||
type: type,
|
||||
|
|
@ -115,6 +118,7 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
|
|||
originPlace: originPlace,
|
||||
abundance: abundance,
|
||||
preservationFormat: preservationFormat,
|
||||
offerStatus: offerStatus,
|
||||
);
|
||||
|
||||
Future<void> deleteLot(String lotId) => _repo.softDeleteLot(lotId);
|
||||
|
|
|
|||
|
|
@ -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<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),
|
||||
|
|
@ -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<void> _printCatalog(BuildContext context) async {
|
||||
final t = context.t;
|
||||
final repository = context.read<VarietyRepository>();
|
||||
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<ShareCatalogService>().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 = <String>[
|
||||
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<void> _captureBurst(BuildContext context) async {
|
||||
final t = context.t;
|
||||
final repository = context.read<VarietyRepository>();
|
||||
|
|
@ -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 = <Widget>[
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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<OfferStatus> 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<void> _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<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
|
|
@ -1227,11 +1276,24 @@ Future<void> _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<void> _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<void> _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<void> _showLotSheet(
|
|||
originPlace: originPlace,
|
||||
abundance: selectedAbundance,
|
||||
preservationFormat: selectedPreservation,
|
||||
offerStatus: selectedShare,
|
||||
);
|
||||
} else {
|
||||
cubit.addLot(
|
||||
|
|
@ -1346,6 +1442,7 @@ Future<void> _showLotSheet(
|
|||
originPlace: originPlace,
|
||||
abundance: selectedAbundance,
|
||||
preservationFormat: selectedPreservation,
|
||||
offerStatus: selectedShare,
|
||||
);
|
||||
}
|
||||
Navigator.of(sheetContext).pop();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
104
apps/app_seeds/test/data/shared_catalog_test.dart
Normal file
104
apps/app_seeds/test/data/shared_catalog_test.dart
Normal file
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
66
apps/app_seeds/test/services/share_catalog_service_test.dart
Normal file
66
apps/app_seeds/test/services/share_catalog_service_test.dart
Normal file
|
|
@ -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<String?> saveFile({
|
||||
required String suggestedName,
|
||||
required Uint8List bytes,
|
||||
}) async {
|
||||
saved = bytes;
|
||||
savedName = suggestedName;
|
||||
return '/dev/null/$suggestedName';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List?> pickFileBytes({List<String>? allowedExtensions}) async =>
|
||||
null;
|
||||
}
|
||||
|
||||
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-');
|
||||
});
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
112
apps/app_seeds/test/ui/share_flow_test.dart
Normal file
112
apps/app_seeds/test/ui/share_flow_test.dart
Normal file
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
40
pubspec.lock
40
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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue