feat(inventory): photo-first drafts + on-device OCR (digitization R2+R4)

Lower the bulk-digitization cliff with two more routes on top of the
already-landed CSV import and "save and add another":

- Photo-first drafts (capture now, catalogue later): burst-capture
  photos (camera or multi-gallery) into unnamed draft varieties, shown
  in a "to catalogue" tray, hidden from the main list until named.
  Adds Variety.isDraft (schema), addDraftVariety/watchDrafts/nameDraft,
  the triage sheet and the inventory banner.
- On-device OCR label suggestion (Tesseract, offline, no Google): a
  "Suggest name from photo" button in the naming dialog behind a
  LabelTextExtractor interface (Tesseract on Android/iOS, no-op
  elsewhere). Reads the largest print via hOCR bounding boxes, drops
  boilerplate/low-confidence noise, preprocesses (grayscale, contrast,
  upscale) and sweeps rotations (0-315 deg) so tilted packets still
  read. Bundles tessdata_fast eng+spa; validated on-device against real
  packets. The photo is written to a temp file deleted immediately in a
  finally block (the plugin needs a path) - a bounded, documented
  exception to no-plaintext-at-rest.

This commit also carries the co-developed schema evolution v5 to v8 that
shares these files (organic flag, species viability years, crop
calendar, lot provenance/abundance/preservation format, condition
checks) plus their exports/migrations and i18n.

Tests: CSV/draft/OCR unit + widget + migration green in isolation.
Note: the full widget suite currently hangs (>10 min) - under investigation.
This commit is contained in:
vjrj 2026-07-09 21:23:46 +02:00
parent 12a2ee2d64
commit 6809dc6143
89 changed files with 17141 additions and 228 deletions

View file

@ -7,10 +7,12 @@ import 'package:go_router/go_router.dart';
import 'data/species_repository.dart';
import 'data/variety_repository.dart';
import 'i18n/strings.g.dart';
import 'services/onboarding_store.dart';
import 'state/inventory_cubit.dart';
import 'state/variety_detail_cubit.dart';
import 'ui/about_screen.dart';
import 'ui/home_screen.dart';
import 'ui/intro_screen.dart';
import 'ui/inventory_list_screen.dart';
import 'ui/settings_screen.dart';
import 'ui/theme.dart';
@ -18,18 +20,40 @@ import 'ui/variety_detail_screen.dart';
/// Root widget. Provides the repositories to the tree and wires go_router:
/// `/` is the home menu, `/inventory` the list, `/variety/:id` the item detail.
/// When [showIntro] is set (first launch), the app opens on `/intro`.
class TaneApp extends StatelessWidget {
TaneApp({required this.repository, required this.species, super.key})
: _router = _buildRouter(repository);
TaneApp({
required this.repository,
required this.species,
required this.onboarding,
this.showIntro = false,
super.key,
}) : _router = _buildRouter(repository, onboarding, showIntro);
final VarietyRepository repository;
final SpeciesRepository species;
final OnboardingStore onboarding;
final bool showIntro;
final GoRouter _router;
static GoRouter _buildRouter(VarietyRepository repository) {
static GoRouter _buildRouter(
VarietyRepository repository,
OnboardingStore onboarding,
bool showIntro,
) {
return GoRouter(
initialLocation: showIntro ? '/intro' : '/',
routes: [
GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
GoRoute(
path: '/intro',
builder: (context, state) => IntroScreen(
onDone: () async {
await onboarding.markIntroSeen();
if (context.mounted) context.go('/');
},
),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),

View file

@ -2,6 +2,7 @@ import 'package:commons_core/commons_core.dart' show QuantityKind;
import '../../db/database.dart';
import '../../db/enums.dart';
import '../../domain/crop_calendar.dart';
import 'inventory_snapshot.dart';
/// Flattens an [InventorySnapshot] into a spreadsheet-friendly CSV one row
@ -20,6 +21,12 @@ class InventoryCsvCodec {
'variety_notes',
'vernacular_names',
'external_links',
'needs_reproduction',
'sow_months',
'transplant_months',
'flowering_months',
'fruiting_months',
'seed_harvest_months',
'lot_id',
'lot_type',
'harvest_year',
@ -30,7 +37,13 @@ class InventoryCsvCodec {
'presentation',
'storage_location',
'offer_status',
'origin_name',
'origin_place',
'abundance',
'preservation_format',
'latest_germination_rate',
'container_count',
'desiccant_state',
'created_at',
'updated_at',
];
@ -54,6 +67,7 @@ class InventoryCsvCodec {
.add(e.title == null ? e.url : '${e.title}|${e.url}');
}
final latestRateByLot = _latestGerminationRates(snapshot.germinationTests);
final latestCheckByLot = _latestConditionChecks(snapshot.conditionChecks);
final lines = <String>[_header.map(_escape).join(',')];
for (final v in snapshot.varieties) {
@ -66,13 +80,19 @@ class InventoryCsvCodec {
v.notes ?? '',
(namesByVariety[v.id] ?? const []).join('; '),
(linksByVariety[v.id] ?? const []).join('; '),
v.needsReproduction ? 'true' : '',
_monthsCell(v.sowMonths),
_monthsCell(v.transplantMonths),
_monthsCell(v.floweringMonths),
_monthsCell(v.fruitingMonths),
_monthsCell(v.seedHarvestMonths),
];
final lots = lotsByVariety[v.id] ?? const <Lot>[];
if (lots.isEmpty) {
lines.add(
[
...varietyCells,
...List.filled(11, ''),
...List.filled(17, ''),
_isoDate(v.createdAt),
_hlcMillisIso(v.updatedAt),
].map(_escape).join(','),
@ -80,6 +100,7 @@ class InventoryCsvCodec {
continue;
}
for (final l in lots) {
final check = latestCheckByLot[l.id];
lines.add(
[
...varietyCells,
@ -93,7 +114,13 @@ class InventoryCsvCodec {
l.presentation?.name ?? '',
l.storageLocation ?? '',
l.offerStatus.name,
l.originName ?? '',
l.originPlace ?? '',
l.abundance?.name ?? '',
l.preservationFormat?.name ?? '',
latestRateByLot[l.id]?.toStringAsFixed(2) ?? '',
check?.containerCount?.toString() ?? '',
check?.desiccantState?.name ?? '',
_isoDate(l.createdAt),
_hlcMillisIso(l.updatedAt),
].map(_escape).join(','),
@ -103,6 +130,25 @@ class InventoryCsvCodec {
return '${lines.join('\r\n')}\r\n';
}
/// Renders a crop-calendar month mask as a space-separated list of month
/// numbers (e.g. `3 4 9`), or empty when unset readable in a spreadsheet.
String _monthsCell(int? mask) => maskToMonths(mask).join(' ');
/// lotId most-recent condition check (by checkedOn), so the CSV can show a
/// lot's latest jar count and drying-agent state as flat columns.
Map<String, ConditionCheck> _latestConditionChecks(
List<ConditionCheck> checks,
) {
final latest = <String, ConditionCheck>{};
for (final c in checks) {
final current = latest[c.lotId];
if (current == null || (c.checkedOn ?? 0) > (current.checkedOn ?? 0)) {
latest[c.lotId] = c;
}
}
return latest;
}
/// lotId latest germination rate (0..1), from the most recent test that
/// has a computable rate.
Map<String, double> _latestGerminationRates(List<GerminationTest> tests) {
@ -200,6 +246,12 @@ class InventoryCsvCodec {
notes: cell(row, 'variety_notes'),
vernacularNames: _parseVernacular(cell(row, 'vernacular_names')),
links: _parseLinks(cell(row, 'external_links')),
needsReproduction: _parseBool(cell(row, 'needs_reproduction')),
sowMonths: _parseMonths(cell(row, 'sow_months')),
transplantMonths: _parseMonths(cell(row, 'transplant_months')),
floweringMonths: _parseMonths(cell(row, 'flowering_months')),
fruitingMonths: _parseMonths(cell(row, 'fruiting_months')),
seedHarvestMonths: _parseMonths(cell(row, 'seed_harvest_months')),
);
builders.add(builder);
if (groupId != null) byGroup[groupId] = builder;
@ -225,6 +277,10 @@ class InventoryCsvCodec {
final presentation = cell(row, 'presentation');
final storage = cell(row, 'storage_location');
final offer = cell(row, 'offer_status');
final originName = cell(row, 'origin_name');
final originPlace = cell(row, 'origin_place');
final abundance = cell(row, 'abundance');
final preservationFormat = cell(row, 'preservation_format');
final hasLot = [
typeCell,
@ -236,6 +292,10 @@ class InventoryCsvCodec {
presentation,
storage,
offer,
originName,
originPlace,
abundance,
preservationFormat,
].any((c) => c != null);
if (!hasLot) return null;
@ -249,9 +309,35 @@ class InventoryCsvCodec {
presentation: _enumOrNull(Presentation.values, presentation),
storageLocation: storage,
offerStatus: _enumOr(OfferStatus.values, offer, OfferStatus.private),
originName: originName,
originPlace: originPlace,
abundance: _enumOrNull(Abundance.values, abundance),
preservationFormat: _enumOrNull(
PreservationFormat.values,
preservationFormat,
),
);
}
/// Parses a boolean cell: `true`/`yes`/`1` (case-insensitive) true,
/// anything else (including null) false.
bool _parseBool(String? cell) {
final v = cell?.trim().toLowerCase();
return v == 'true' || v == 'yes' || v == '1';
}
/// Parses a crop-calendar months cell (month numbers separated by spaces,
/// commas or semicolons, e.g. `3 4 9`) into a 12-bit mask, or null when empty.
int? _parseMonths(String? cell) {
if (cell == null) return null;
final months = <int>[];
for (final part in cell.split(RegExp(r'[\s,;]+'))) {
final n = int.tryParse(part.trim());
if (n != null) months.add(n);
}
return monthsToMask(months);
}
/// `name (lang); name2` vernacular names; the trailing `(xx)` is the ISO
/// language, mirroring the export join in [encode].
List<CsvImportName> _parseVernacular(String? cell) {
@ -393,6 +479,12 @@ class CsvImportVariety {
this.vernacularNames = const [],
this.links = const [],
this.lots = const [],
this.needsReproduction = false,
this.sowMonths,
this.transplantMonths,
this.floweringMonths,
this.fruitingMonths,
this.seedHarvestMonths,
});
final String label;
@ -403,6 +495,12 @@ class CsvImportVariety {
final List<CsvImportName> vernacularNames;
final List<CsvImportLink> links;
final List<CsvImportLot> lots;
final bool needsReproduction;
final int? sowMonths;
final int? transplantMonths;
final int? floweringMonths;
final int? fruitingMonths;
final int? seedHarvestMonths;
}
/// One lot parsed from a CSV row.
@ -417,6 +515,10 @@ class CsvImportLot {
this.presentation,
this.storageLocation,
this.offerStatus = OfferStatus.private,
this.originName,
this.originPlace,
this.abundance,
this.preservationFormat,
});
final LotType type;
@ -428,6 +530,10 @@ class CsvImportLot {
final Presentation? presentation;
final String? storageLocation;
final OfferStatus offerStatus;
final String? originName;
final String? originPlace;
final Abundance? abundance;
final PreservationFormat? preservationFormat;
}
/// One vernacular name parsed from CSV.
@ -456,6 +562,12 @@ class _CsvVarietyBuilder {
this.notes,
this.vernacularNames = const [],
this.links = const [],
this.needsReproduction = false,
this.sowMonths,
this.transplantMonths,
this.floweringMonths,
this.fruitingMonths,
this.seedHarvestMonths,
});
final String label;
@ -465,6 +577,12 @@ class _CsvVarietyBuilder {
final String? notes;
final List<CsvImportName> vernacularNames;
final List<CsvImportLink> links;
final bool needsReproduction;
final int? sowMonths;
final int? transplantMonths;
final int? floweringMonths;
final int? fruitingMonths;
final int? seedHarvestMonths;
final List<CsvImportLot> lots = [];
CsvImportVariety build() => CsvImportVariety(
@ -475,6 +593,12 @@ class _CsvVarietyBuilder {
notes: notes,
vernacularNames: vernacularNames,
links: links,
needsReproduction: needsReproduction,
sowMonths: sowMonths,
transplantMonths: transplantMonths,
floweringMonths: floweringMonths,
fruitingMonths: fruitingMonths,
seedHarvestMonths: seedHarvestMonths,
lots: List.unmodifiable(lots),
);
}

View file

@ -42,6 +42,14 @@ class InventoryJsonCodec {
'cultivarName': v.cultivarName,
'category': v.category,
'notes': v.notes,
'isDraft': v.isDraft,
'isOrganic': v.isOrganic,
'needsReproduction': v.needsReproduction,
'sowMonths': v.sowMonths,
'transplantMonths': v.transplantMonths,
'floweringMonths': v.floweringMonths,
'fruitingMonths': v.fruitingMonths,
'seedHarvestMonths': v.seedHarvestMonths,
},
],
'lots': [
@ -65,6 +73,10 @@ class InventoryJsonCodec {
'storageLocation': l.storageLocation,
'offerStatus': l.offerStatus.name,
'seedbankId': l.seedbankId,
'originName': l.originName,
'originPlace': l.originPlace,
'abundance': l.abundance?.name,
'preservationFormat': l.preservationFormat?.name,
},
],
'vernacularNames': [
@ -116,6 +128,23 @@ class InventoryJsonCodec {
'notes': g.notes,
},
],
'conditionChecks': [
for (final c in snapshot.conditionChecks)
{
..._syncMeta(
id: c.id,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
lastAuthor: c.lastAuthor,
schemaRowVersion: c.schemaRowVersion,
),
'lotId': c.lotId,
'checkedOn': c.checkedOn,
'containerCount': c.containerCount,
'desiccantState': c.desiccantState?.name,
'notes': c.notes,
},
],
'movements': [
for (final m in snapshot.movements)
{
@ -214,6 +243,14 @@ class InventoryJsonCodec {
cultivarName: m['cultivarName'] as String?,
category: m['category'] as String?,
notes: m['notes'] as String?,
isDraft: m['isDraft'] == true,
isOrganic: m['isOrganic'] == true,
needsReproduction: m['needsReproduction'] == true,
sowMonths: m['sowMonths'] as int?,
transplantMonths: m['transplantMonths'] as int?,
floweringMonths: m['floweringMonths'] as int?,
fruitingMonths: m['fruitingMonths'] as int?,
seedHarvestMonths: m['seedHarvestMonths'] as int?,
);
});
@ -244,6 +281,13 @@ class InventoryJsonCodec {
OfferStatus.private,
),
seedbankId: m['seedbankId'] as String?,
originName: m['originName'] as String?,
originPlace: m['originPlace'] as String?,
abundance: _enumOrNull(Abundance.values, m['abundance']),
preservationFormat: _enumOrNull(
PreservationFormat.values,
m['preservationFormat'],
),
);
}),
vernacularNames: _rows(root, 'vernacularNames', (m) {
@ -291,6 +335,24 @@ class InventoryJsonCodec {
notes: m['notes'] as String?,
);
}),
conditionChecks: _rows(root, 'conditionChecks', (m) {
return ConditionCheck(
id: _string(m, 'id'),
createdAt: _int(m, 'createdAt'),
updatedAt: _string(m, 'updatedAt'),
lastAuthor: _string(m, 'lastAuthor'),
isDeleted: false,
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
lotId: _string(m, 'lotId'),
checkedOn: m['checkedOn'] as int?,
containerCount: m['containerCount'] as int?,
desiccantState: _enumOrNull(
DesiccantState.values,
m['desiccantState'],
),
notes: m['notes'] as String?,
);
}),
movements: _rows(root, 'movements', (m) {
final type = _enumOrNull(MovementType.values, m['type']);
if (type == null) return null; // no safe default drop row

View file

@ -26,6 +26,7 @@ class InventorySnapshot {
this.vernacularNames = const [],
this.externalLinks = const [],
this.germinationTests = const [],
this.conditionChecks = const [],
this.movements = const [],
this.parties = const [],
this.attachments = const [],
@ -37,6 +38,7 @@ class InventorySnapshot {
final List<VarietyVernacularName> vernacularNames;
final List<ExternalLink> externalLinks;
final List<GerminationTest> germinationTests;
final List<ConditionCheck> conditionChecks;
final List<Movement> movements;
final List<Party> parties;
final List<Attachment> attachments;

View file

@ -19,6 +19,7 @@ List<SpeciesSeed> parseSpeciesCatalog(String jsonString) {
scientificName: e['scientific_name'] as String,
family: e['family'] as String?,
commonNames: common,
viabilityYears: (e['viability_years'] as num?)?.toInt(),
);
}).toList();
}

View file

@ -9,6 +9,7 @@ class SpeciesSeed {
required this.scientificName,
this.family,
this.commonNames = const {},
this.viabilityYears,
});
final String scientificName;
@ -16,6 +17,9 @@ class SpeciesSeed {
/// language code list of common names.
final Map<String, List<String>> commonNames;
/// Typical seed longevity in years (bundled reference data); null if unknown.
final int? viabilityYears;
}
/// A catalog match surfaced to the UI (with a best common name for the locale).
@ -47,7 +51,9 @@ class SpeciesRepository {
final String nodeId;
/// Idempotently inserts bundled species (keyed by scientific name). Safe to
/// call on every startup; existing entries are left untouched.
/// call on every startup. Existing rows keep their identity, but a bundled
/// row still missing [SpeciesSeed.viabilityYears] is backfilled so the
/// reference data reaches installs seeded before the field was bundled.
Future<void> seedBundled(List<SpeciesSeed> seeds) async {
final stamp = Hlc.zero(nodeId).pack();
await _db.transaction(() async {
@ -56,7 +62,19 @@ class SpeciesRepository {
await (_db.select(_db.species)
..where((s) => s.scientificName.equals(seed.scientificName)))
.getSingleOrNull();
if (existing != null) continue;
if (existing != null) {
// Backfill bundled reference data added after the row was seeded.
if (existing.viabilityYears == null && seed.viabilityYears != null) {
await (_db.update(_db.species)
..where((s) => s.id.equals(existing.id)))
.write(
SpeciesCompanion(
viabilityYears: Value(seed.viabilityYears),
),
);
}
continue;
}
final speciesId = idGen.newId();
await _db
@ -70,6 +88,7 @@ class SpeciesRepository {
scientificName: seed.scientificName,
family: Value(seed.family),
isBundled: const Value(true),
viabilityYears: Value(seed.viabilityYears),
),
);

View file

@ -5,6 +5,7 @@ import 'package:equatable/equatable.dart';
import '../db/database.dart';
import '../db/enums.dart';
import '../domain/seed_viability.dart';
import 'export_import/import_reconciler.dart';
import 'export_import/inventory_csv_codec.dart';
import 'export_import/inventory_snapshot.dart';
@ -18,6 +19,10 @@ class VarietyListItem extends Equatable {
this.scientificName,
this.photo,
this.lotTypes = const {},
this.isDraft = false,
this.isOrganic = false,
this.needsReproduction = false,
this.viability = SeedViability.unknown,
});
final String id;
@ -34,6 +39,23 @@ class VarietyListItem extends Equatable {
/// used to filter the list by form. Empty when it has no lots yet.
final Set<LotType> lotTypes;
/// True for a photo-first capture still awaiting a name (in the "to
/// catalogue" tray, hidden from the main inventory list).
final bool isDraft;
/// Grower-declared organic ("eco") provenance badged and filterable.
final bool isOrganic;
/// Grower-flagged "regrow this season" stewardship intent badged and
/// filterable, alongside the automatic viability warning.
final bool needsReproduction;
/// 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
/// or there is no reference figure.
final SeedViability viability;
@override
List<Object?> get props => [
id,
@ -42,6 +64,10 @@ class VarietyListItem extends Equatable {
scientificName,
photo,
lotTypes,
isDraft,
isOrganic,
needsReproduction,
viability,
];
}
@ -73,8 +99,35 @@ class GerminationEntry extends Equatable {
List<Object?> get props => [id, testedOn, sampleSize, germinatedCount, notes];
}
/// One held batch of a variety, for the detail view. [germinationTests] are
/// ordered most-recent first, so `germinationTests.first` is the latest.
/// One storage-condition check on a lot: how many containers held it and the
/// state of the drying agent at a point in time.
class ConditionEntry extends Equatable {
const ConditionEntry({
required this.id,
this.checkedOn,
this.containerCount,
this.desiccantState,
this.notes,
});
final String id;
final int? checkedOn; // ms since epoch
final int? containerCount;
final DesiccantState? desiccantState;
final String? notes;
@override
List<Object?> get props => [
id,
checkedOn,
containerCount,
desiccantState,
notes,
];
}
/// One held batch of a variety, for the detail view. [germinationTests] and
/// [conditionChecks] are ordered most-recent first, so `.first` is the latest.
class VarietyLot extends Equatable {
const VarietyLot({
required this.id,
@ -84,7 +137,12 @@ class VarietyLot extends Equatable {
this.quantity,
this.presentation,
this.storageLocation,
this.originName,
this.originPlace,
this.abundance,
this.preservationFormat,
this.germinationTests = const [],
this.conditionChecks = const [],
});
final String id;
@ -98,8 +156,23 @@ class VarietyLot extends Equatable {
/// How living material is packaged (pot, tray, bare-root); null for seeds.
final Presentation? presentation;
final String? storageLocation;
/// Provenance of this batch: who grew/gave it ([originName]) and where it
/// comes from ([originPlace], with region/province). Optional free text.
final String? originName;
final String? originPlace;
/// Optional coarse "how much I have" fusing amount with shareability.
final Abundance? abundance;
/// How the (seed) lot is physically conserved; distinct from storage location.
final PreservationFormat? preservationFormat;
final List<GerminationEntry> germinationTests;
/// Storage-condition checks, most-recent first (`conditionChecks.first` = last).
final List<ConditionEntry> conditionChecks;
/// The most recent germination rate (0..1), or null if there are no tests.
double? get latestGerminationRate =>
germinationTests.isEmpty ? null : germinationTests.first.rate;
@ -113,7 +186,12 @@ class VarietyLot extends Equatable {
quantity,
presentation,
storageLocation,
originName,
originPlace,
abundance,
preservationFormat,
germinationTests,
conditionChecks,
];
}
@ -171,6 +249,14 @@ class VarietyDetail extends Equatable {
this.notes,
this.speciesId,
this.scientificName,
this.viabilityYears,
this.isOrganic = false,
this.needsReproduction = false,
this.sowMonths,
this.transplantMonths,
this.floweringMonths,
this.fruitingMonths,
this.seedHarvestMonths,
this.lots = const [],
this.vernacularNames = const [],
this.photos = const [],
@ -183,6 +269,35 @@ class VarietyDetail extends Equatable {
final String? notes;
final String? speciesId;
final String? scientificName;
/// Grower-declared organic ("eco") provenance.
final bool isOrganic;
/// Grower-flagged "regrow this season" stewardship intent.
final bool needsReproduction;
/// Advisory crop-calendar months, packed as 12-bit masks (a phase can span
/// several months); null where unrecorded. See [Varieties.sowMonths] et al.
/// and `domain/crop_calendar.dart` for packing.
final int? sowMonths;
final int? transplantMonths;
final int? floweringMonths;
final int? fruitingMonths;
final int? seedHarvestMonths;
/// True when any crop-calendar phase is recorded so the UI shows the
/// calendar section only when there is something in it.
bool get hasCropCalendar =>
sowMonths != null ||
transplantMonths != null ||
floweringMonths != null ||
fruitingMonths != null ||
seedHarvestMonths != null;
/// Typical seed longevity (years) of the linked species, from the bundled
/// catalog; null when no species is linked or the figure is unknown. Drives
/// the per-lot viability warning together with each lot's harvest year.
final int? viabilityYears;
final List<VarietyLot> lots;
final List<VernacularName> vernacularNames;
final List<VarietyPhoto> photos;
@ -196,6 +311,14 @@ class VarietyDetail extends Equatable {
notes,
speciesId,
scientificName,
viabilityYears,
isOrganic,
needsReproduction,
sowMonths,
transplantMonths,
floweringMonths,
fruitingMonths,
seedHarvestMonths,
lots,
vernacularNames,
photos,
@ -245,18 +368,51 @@ class VarietyRepository {
Future<List<VarietyListItem>> _loadInventory() async {
final rows =
await (_db.select(_db.varieties)
..where((v) => v.isDeleted.equals(false))
// Drafts live in the "to catalogue" tray, not the main list.
..where((v) => v.isDeleted.equals(false) & v.isDraft.equals(false))
..orderBy([
(v) => OrderingTerm(expression: v.category),
(v) => OrderingTerm(expression: v.label),
]))
.get();
return _toListItems(rows);
}
/// Reactively watches the photo-first drafts awaiting a name, newest first
/// the "to catalogue" tray. Re-emits when a draft is added, named or its
/// 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.attachments).watch().map((_) {}),
]);
return triggers.asyncMap((_) => _loadDrafts());
}
Future<List<VarietyListItem>> _loadDrafts() async {
final rows =
await (_db.select(_db.varieties)
..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)]))
.get();
return _toListItems(rows);
}
Future<List<VarietyListItem>> _toListItems(List<Variety> rows) async {
final varietyIds = rows.map((v) => v.id).toList();
final speciesIds = rows.map((v) => v.speciesId).whereType<String>().toSet();
final photos = await _firstPhotosFor(varietyIds);
final sciNames = await _scientificNamesFor(
rows.map((v) => v.speciesId).whereType<String>().toSet(),
);
final sciNames = await _scientificNamesFor(speciesIds);
final speciesViability = await _speciesViabilityFor(speciesIds);
final lotTypes = await _lotTypesFor(varietyIds);
final seedYears = await _seedHarvestYearsFor(varietyIds);
final currentYear = DateTime.fromMillisecondsSinceEpoch(_now()).year;
return rows
.map(
(v) => VarietyListItem(
@ -266,11 +422,82 @@ class VarietyRepository {
scientificName: v.speciesId == null ? null : sciNames[v.speciesId],
photo: photos[v.id],
lotTypes: lotTypes[v.id] ?? const {},
isDraft: v.isDraft,
isOrganic: v.isOrganic,
needsReproduction: v.needsReproduction,
viability: _worstViability(
harvestYears: seedYears[v.id] ?? const [],
viabilityYears: v.speciesId == null
? null
: speciesViability[v.speciesId],
currentYear: currentYear,
),
),
)
.toList();
}
/// Maps each of [speciesIds] to its bundled viability figure (years); species
/// without a figure are simply absent from the map.
Future<Map<String, int>> _speciesViabilityFor(Set<String> speciesIds) async {
if (speciesIds.isEmpty) return const {};
final rows = await (_db.select(
_db.species,
)..where((s) => s.id.isIn(speciesIds))).get();
return {
for (final s in rows)
if (s.viabilityYears != null) s.id: s.viabilityYears!,
};
}
/// Harvest years of each variety's non-deleted seed lots (living lots don't
/// age like dry seed). Lots without a year are omitted they can't be judged.
Future<Map<String, List<int>>> _seedHarvestYearsFor(
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.type.equalsValue(LotType.seed),
))
.get();
final byVariety = <String, List<int>>{};
for (final row in rows) {
final year = row.harvestYear;
if (year != null) (byVariety[row.varietyId] ??= <int>[]).add(year);
}
return byVariety;
}
/// The most-urgent viability across a variety's seed lots (expired outranks
/// expiring-soon), so the list surfaces the worst case at a glance.
SeedViability _worstViability({
required List<int> harvestYears,
required int? viabilityYears,
required int currentYear,
}) {
var worst = SeedViability.unknown;
for (final year in harvestYears) {
final status = seedViability(
harvestYear: year,
viabilityYears: viabilityYears,
currentYear: currentYear,
);
if (_viabilityRank(status) > _viabilityRank(worst)) worst = status;
}
return worst;
}
int _viabilityRank(SeedViability status) => switch (status) {
SeedViability.unknown => 0,
SeedViability.fresh => 1,
SeedViability.expiringSoon => 2,
SeedViability.expired => 3,
};
/// Maps each variety to the distinct lot forms it currently holds (one
/// query). Varieties without lots are simply absent from the map.
Future<Map<String, Set<LotType>>> _lotTypesFor(
@ -394,6 +621,59 @@ class VarietyRepository {
return varietyId;
}
/// Captures a photo-first draft: a Variety with no name yet ([label] empty),
/// flagged [isDraft], plus its photo. It lands in the "to catalogue" tray
/// ([watchDrafts]) until the user names it with [nameDraft]. Returns the id.
Future<String> addDraftVariety(Uint8List photoBytes) async {
final varietyId = idGen.newId();
await _db.transaction(() async {
final (created, updated) = _stamp();
await _db
.into(_db.varieties)
.insert(
VarietiesCompanion.insert(
id: varietyId,
label: '',
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
isDraft: const Value(true),
),
);
final (createdA, updatedA) = _stamp();
await _db
.into(_db.attachments)
.insert(
AttachmentsCompanion.insert(
id: idGen.newId(),
createdAt: createdA,
updatedAt: updatedA,
lastAuthor: nodeId,
parentType: ParentType.variety,
parentId: varietyId,
kind: AttachmentKind.photo,
bytes: Value(photoBytes),
mimeType: const Value('image/jpeg'),
),
);
});
return varietyId;
}
/// Names a draft and promotes it out of the "to catalogue" tray: sets its
/// [label] and clears [isDraft] in one LWW write.
Future<void> nameDraft(String id, String label) async {
final (_, updated) = _stamp();
await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
VarietiesCompanion(
label: Value(label),
isDraft: const Value(false),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
);
}
/// Reactively watches one variety with its lots, vernacular names and first
/// photo. Emits `null` if the variety does not exist or is soft-deleted.
///
@ -419,6 +699,8 @@ class VarietyRepository {
)..where((e) => e.parentId.equals(id))).watch().map((_) {}),
// Coarse: any germination-test change re-emits (catalog of tests is tiny).
_db.select(_db.germinationTests).watch().map((_) {}),
// Same for condition checks (jars + drying-agent state).
_db.select(_db.conditionChecks).watch().map((_) {}),
]);
return triggers.asyncMap((_) => _loadVariety(id));
}
@ -431,11 +713,13 @@ class VarietyRepository {
if (v == null) return null;
String? scientificName;
int? viabilityYears;
if (v.speciesId != null) {
final species = await (_db.select(
_db.species,
)..where((s) => s.id.equals(v.speciesId!))).getSingleOrNull();
scientificName = species?.scientificName;
viabilityYears = species?.viabilityYears;
}
final lots =
@ -446,6 +730,7 @@ class VarietyRepository {
final lotIds = lots.map((l) => l.id).toList();
final testsByLot = <String, List<GerminationEntry>>{};
final checksByLot = <String, List<ConditionEntry>>{};
if (lotIds.isNotEmpty) {
final tests =
await (_db.select(_db.germinationTests)
@ -465,6 +750,24 @@ class VarietyRepository {
),
);
}
final checks =
await (_db.select(_db.conditionChecks)
..where((c) => c.lotId.isIn(lotIds) & c.isDeleted.equals(false))
..orderBy([(c) => OrderingTerm.desc(c.checkedOn)]))
.get();
for (final c in checks) {
checksByLot
.putIfAbsent(c.lotId, () => [])
.add(
ConditionEntry(
id: c.id,
checkedOn: c.checkedOn,
containerCount: c.containerCount,
desiccantState: c.desiccantState,
notes: c.notes,
),
);
}
}
final names = await (_db.select(
@ -503,7 +806,23 @@ class VarietyRepository {
notes: v.notes,
speciesId: v.speciesId,
scientificName: scientificName,
lots: lots.map((l) => _toLot(l, testsByLot[l.id] ?? const [])).toList(),
viabilityYears: viabilityYears,
isOrganic: v.isOrganic,
needsReproduction: v.needsReproduction,
sowMonths: v.sowMonths,
transplantMonths: v.transplantMonths,
floweringMonths: v.floweringMonths,
fruitingMonths: v.fruitingMonths,
seedHarvestMonths: v.seedHarvestMonths,
lots: lots
.map(
(l) => _toLot(
l,
testsByLot[l.id] ?? const [],
checksByLot[l.id] ?? const [],
),
)
.toList(),
vernacularNames: names
.map(
(n) => VernacularName(
@ -552,12 +871,21 @@ class VarietyRepository {
}
/// Updates a variety's scalar fields (LWW). Passing null clears [category]
/// and [notes]; a null [label] leaves the label unchanged.
/// and [notes]; a null [label] leaves the label unchanged. Boolean flags and
/// the crop-calendar months are only touched when a wrapping [Value] is
/// passed, so callers can update one field without disturbing the rest.
Future<void> updateVariety({
required String id,
String? label,
String? category,
String? notes,
bool? isOrganic,
bool? needsReproduction,
Value<int?> sowMonths = const Value.absent(),
Value<int?> transplantMonths = const Value.absent(),
Value<int?> floweringMonths = const Value.absent(),
Value<int?> fruitingMonths = const Value.absent(),
Value<int?> seedHarvestMonths = const Value.absent(),
}) async {
final (_, updated) = _stamp();
await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
@ -565,6 +893,15 @@ class VarietyRepository {
label: label == null ? const Value.absent() : Value(label),
category: Value(category),
notes: Value(notes),
isOrganic: isOrganic == null ? const Value.absent() : Value(isOrganic),
needsReproduction: needsReproduction == null
? const Value.absent()
: Value(needsReproduction),
sowMonths: sowMonths,
transplantMonths: transplantMonths,
floweringMonths: floweringMonths,
fruitingMonths: fruitingMonths,
seedHarvestMonths: seedHarvestMonths,
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
@ -580,6 +917,10 @@ class VarietyRepository {
Quantity? quantity,
Presentation? presentation,
String? storageLocation,
String? originName,
String? originPlace,
Abundance? abundance,
PreservationFormat? preservationFormat,
}) async {
final (created, updated) = _stamp();
final id = idGen.newId();
@ -600,13 +941,17 @@ class VarietyRepository {
quantityLabel: Value(quantity?.label),
presentation: Value(presentation),
storageLocation: Value(storageLocation),
originName: Value(originName),
originPlace: Value(originPlace),
abundance: Value(abundance),
preservationFormat: Value(preservationFormat),
),
);
return id;
}
/// Updates an existing lot's fields (LWW). The [quantity], [harvestYear] and
/// [harvestMonth] are set as given (null clears them).
/// Updates an existing lot's fields (LWW). Every field is set as given (null
/// clears it), matching the "save the whole lot form" call from the UI.
Future<void> updateLot({
required String lotId,
required LotType type,
@ -615,6 +960,10 @@ class VarietyRepository {
Quantity? quantity,
Presentation? presentation,
String? storageLocation,
String? originName,
String? originPlace,
Abundance? abundance,
PreservationFormat? preservationFormat,
}) async {
final (_, updated) = _stamp();
await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write(
@ -627,6 +976,10 @@ class VarietyRepository {
quantityLabel: Value(quantity?.label),
presentation: Value(presentation),
storageLocation: Value(storageLocation),
originName: Value(originName),
originPlace: Value(originPlace),
abundance: Value(abundance),
preservationFormat: Value(preservationFormat),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
@ -829,6 +1182,35 @@ class VarietyRepository {
return id;
}
/// Records a storage-condition check for a lot (container count + drying-agent
/// state). Returns the new check id.
Future<String> addConditionCheck({
required String lotId,
int? checkedOn,
int? containerCount,
DesiccantState? desiccantState,
String? notes,
}) async {
final (created, updated) = _stamp();
final id = idGen.newId();
await _db
.into(_db.conditionChecks)
.insert(
ConditionChecksCompanion.insert(
id: id,
lotId: lotId,
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
checkedOn: Value(checkedOn),
containerCount: Value(containerCount),
desiccantState: Value(desiccantState),
notes: Value(notes),
),
);
return id;
}
/// Snapshots the live inventory (tombstones excluded) for the interchange
/// export data-model §7. Includes photo bytes; the JSON codec embeds them
/// as base64 and the CSV codec ignores them.
@ -854,6 +1236,9 @@ class VarietyRepository {
germinationTests: await (_db.select(
_db.germinationTests,
)..where((g) => g.isDeleted.equals(false))).get(),
conditionChecks: await (_db.select(
_db.conditionChecks,
)..where((c) => c.isDeleted.equals(false))).get(),
movements: await _db.select(_db.movements).get(),
parties: await (_db.select(
_db.parties,
@ -928,6 +1313,14 @@ class VarietyRepository {
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.conditionChecks,
idColumn: _db.conditionChecks.id,
rows: snapshot.conditionChecks,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows(
table: _db.parties,
idColumn: _db.parties.id,
@ -953,6 +1346,7 @@ class VarietyRepository {
for (final n in snapshot.vernacularNames) n.updatedAt,
for (final e in snapshot.externalLinks) e.updatedAt,
for (final g in snapshot.germinationTests) g.updatedAt,
for (final c in snapshot.conditionChecks) c.updatedAt,
for (final p in snapshot.parties) p.updatedAt,
for (final a in snapshot.attachments) a.updatedAt,
]);
@ -990,6 +1384,12 @@ class VarietyRepository {
cultivarName: Value(v.cultivarName),
notes: Value(v.notes),
speciesId: Value(speciesId),
needsReproduction: Value(v.needsReproduction),
sowMonths: Value(v.sowMonths),
transplantMonths: Value(v.transplantMonths),
floweringMonths: Value(v.floweringMonths),
fruitingMonths: Value(v.fruitingMonths),
seedHarvestMonths: Value(v.seedHarvestMonths),
),
);
inserted++;
@ -1014,6 +1414,10 @@ class VarietyRepository {
presentation: Value(lot.presentation),
storageLocation: Value(lot.storageLocation),
offerStatus: Value(lot.offerStatus),
originName: Value(lot.originName),
originPlace: Value(lot.originPlace),
abundance: Value(lot.abundance),
preservationFormat: Value(lot.preservationFormat),
),
);
}
@ -1153,7 +1557,11 @@ class VarietyRepository {
return ImportSummary(inserted: inserted, skipped: skipped);
}
VarietyLot _toLot(Lot l, List<GerminationEntry> germinationTests) {
VarietyLot _toLot(
Lot l,
List<GerminationEntry> germinationTests,
List<ConditionEntry> conditionChecks,
) {
final hasQuantity =
l.quantityKind != null ||
l.quantityPrecise != null ||
@ -1165,7 +1573,12 @@ class VarietyRepository {
harvestMonth: l.harvestMonth,
presentation: l.presentation,
storageLocation: l.storageLocation,
originName: l.originName,
originPlace: l.originPlace,
abundance: l.abundance,
preservationFormat: l.preservationFormat,
germinationTests: germinationTests,
conditionChecks: conditionChecks,
quantity: hasQuantity
? Quantity(
kind: _parseKind(l.quantityKind),

View file

@ -17,6 +17,7 @@ part 'database.g.dart';
SpeciesCommonNames,
Lots,
GerminationTests,
ConditionChecks,
Movements,
Parties,
Attachments,
@ -28,7 +29,7 @@ class AppDatabase extends _$AppDatabase {
/// Current schema version; also stamped into interchange exports so an
/// importer knows which app generation wrote the file (data-model §7).
static const int currentSchemaVersion = 5;
static const int currentSchemaVersion = 8;
@override
int get schemaVersion => currentSchemaVersion;
@ -53,6 +54,110 @@ class AppDatabase extends _$AppDatabase {
if (from < 5) {
await m.addColumn(attachments, attachments.sortOrder);
}
// v6: photo-first draft varieties awaiting a name ("to catalogue" tray).
if (from < 6) {
await m.addColumn(varieties, varieties.isDraft);
}
// v7: organic ("eco") self-declaration on varieties; per-species seed
// viability (years) reference data for expiry warnings. Guarded by a
// column-existence check so a dev database left half-migrated (column
// added but user_version not bumped) re-runs cleanly instead of failing
// on "duplicate column".
if (from < 7) {
if (!await _hasColumn('varieties', 'is_organic')) {
await m.addColumn(varieties, varieties.isOrganic);
}
if (!await _hasColumn('species', 'viability_years')) {
await m.addColumn(species, species.viabilityYears);
}
}
// v8: absorbs fields from a real seed-bank inventory. Varieties gain a
// "needs reproducing" intent and an advisory crop calendar; Lots gain
// provenance (origin name/place), a qualitative abundance level and a
// preservation format; a new ConditionChecks table logs container count +
// drying-agent state. Every step is guarded so a half-migrated dev
// database re-runs cleanly (see the v7 note above).
if (from < 8) {
Future<void> addIfMissing(
String table,
String column,
GeneratedColumn<Object> definition,
TableInfo<Table, dynamic> tableInfo,
) async {
if (!await _hasColumn(table, column)) {
await m.addColumn(tableInfo, definition);
}
}
await addIfMissing(
'varieties',
'needs_reproduction',
varieties.needsReproduction,
varieties,
);
await addIfMissing(
'varieties',
'sow_months',
varieties.sowMonths,
varieties,
);
await addIfMissing(
'varieties',
'transplant_months',
varieties.transplantMonths,
varieties,
);
await addIfMissing(
'varieties',
'flowering_months',
varieties.floweringMonths,
varieties,
);
await addIfMissing(
'varieties',
'fruiting_months',
varieties.fruitingMonths,
varieties,
);
await addIfMissing(
'varieties',
'seed_harvest_months',
varieties.seedHarvestMonths,
varieties,
);
await addIfMissing('lots', 'origin_name', lots.originName, lots);
await addIfMissing('lots', 'origin_place', lots.originPlace, lots);
await addIfMissing('lots', 'abundance', lots.abundance, lots);
await addIfMissing(
'lots',
'preservation_format',
lots.preservationFormat,
lots,
);
if (!await _hasTable('condition_checks')) {
await m.createTable(conditionChecks);
}
}
},
);
/// Whether a table named [table] already exists. Keeps the additive v8
/// table creation idempotent against partially-migrated databases.
Future<bool> _hasTable(String table) async {
final rows = await customSelect(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
variables: [Variable.withString(table)],
).get();
return rows.isNotEmpty;
}
/// Whether [table] already has a column named [column]. Used to keep additive
/// migrations idempotent against partially-migrated databases.
Future<bool> _hasColumn(String table, String column) async {
final rows = await customSelect(
'SELECT 1 FROM pragma_table_info(?) WHERE name = ?',
variables: [Variable.withString(table), Variable.withString(column)],
).get();
return rows.isNotEmpty;
}
}

File diff suppressed because it is too large Load diff

View file

@ -27,6 +27,46 @@ enum Presentation { pot, tray, plug, bareRoot, rootBall }
/// Per-lot visibility (data-model §2.3). Used by the future sharing layer.
enum OfferStatus { private, shared, exchange, sell }
/// A coarse, offline-friendly "how much do I have" that fuses amount with
/// shareability the way a seed saver actually thinks, without weighing or
/// counting. Modelled on the qualitative scale a real seed bank used
/// (mucha/bastante/suficiente/poca), where each level is defined by what you
/// can do with it. Optional alternative to a precise Quantity. Append-only by
/// name.
///
/// - `plentyToShare` surplus, free to give away (mucha).
/// - `enoughToShare` can share a little, sparingly (bastante).
/// - `enoughForMe` enough for my own use, not to share (suficiente).
/// - `runningLow` only enough to keep the variety alive (poca).
enum Abundance { plentyToShare, enoughToShare, enoughForMe, runningLow }
/// How dry seed is physically conserved affects longevity, kept separate from
/// *where* it is stored. Optional attribute of a seed [Lot]. Append-only by name.
///
/// - `jarWithDesiccant` sealed jar with a drying agent (bote con sílice).
/// - `glassJar` plain glass jar (bote de cristal).
/// - `paperEnvelope` paper envelope (sobre de papel).
/// - `paperBag` paper bag (bolsa de papel).
/// - `plasticBag` plastic bag (bolsa de plástico).
enum PreservationFormat {
jarWithDesiccant,
glassJar,
paperEnvelope,
paperBag,
plasticBag,
}
/// State of the drying agent (silica gel) in a stored seed container, recorded
/// at a periodic condition check. The colour is a proxy for moisture: a
/// saturated agent means the seed is at risk. Append-only by name.
///
/// - `none` no drying agent present (no tiene).
/// - `add` should add some (se pone).
/// - `replace` saturated, replace it (se cambia).
/// - `dry` indicating dry (azul/blue).
/// - `fresh` just renewed (lila/violet).
enum DesiccantState { none, add, replace, dry, fresh }
/// Append-only event kinds on a Lot (data-model §2.4).
enum MovementType {
received,

View file

@ -12,6 +12,35 @@ class Varieties extends Table with SyncColumns {
TextColumn get category =>
text().nullable()(); // free text, prefilled from family
TextColumn get notes => text().nullable()();
/// A draft captured photo-first ("capture now, catalogue later"): it holds a
/// photo but not yet a real name, and lives in the "to catalogue" tray until
/// the user labels it. A plain LWW scalar, so it merges like any other field.
BoolColumn get isDraft => boolean().withDefault(const Constant(false))();
/// Grower-declared organic ("eco") provenance. A self-declaration, not a
/// third-party certification (that would be a separate flag). Surfaced as a
/// badge and an inventory filter. A plain LWW scalar.
BoolColumn get isOrganic => boolean().withDefault(const Constant(false))();
/// A stewardship intent set by the grower: "regrow this variety this season"
/// before its stock or vitality runs out. Complements the automatic viability
/// warning (which is age-derived) with an explicit human decision. Surfaced
/// as a badge and an inventory filter. A plain LWW scalar.
BoolColumn get needsReproduction =>
boolean().withDefault(const Constant(false))();
/// Advisory crop-calendar months, typical for this variety when to sow,
/// transplant, expect flowers/fruit, and harvest seed. Each phase usually
/// spans several months (e.g. sow in spring *and* autumn), so each is a set
/// of months packed as a 12-bit mask (see `domain/crop_calendar.dart`), an
/// optional LWW scalar; null means "not recorded". Guidance, not a per-year
/// actuals log (that path stays available via Movements).
IntColumn get sowMonths => integer().nullable()();
IntColumn get transplantMonths => integer().nullable()();
IntColumn get floweringMonths => integer().nullable()();
IntColumn get fruitingMonths => integer().nullable()();
IntColumn get seedHarvestMonths => integer().nullable()();
}
/// The multiple common names of a Variety (separate table so concurrent adds
@ -30,6 +59,13 @@ class Species extends Table with SyncColumns {
IntColumn get gbifKey => integer().nullable()();
TextColumn get family => text().nullable()();
BoolColumn get isBundled => boolean().withDefault(const Constant(false))();
/// Typical seed longevity in years under normal home storage public-domain
/// reference data bundled with the catalog (agricultural-extension viability
/// tables). Drives the "expiring / past viability" warning on aging lots by
/// comparing against a lot's [Lots.harvestYear]. Nullable: unknown for
/// species without a bundled figure.
IntColumn get viabilityYears => integer().nullable()();
}
/// Localized common names for the catalog (bundled).
@ -56,6 +92,23 @@ class Lots extends Table with SyncColumns {
TextColumn get offerStatus =>
textEnum<OfferStatus>().withDefault(const Constant('private'))();
TextColumn get seedbankId => text().nullable()();
/// Provenance of this batch, kept as lightweight free text so it needs no
/// Party/Movement to record (the rigorous exchange path stays via Movements).
/// [originName] = who grew or gave the seeds; [originPlace] = where they come
/// from (with region/province). Both optional LWW scalars.
TextColumn get originName => text().nullable()();
TextColumn get originPlace => text().nullable()();
/// Optional coarse "how much I have" for this lot see [Abundance]. An
/// offline alternative to the precise Quantity columns; either, both, or
/// neither may be set.
TextColumn get abundance => textEnum<Abundance>().nullable()();
/// How the (seed) lot is physically conserved see [PreservationFormat].
/// Distinct from [storageLocation]. Optional.
TextColumn get preservationFormat =>
textEnum<PreservationFormat>().nullable()();
}
/// Optional germination history for a Lot; percent is derived in code.
@ -67,6 +120,17 @@ class GerminationTests extends Table with SyncColumns {
TextColumn get notes => text().nullable()();
}
/// Optional storage-condition history for a seed Lot: periodic physical checks
/// of how many containers hold it and the state of the drying agent. Mirrors
/// [GerminationTests] a dated log under a Lot, newest shown first.
class ConditionChecks extends Table with SyncColumns {
TextColumn get lotId => text()();
IntColumn get checkedOn => integer().nullable()(); // date, ms since epoch
IntColumn get containerCount => integer().nullable()(); // "botes"
TextColumn get desiccantState => textEnum<DesiccantState>().nullable()();
TextColumn get notes => text().nullable()();
}
/// The append-only event log on a Lot history + provenance DAG.
class Movements extends Table with AppendOnlyColumns {
TextColumn get lotId => text()();

View file

@ -1,6 +1,8 @@
import 'dart:io';
import 'package:commons_core/commons_core.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter/widgets.dart';
import 'package:get_it/get_it.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
@ -10,11 +12,16 @@ import '../data/species_repository.dart';
import '../data/variety_repository.dart';
import '../db/database.dart';
import '../db/encrypted_executor.dart';
import '../i18n/strings.g.dart';
import '../security/secret_store.dart';
import '../security/secure_key_store.dart';
import '../services/export_import_service.dart';
import '../services/file_picker_file_service.dart';
import '../services/file_service.dart';
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';
/// 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
@ -24,7 +31,8 @@ final GetIt getIt = GetIt.instance;
/// Wires the encrypted DB, keystore and repositories. Call once from `main`
/// before `runApp`; the DB key must exist before the DB opens.
Future<void> configureDependencies() async {
final keyStore = SecureKeyStore(store: FlutterSecretStore());
final secretStore = FlutterSecretStore();
final keyStore = SecureKeyStore(store: secretStore);
final dbKeyHex = await keyStore.databaseKeyHex();
final rootSeedHex = await keyStore.rootSeedHex();
@ -48,12 +56,23 @@ Future<void> configureDependencies() async {
const fileService = FilePickerFileService();
// OCR label suggestions only where a native Tesseract engine exists; every
// other platform (desktop, web) degrades to the no-op and hides the button.
// The language pack(s) follow the user's locale(s) — never a fixed region —
// limited to what we actually bundle, always unioned with the Latin baseline.
final LabelTextExtractor labelExtractor =
(Platform.isAndroid || Platform.isIOS)
? TesseractLabelExtractor(language: await _resolveOcrLanguage())
: const NoOpLabelTextExtractor();
getIt
..registerSingleton<SecureKeyStore>(keyStore)
..registerSingleton<AppDatabase>(database)
..registerSingleton<SpeciesRepository>(speciesRepository)
..registerSingleton<VarietyRepository>(varietyRepository)
..registerSingleton<FileService>(fileService)
..registerSingleton<LabelTextExtractor>(labelExtractor)
..registerSingleton<OnboardingStore>(OnboardingStore(secretStore))
..registerSingleton<ExportImportService>(
ExportImportService(repository: varietyRepository, files: fileService),
);
@ -63,3 +82,18 @@ Future<File> _databaseFile() async {
final dir = await getApplicationDocumentsDirectory();
return File(p.join(dir.path, 'tane_inventory.sqlite'));
}
/// Picks the Tesseract language pack(s) for the current locale(s), limited to
/// the bundled packs listed in `tessdata_config.json`. Prefers the app's chosen
/// language, then the device's ordered locales.
Future<String> _resolveOcrLanguage() async {
final available = parseAvailableOcrLanguages(
await rootBundle.loadString('assets/tessdata_config.json'),
);
final preferred = <String>[
LocaleSettings.currentLocale.languageCode,
for (final l in WidgetsBinding.instance.platformDispatcher.locales)
l.languageCode,
];
return resolveOcrLanguages(preferredLocales: preferred, available: available);
}

View file

@ -0,0 +1,39 @@
/// Crop-calendar months are stored compactly as a 12-bit mask in a single
/// nullable integer column (bit 0 = January bit 11 = December). A phase can
/// happen in several months (e.g. sow lettuce in March, April and September),
/// so the calendar is a *set* of months, not one. `null` or `0` means "not
/// recorded". Pure functions — no I/O — so they are trivially testable.
library;
/// Packs a set of 1..12 month numbers into a bitmask, or null when empty (so an
/// unset phase stays null in the DB rather than 0). Values outside 1..12 are
/// ignored.
int? monthsToMask(Iterable<int> months) {
var mask = 0;
for (final m in months) {
if (m >= 1 && m <= 12) mask |= 1 << (m - 1);
}
return mask == 0 ? null : mask;
}
/// Unpacks a month bitmask into an ascending list of 1..12 month numbers.
/// Returns an empty list for null or 0.
List<int> maskToMonths(int? mask) {
if (mask == null || mask == 0) return const [];
return [
for (var m = 1; m <= 12; m++)
if (mask & (1 << (m - 1)) != 0) m,
];
}
/// Whether month [month] (1..12) is set in [mask].
bool maskHasMonth(int? mask, int month) =>
mask != null && month >= 1 && month <= 12 && mask & (1 << (month - 1)) != 0;
/// Toggles month [month] (1..12) in [mask], returning the new mask (null when
/// the result is empty).
int? toggleMonth(int? mask, int month) {
if (month < 1 || month > 12) return mask;
final next = (mask ?? 0) ^ (1 << (month - 1));
return next == 0 ? null : next;
}

View file

@ -0,0 +1,41 @@
/// Viability status of a seed lot, derived purely from its age versus the
/// species' typical seed longevity (bundled reference data). Turns passive
/// storage into active stewardship: it surfaces which lots to sow or regenerate
/// before they lapse, instead of just accumulating them.
enum SeedViability {
/// Comfortably within the species' typical viability window.
fresh,
/// In the final year of the window sow or reproduce this season.
expiringSoon,
/// Past the typical viability window germination is likely dropping fast.
expired,
/// Not enough data to judge (no harvest year, or no reference figure).
unknown,
}
/// Computes the viability status of a seed lot harvested in [harvestYear] for a
/// species whose typical longevity is [viabilityYears], as of [currentYear].
///
/// A conservative, age-based signal: the bundled figure is a single number, not
/// a decay curve, so this only distinguishes "fine / use soon / past it".
/// [expiringSoon] flags the last year of the window so the grower can prioritise
/// what to reproduce before it lapses. Any actual [GerminationTest] a grower
/// records remains the ground truth and is shown alongside this estimate.
SeedViability seedViability({
required int? harvestYear,
required int? viabilityYears,
required int currentYear,
}) {
if (harvestYear == null || viabilityYears == null || viabilityYears <= 0) {
return SeedViability.unknown;
}
final age = currentYear - harvestYear;
// A harvest stamped in the future is treated as fresh, not expired.
if (age < 0) return SeedViability.fresh;
if (age >= viabilityYears) return SeedViability.expired;
if (age >= viabilityYears - 1) return SeedViability.expiringSoon;
return SeedViability.fresh;
}

View file

@ -44,24 +44,24 @@
"aboutOpen": "About Tanemaki"
},
"backup": {
"title": "Backup",
"exportCsv": "Export as CSV",
"exportCsvSubtitle": "For spreadsheets — no photos",
"exportJson": "Export as JSON",
"exportJsonSubtitle": "Complete copy, can be imported back",
"importJson": "Import from JSON",
"importJsonSubtitle": "Merge a saved copy into this inventory",
"importCsv": "Import from CSV",
"importCsvSubtitle": "Add a list from a spreadsheet",
"importConfirmTitle": "Import a saved copy?",
"title": "Backup & restore",
"exportJson": "Save a backup",
"exportJsonSubtitle": "A complete copy to keep safe, restore later or move to another device",
"importJson": "Restore a backup",
"importJsonSubtitle": "Bring a saved copy back — nothing gets duplicated",
"exportCsv": "Export to a spreadsheet",
"exportCsvSubtitle": "A simple list for Excel or LibreOffice — no photos",
"importCsv": "Import a list",
"importCsvSubtitle": "Add entries from a spreadsheet",
"importConfirmTitle": "Restore a backup?",
"importConfirmBody": "Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.",
"importCsvConfirmTitle": "Import a CSV list?",
"importCsvConfirmTitle": "Import a list?",
"importCsvConfirmBody": "Every row is added as a new entry. This does not merge or replace, so importing the same file twice adds it twice.",
"importAction": "Import",
"exportSaved": "Copy saved",
"cancelled": "Cancelled",
"importDone": "Imported: {added} new, {updated} updated",
"importCsvDone": "Imported {count} from CSV",
"importCsvDone": "Added {count} entries",
"importFailed": "This file could not be read as a Tanemaki copy",
"failed": "Something went wrong"
},
@ -112,7 +112,19 @@
"empty": "No seeds yet. Tap + to add your first.",
"noMatches": "No seeds match your filters.",
"clearFilters": "Clear filters",
"uncategorized": "Uncategorized"
"uncategorized": "Uncategorized",
"needsReproductionFilter": "To regrow"
},
"draft": {
"capture": "Capture photos",
"captured": "{n} captured to catalogue",
"triageTitle": "To catalogue",
"triageCount": "{n} to catalogue",
"untitled": "Unnamed",
"nameField": "Name this seed",
"nameHint": "What is it?",
"suggestFromPhoto": "Suggest name from photo",
"discard": "Discard"
},
"quickAdd": {
"title": "Add a seed",
@ -151,13 +163,21 @@
"none": "No germination tests yet.",
"result": "{percent}%"
},
"viability": {
"expiringSoon": "Use or reproduce this season",
"expiringSoonYears": "Use or reproduce this season · keeps ~{years} yr",
"expired": "Past typical viability — reproduce",
"expiredYears": "Past typical viability (~{years} yr) — reproduce"
},
"editVariety": {
"title": "Edit seed",
"name": "Name",
"category": "Category",
"notes": "Notes",
"species": "Species (from catalog)",
"speciesHint": "Search a species…"
"speciesHint": "Search a species…",
"organic": "Organic",
"organicHint": "Grown organically (eco)"
},
"addLot": {
"title": "Add lot",
@ -191,6 +211,65 @@
"bareRoot": "Bare-root",
"rootBall": "Root-ball"
},
"provenance": {
"section": "Where it's from",
"seedsFrom": "Seeds from",
"seedsFromHint": "Who grew or gave them",
"place": "Place",
"placeHint": "Where they come from (with region)",
"addSeedsFrom": "Seeds from",
"addPlace": "Place"
},
"abundance": {
"add": "How much I have",
"title": "How much I have",
"none": "Not set",
"plentyToShare": "Plenty to share",
"enoughToShare": "Enough to share a little",
"enoughForMe": "Enough for me",
"runningLow": "Running low"
},
"cropCalendar": {
"add": "Crop calendar",
"title": "Crop calendar",
"sow": "Sow",
"transplant": "Transplant",
"flowering": "Flowering",
"fruiting": "Fruiting",
"seedHarvest": "Seed harvest",
"unset": "—"
},
"needsReproduction": {
"label": "To regrow this season",
"hint": "Grow it out before the seed runs out",
"badge": "To regrow"
},
"preservation": {
"add": "How it's kept",
"title": "How it's kept",
"none": "Unspecified",
"jarWithDesiccant": "Jar with drying agent",
"glassJar": "Glass jar",
"paperEnvelope": "Paper envelope",
"paperBag": "Paper bag",
"plasticBag": "Plastic bag"
},
"conditionCheck": {
"advanced": "Storage & seed-bank details",
"title": "Storage checks",
"add": "Add check",
"containers": "Jars / containers",
"desiccant": "Drying agent",
"none": "No storage checks yet.",
"summary": "{count} jar(s) · {state}"
},
"desiccant": {
"none": "None",
"add": "Add some",
"replace": "Replace it",
"dry": "Blue — dry",
"fresh": "Just renewed"
},
"unit": {
"aFew": "a few",
"some": "some",

View file

@ -45,23 +45,23 @@
},
"backup": {
"title": "Copia de seguridad",
"exportCsv": "Exportar a CSV",
"exportCsvSubtitle": "Para hojas de cálculo — sin fotos",
"exportJson": "Exportar a JSON",
"exportJsonSubtitle": "Copia completa, se puede volver a importar",
"importJson": "Importar desde JSON",
"importJsonSubtitle": "Fusiona una copia guardada con este inventario",
"importCsv": "Importar desde CSV",
"importCsvSubtitle": "Añade una lista desde una hoja de cálculo",
"importConfirmTitle": "¿Importar una copia guardada?",
"exportJson": "Guardar una copia de seguridad",
"exportJsonSubtitle": "Una copia completa para guardar a salvo, restaurar luego o pasar a otro dispositivo",
"importJson": "Restaurar una copia",
"importJsonSubtitle": "Recupera una copia guardada — no se duplica nada",
"exportCsv": "Exportar a una hoja de cálculo",
"exportCsvSubtitle": "Una lista sencilla para Excel o LibreOffice — sin fotos",
"importCsv": "Importar una lista",
"importCsvSubtitle": "Añade entradas desde una hoja de cálculo",
"importConfirmTitle": "¿Restaurar una copia?",
"importConfirmBody": "Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.",
"importCsvConfirmTitle": "¿Importar una lista CSV?",
"importCsvConfirmTitle": "¿Importar una lista?",
"importCsvConfirmBody": "Cada fila se añade como una entrada nueva. No fusiona ni reemplaza, así que importar el mismo fichero dos veces lo añade dos veces.",
"importAction": "Importar",
"exportSaved": "Copia guardada",
"cancelled": "Cancelado",
"importDone": "Importado: {added} nuevas, {updated} actualizadas",
"importCsvDone": "Importadas {count} desde CSV",
"importCsvDone": "Añadidas {count} entradas",
"importFailed": "Este fichero no se pudo leer como una copia de Tanemaki",
"failed": "Algo ha salido mal"
},
@ -112,7 +112,19 @@
"empty": "Aún no hay semillas. Toca + para añadir la primera.",
"noMatches": "Ninguna semilla coincide con los filtros.",
"clearFilters": "Quitar filtros",
"uncategorized": "Sin categoría"
"uncategorized": "Sin categoría",
"needsReproductionFilter": "Por reproducir"
},
"draft": {
"capture": "Capturar fotos",
"captured": "{n} capturadas por catalogar",
"triageTitle": "Por catalogar",
"triageCount": "{n} por catalogar",
"untitled": "Sin nombre",
"nameField": "Nombra esta semilla",
"nameHint": "¿Qué es?",
"suggestFromPhoto": "Sugerir nombre de la foto",
"discard": "Descartar"
},
"quickAdd": {
"title": "Añadir una semilla",
@ -151,13 +163,21 @@
"none": "Aún no hay pruebas de germinación.",
"result": "{percent}%"
},
"viability": {
"expiringSoon": "Úsala o multiplícala esta temporada",
"expiringSoonYears": "Úsala o multiplícala esta temporada · dura ~{years} años",
"expired": "Supera su viabilidad típica — a multiplicar",
"expiredYears": "Supera su viabilidad típica (~{years} años) — a multiplicar"
},
"editVariety": {
"title": "Editar semilla",
"name": "Nombre",
"category": "Categoría",
"notes": "Notas",
"species": "Especie (del catálogo)",
"speciesHint": "Buscar una especie…"
"speciesHint": "Buscar una especie…",
"organic": "Ecológica",
"organicHint": "Cultivada de forma ecológica (eco)"
},
"addLot": {
"title": "Añadir lote",
@ -191,6 +211,65 @@
"bareRoot": "Raíz desnuda",
"rootBall": "Cepellón"
},
"provenance": {
"section": "De dónde viene",
"seedsFrom": "Semillas de",
"seedsFromHint": "Quién las cultivó o las dio",
"place": "Lugar",
"placeHint": "De dónde vienen (con provincia)",
"addSeedsFrom": "Semillas de",
"addPlace": "Lugar"
},
"abundance": {
"add": "Cuánta tengo",
"title": "Cuánta tengo",
"none": "Sin indicar",
"plentyToShare": "De sobra para compartir",
"enoughToShare": "Bastante, para compartir con moderación",
"enoughForMe": "Suficiente para mí",
"runningLow": "Queda poca"
},
"cropCalendar": {
"add": "Calendario de cultivo",
"title": "Calendario de cultivo",
"sow": "Siembra",
"transplant": "Trasplante",
"flowering": "Floración",
"fruiting": "Fructificación",
"seedHarvest": "Cosecha de semilla",
"unset": "—"
},
"needsReproduction": {
"label": "Reproducir esta temporada",
"hint": "Cultívala antes de que se acabe la semilla",
"badge": "Por reproducir"
},
"preservation": {
"add": "Cómo se conserva",
"title": "Cómo se conserva",
"none": "Sin especificar",
"jarWithDesiccant": "Bote con desecante",
"glassJar": "Bote de cristal",
"paperEnvelope": "Sobre de papel",
"paperBag": "Bolsa de papel",
"plasticBag": "Bolsa de plástico"
},
"conditionCheck": {
"advanced": "Conservación y detalles del banco",
"title": "Revisiones de conservación",
"add": "Añadir revisión",
"containers": "Botes / recipientes",
"desiccant": "Desecante",
"none": "Aún no hay revisiones.",
"summary": "{count} bote(s) · {state}"
},
"desiccant": {
"none": "No tiene",
"add": "Se pone",
"replace": "Se cambia",
"dry": "Azul — seco",
"fresh": "Recién puesto"
},
"unit": {
"aFew": "unas pocas",
"some": "algunas",

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 2
/// Strings: 408 (204 per locale)
/// Strings: 530 (265 per locale)
///
/// Built on 2026-07-09 at 12:44 UTC
/// Built on 2026-07-09 at 17:18 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View file

@ -50,14 +50,23 @@ class Translations with BaseTranslations<AppLocale, Translations> {
late final Translations$about$en about = Translations$about$en.internal(_root);
late final Translations$intro$en intro = Translations$intro$en.internal(_root);
late final Translations$inventory$en inventory = Translations$inventory$en.internal(_root);
late final Translations$draft$en draft = Translations$draft$en.internal(_root);
late final Translations$quickAdd$en quickAdd = Translations$quickAdd$en.internal(_root);
late final Translations$detail$en detail = Translations$detail$en.internal(_root);
late final Translations$germination$en germination = Translations$germination$en.internal(_root);
late final Translations$viability$en viability = Translations$viability$en.internal(_root);
late final Translations$editVariety$en editVariety = Translations$editVariety$en.internal(_root);
late final Translations$addLot$en addLot = Translations$addLot$en.internal(_root);
late final Translations$harvest$en harvest = Translations$harvest$en.internal(_root);
late final Translations$lotType$en lotType = Translations$lotType$en.internal(_root);
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$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);
late final Translations$conditionCheck$en conditionCheck = Translations$conditionCheck$en.internal(_root);
late final Translations$desiccant$en desiccant = Translations$desiccant$en.internal(_root);
late final Translations$unit$en unit = Translations$unit$en.internal(_root);
}
@ -219,41 +228,41 @@ class Translations$backup$en {
// Translations
/// en: 'Backup'
String get title => 'Backup';
/// en: 'Backup & restore'
String get title => 'Backup & restore';
/// en: 'Export as CSV'
String get exportCsv => 'Export as CSV';
/// en: 'Save a backup'
String get exportJson => 'Save a backup';
/// en: 'For spreadsheets — no photos'
String get exportCsvSubtitle => 'For spreadsheets — no photos';
/// en: 'A complete copy to keep safe, restore later or move to another device'
String get exportJsonSubtitle => 'A complete copy to keep safe, restore later or move to another device';
/// en: 'Export as JSON'
String get exportJson => 'Export as JSON';
/// en: 'Restore a backup'
String get importJson => 'Restore a backup';
/// en: 'Complete copy, can be imported back'
String get exportJsonSubtitle => 'Complete copy, can be imported back';
/// en: 'Bring a saved copy back — nothing gets duplicated'
String get importJsonSubtitle => 'Bring a saved copy back — nothing gets duplicated';
/// en: 'Import from JSON'
String get importJson => 'Import from JSON';
/// en: 'Export to a spreadsheet'
String get exportCsv => 'Export to a spreadsheet';
/// en: 'Merge a saved copy into this inventory'
String get importJsonSubtitle => 'Merge a saved copy into this inventory';
/// en: 'A simple list for Excel or LibreOffice — no photos'
String get exportCsvSubtitle => 'A simple list for Excel or LibreOffice — no photos';
/// en: 'Import from CSV'
String get importCsv => 'Import from CSV';
/// en: 'Import a list'
String get importCsv => 'Import a list';
/// en: 'Add a list from a spreadsheet'
String get importCsvSubtitle => 'Add a list from a spreadsheet';
/// en: 'Add entries from a spreadsheet'
String get importCsvSubtitle => 'Add entries from a spreadsheet';
/// en: 'Import a saved copy?'
String get importConfirmTitle => 'Import a saved copy?';
/// en: 'Restore a backup?'
String get importConfirmTitle => 'Restore a backup?';
/// en: 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.'
String get importConfirmBody => 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.';
/// en: 'Import a CSV list?'
String get importCsvConfirmTitle => 'Import a CSV list?';
/// en: 'Import a list?'
String get importCsvConfirmTitle => 'Import a list?';
/// en: 'Every row is added as a new entry. This does not merge or replace, so importing the same file twice adds it twice.'
String get importCsvConfirmBody => 'Every row is added as a new entry. This does not merge or replace, so importing the same file twice adds it twice.';
@ -270,8 +279,8 @@ class Translations$backup$en {
/// en: 'Imported: {added} new, {updated} updated'
String importDone({required Object added, required Object updated}) => 'Imported: ${added} new, ${updated} updated';
/// en: 'Imported {count} from CSV'
String importCsvDone({required Object count}) => 'Imported ${count} from CSV';
/// en: 'Added {count} entries'
String importCsvDone({required Object count}) => 'Added ${count} entries';
/// en: 'This file could not be read as a Tanemaki copy'
String get importFailed => 'This file could not be read as a Tanemaki copy';
@ -370,6 +379,45 @@ class Translations$inventory$en {
/// en: 'Uncategorized'
String get uncategorized => 'Uncategorized';
/// en: 'To regrow'
String get needsReproductionFilter => 'To regrow';
}
// Path: draft
class Translations$draft$en {
Translations$draft$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Capture photos'
String get capture => 'Capture photos';
/// en: '{n} captured to catalogue'
String captured({required Object n}) => '${n} captured to catalogue';
/// en: 'To catalogue'
String get triageTitle => 'To catalogue';
/// en: '{n} to catalogue'
String triageCount({required Object n}) => '${n} to catalogue';
/// en: 'Unnamed'
String get untitled => 'Unnamed';
/// en: 'Name this seed'
String get nameField => 'Name this seed';
/// en: 'What is it?'
String get nameHint => 'What is it?';
/// en: 'Suggest name from photo'
String get suggestFromPhoto => 'Suggest name from photo';
/// en: 'Discard'
String get discard => 'Discard';
}
// Path: quickAdd
@ -492,6 +540,27 @@ class Translations$germination$en {
String result({required Object percent}) => '${percent}%';
}
// Path: viability
class Translations$viability$en {
Translations$viability$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Use or reproduce this season'
String get expiringSoon => 'Use or reproduce this season';
/// en: 'Use or reproduce this season · keeps ~{years} yr'
String expiringSoonYears({required Object years}) => 'Use or reproduce this season · keeps ~${years} yr';
/// en: 'Past typical viability — reproduce'
String get expired => 'Past typical viability — reproduce';
/// en: 'Past typical viability (~{years} yr) — reproduce'
String expiredYears({required Object years}) => 'Past typical viability (~${years} yr) — reproduce';
}
// Path: editVariety
class Translations$editVariety$en {
Translations$editVariety$en.internal(this._root);
@ -517,6 +586,12 @@ class Translations$editVariety$en {
/// en: 'Search a species…'
String get speciesHint => 'Search a species…';
/// en: 'Organic'
String get organic => 'Organic';
/// en: 'Grown organically (eco)'
String get organicHint => 'Grown organically (eco)';
}
// Path: addLot
@ -630,6 +705,204 @@ class Translations$presentation$en {
String get rootBall => 'Root-ball';
}
// Path: provenance
class Translations$provenance$en {
Translations$provenance$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Where it's from'
String get section => 'Where it\'s from';
/// en: 'Seeds from'
String get seedsFrom => 'Seeds from';
/// en: 'Who grew or gave them'
String get seedsFromHint => 'Who grew or gave them';
/// en: 'Place'
String get place => 'Place';
/// en: 'Where they come from (with region)'
String get placeHint => 'Where they come from (with region)';
/// en: 'Seeds from'
String get addSeedsFrom => 'Seeds from';
/// en: 'Place'
String get addPlace => 'Place';
}
// Path: abundance
class Translations$abundance$en {
Translations$abundance$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'How much I have'
String get add => 'How much I have';
/// en: 'How much I have'
String get title => 'How much I have';
/// en: 'Not set'
String get none => 'Not set';
/// en: 'Plenty to share'
String get plentyToShare => 'Plenty to share';
/// en: 'Enough to share a little'
String get enoughToShare => 'Enough to share a little';
/// en: 'Enough for me'
String get enoughForMe => 'Enough for me';
/// en: 'Running low'
String get runningLow => 'Running low';
}
// Path: cropCalendar
class Translations$cropCalendar$en {
Translations$cropCalendar$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Crop calendar'
String get add => 'Crop calendar';
/// en: 'Crop calendar'
String get title => 'Crop calendar';
/// en: 'Sow'
String get sow => 'Sow';
/// en: 'Transplant'
String get transplant => 'Transplant';
/// en: 'Flowering'
String get flowering => 'Flowering';
/// en: 'Fruiting'
String get fruiting => 'Fruiting';
/// en: 'Seed harvest'
String get seedHarvest => 'Seed harvest';
/// en: ''
String get unset => '';
}
// Path: needsReproduction
class Translations$needsReproduction$en {
Translations$needsReproduction$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'To regrow this season'
String get label => 'To regrow this season';
/// en: 'Grow it out before the seed runs out'
String get hint => 'Grow it out before the seed runs out';
/// en: 'To regrow'
String get badge => 'To regrow';
}
// Path: preservation
class Translations$preservation$en {
Translations$preservation$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'How it's kept'
String get add => 'How it\'s kept';
/// en: 'How it's kept'
String get title => 'How it\'s kept';
/// en: 'Unspecified'
String get none => 'Unspecified';
/// en: 'Jar with drying agent'
String get jarWithDesiccant => 'Jar with drying agent';
/// en: 'Glass jar'
String get glassJar => 'Glass jar';
/// en: 'Paper envelope'
String get paperEnvelope => 'Paper envelope';
/// en: 'Paper bag'
String get paperBag => 'Paper bag';
/// en: 'Plastic bag'
String get plasticBag => 'Plastic bag';
}
// Path: conditionCheck
class Translations$conditionCheck$en {
Translations$conditionCheck$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Storage & seed-bank details'
String get advanced => 'Storage & seed-bank details';
/// en: 'Storage checks'
String get title => 'Storage checks';
/// en: 'Add check'
String get add => 'Add check';
/// en: 'Jars / containers'
String get containers => 'Jars / containers';
/// en: 'Drying agent'
String get desiccant => 'Drying agent';
/// en: 'No storage checks yet.'
String get none => 'No storage checks yet.';
/// en: '{count} jar(s) · {state}'
String summary({required Object count, required Object state}) => '${count} jar(s) · ${state}';
}
// Path: desiccant
class Translations$desiccant$en {
Translations$desiccant$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'None'
String get none => 'None';
/// en: 'Add some'
String get add => 'Add some';
/// en: 'Replace it'
String get replace => 'Replace it';
/// en: 'Blue — dry'
String get dry => 'Blue — dry';
/// en: 'Just renewed'
String get fresh => 'Just renewed';
}
// Path: unit
class Translations$unit$en {
Translations$unit$en.internal(this._root);
@ -1165,24 +1438,24 @@ extension on Translations {
'settings.about' => 'About',
'settings.aboutText' => 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.',
'settings.aboutOpen' => 'About Tanemaki',
'backup.title' => 'Backup',
'backup.exportCsv' => 'Export as CSV',
'backup.exportCsvSubtitle' => 'For spreadsheets — no photos',
'backup.exportJson' => 'Export as JSON',
'backup.exportJsonSubtitle' => 'Complete copy, can be imported back',
'backup.importJson' => 'Import from JSON',
'backup.importJsonSubtitle' => 'Merge a saved copy into this inventory',
'backup.importCsv' => 'Import from CSV',
'backup.importCsvSubtitle' => 'Add a list from a spreadsheet',
'backup.importConfirmTitle' => 'Import a saved copy?',
'backup.title' => 'Backup & restore',
'backup.exportJson' => 'Save a backup',
'backup.exportJsonSubtitle' => 'A complete copy to keep safe, restore later or move to another device',
'backup.importJson' => 'Restore a backup',
'backup.importJsonSubtitle' => 'Bring a saved copy back — nothing gets duplicated',
'backup.exportCsv' => 'Export to a spreadsheet',
'backup.exportCsvSubtitle' => 'A simple list for Excel or LibreOffice — no photos',
'backup.importCsv' => 'Import a list',
'backup.importCsvSubtitle' => 'Add entries from a spreadsheet',
'backup.importConfirmTitle' => 'Restore a backup?',
'backup.importConfirmBody' => 'Entries merge with your inventory; when the same entry exists on both sides, the newest version wins. Nothing gets duplicated.',
'backup.importCsvConfirmTitle' => 'Import a CSV list?',
'backup.importCsvConfirmTitle' => 'Import a list?',
'backup.importCsvConfirmBody' => 'Every row is added as a new entry. This does not merge or replace, so importing the same file twice adds it twice.',
'backup.importAction' => 'Import',
'backup.exportSaved' => 'Copy saved',
'backup.cancelled' => 'Cancelled',
'backup.importDone' => ({required Object added, required Object updated}) => 'Imported: ${added} new, ${updated} updated',
'backup.importCsvDone' => ({required Object count}) => 'Imported ${count} from CSV',
'backup.importCsvDone' => ({required Object count}) => 'Added ${count} entries',
'backup.importFailed' => 'This file could not be read as a Tanemaki copy',
'backup.failed' => 'Something went wrong',
'about.title' => 'About',
@ -1216,6 +1489,16 @@ extension on Translations {
'inventory.noMatches' => 'No seeds match your filters.',
'inventory.clearFilters' => 'Clear filters',
'inventory.uncategorized' => 'Uncategorized',
'inventory.needsReproductionFilter' => 'To regrow',
'draft.capture' => 'Capture photos',
'draft.captured' => ({required Object n}) => '${n} captured to catalogue',
'draft.triageTitle' => 'To catalogue',
'draft.triageCount' => ({required Object n}) => '${n} to catalogue',
'draft.untitled' => 'Unnamed',
'draft.nameField' => 'Name this seed',
'draft.nameHint' => 'What is it?',
'draft.suggestFromPhoto' => 'Suggest name from photo',
'draft.discard' => 'Discard',
'quickAdd.title' => 'Add a seed',
'quickAdd.labelField' => 'Name',
'quickAdd.labelRequired' => 'Give it a name',
@ -1247,12 +1530,18 @@ extension on Translations {
'germination.germinated' => 'Germinated',
'germination.none' => 'No germination tests yet.',
'germination.result' => ({required Object percent}) => '${percent}%',
'viability.expiringSoon' => 'Use or reproduce this season',
'viability.expiringSoonYears' => ({required Object years}) => 'Use or reproduce this season · keeps ~${years} yr',
'viability.expired' => 'Past typical viability — reproduce',
'viability.expiredYears' => ({required Object years}) => 'Past typical viability (~${years} yr) — reproduce',
'editVariety.title' => 'Edit seed',
'editVariety.name' => 'Name',
'editVariety.category' => 'Category',
'editVariety.notes' => 'Notes',
'editVariety.species' => 'Species (from catalog)',
'editVariety.speciesHint' => 'Search a species…',
'editVariety.organic' => 'Organic',
'editVariety.organicHint' => 'Grown organically (eco)',
'addLot.title' => 'Add lot',
'addLot.year' => 'Harvest date',
'addLot.quantity' => 'How much?',
@ -1285,6 +1574,51 @@ extension on Translations {
'presentation.plug' => 'Plug',
'presentation.bareRoot' => 'Bare-root',
'presentation.rootBall' => 'Root-ball',
'provenance.section' => 'Where it\'s from',
'provenance.seedsFrom' => 'Seeds from',
'provenance.seedsFromHint' => 'Who grew or gave them',
'provenance.place' => 'Place',
'provenance.placeHint' => 'Where they come from (with region)',
'provenance.addSeedsFrom' => 'Seeds from',
'provenance.addPlace' => 'Place',
'abundance.add' => 'How much I have',
'abundance.title' => 'How much I have',
'abundance.none' => 'Not set',
'abundance.plentyToShare' => 'Plenty to share',
'abundance.enoughToShare' => 'Enough to share a little',
'abundance.enoughForMe' => 'Enough for me',
'abundance.runningLow' => 'Running low',
'cropCalendar.add' => 'Crop calendar',
'cropCalendar.title' => 'Crop calendar',
'cropCalendar.sow' => 'Sow',
'cropCalendar.transplant' => 'Transplant',
'cropCalendar.flowering' => 'Flowering',
'cropCalendar.fruiting' => 'Fruiting',
'cropCalendar.seedHarvest' => 'Seed harvest',
'cropCalendar.unset' => '',
'needsReproduction.label' => 'To regrow this season',
'needsReproduction.hint' => 'Grow it out before the seed runs out',
'needsReproduction.badge' => 'To regrow',
'preservation.add' => 'How it\'s kept',
'preservation.title' => 'How it\'s kept',
'preservation.none' => 'Unspecified',
'preservation.jarWithDesiccant' => 'Jar with drying agent',
'preservation.glassJar' => 'Glass jar',
'preservation.paperEnvelope' => 'Paper envelope',
'preservation.paperBag' => 'Paper bag',
'preservation.plasticBag' => 'Plastic bag',
'conditionCheck.advanced' => 'Storage & seed-bank details',
'conditionCheck.title' => 'Storage checks',
'conditionCheck.add' => 'Add check',
'conditionCheck.containers' => 'Jars / containers',
'conditionCheck.desiccant' => 'Drying agent',
'conditionCheck.none' => 'No storage checks yet.',
'conditionCheck.summary' => ({required Object count, required Object state}) => '${count} jar(s) · ${state}',
'desiccant.none' => 'None',
'desiccant.add' => 'Add some',
'desiccant.replace' => 'Replace it',
'desiccant.dry' => 'Blue — dry',
'desiccant.fresh' => 'Just renewed',
'unit.aFew' => 'a few',
'unit.some' => 'some',
'unit.plenty' => 'plenty',

View file

@ -49,14 +49,23 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$about$es about = _Translations$about$es._(_root);
@override late final _Translations$intro$es intro = _Translations$intro$es._(_root);
@override late final _Translations$inventory$es inventory = _Translations$inventory$es._(_root);
@override late final _Translations$draft$es draft = _Translations$draft$es._(_root);
@override late final _Translations$quickAdd$es quickAdd = _Translations$quickAdd$es._(_root);
@override late final _Translations$detail$es detail = _Translations$detail$es._(_root);
@override late final _Translations$germination$es germination = _Translations$germination$es._(_root);
@override late final _Translations$viability$es viability = _Translations$viability$es._(_root);
@override late final _Translations$editVariety$es editVariety = _Translations$editVariety$es._(_root);
@override late final _Translations$addLot$es addLot = _Translations$addLot$es._(_root);
@override late final _Translations$harvest$es harvest = _Translations$harvest$es._(_root);
@override late final _Translations$lotType$es lotType = _Translations$lotType$es._(_root);
@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$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);
@override late final _Translations$conditionCheck$es conditionCheck = _Translations$conditionCheck$es._(_root);
@override late final _Translations$desiccant$es desiccant = _Translations$desiccant$es._(_root);
@override late final _Translations$unit$es unit = _Translations$unit$es._(_root);
}
@ -154,23 +163,23 @@ class _Translations$backup$es extends Translations$backup$en {
// Translations
@override String get title => 'Copia de seguridad';
@override String get exportCsv => 'Exportar a CSV';
@override String get exportCsvSubtitle => 'Para hojas de cálculo — sin fotos';
@override String get exportJson => 'Exportar a JSON';
@override String get exportJsonSubtitle => 'Copia completa, se puede volver a importar';
@override String get importJson => 'Importar desde JSON';
@override String get importJsonSubtitle => 'Fusiona una copia guardada con este inventario';
@override String get importCsv => 'Importar desde CSV';
@override String get importCsvSubtitle => 'Añade una lista desde una hoja de cálculo';
@override String get importConfirmTitle => '¿Importar una copia guardada?';
@override String get exportJson => 'Guardar una copia de seguridad';
@override String get exportJsonSubtitle => 'Una copia completa para guardar a salvo, restaurar luego o pasar a otro dispositivo';
@override String get importJson => 'Restaurar una copia';
@override String get importJsonSubtitle => 'Recupera una copia guardada — no se duplica nada';
@override String get exportCsv => 'Exportar a una hoja de cálculo';
@override String get exportCsvSubtitle => 'Una lista sencilla para Excel o LibreOffice — sin fotos';
@override String get importCsv => 'Importar una lista';
@override String get importCsvSubtitle => 'Añade entradas desde una hoja de cálculo';
@override String get importConfirmTitle => '¿Restaurar una copia?';
@override String get importConfirmBody => 'Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.';
@override String get importCsvConfirmTitle => '¿Importar una lista CSV?';
@override String get importCsvConfirmTitle => '¿Importar una lista?';
@override String get importCsvConfirmBody => 'Cada fila se añade como una entrada nueva. No fusiona ni reemplaza, así que importar el mismo fichero dos veces lo añade dos veces.';
@override String get importAction => 'Importar';
@override String get exportSaved => 'Copia guardada';
@override String get cancelled => 'Cancelado';
@override String importDone({required Object added, required Object updated}) => 'Importado: ${added} nuevas, ${updated} actualizadas';
@override String importCsvDone({required Object count}) => 'Importadas ${count} desde CSV';
@override String importCsvDone({required Object count}) => 'Añadidas ${count} entradas';
@override String get importFailed => 'Este fichero no se pudo leer como una copia de Tanemaki';
@override String get failed => 'Algo ha salido mal';
}
@ -222,6 +231,25 @@ class _Translations$inventory$es extends Translations$inventory$en {
@override String get noMatches => 'Ninguna semilla coincide con los filtros.';
@override String get clearFilters => 'Quitar filtros';
@override String get uncategorized => 'Sin categoría';
@override String get needsReproductionFilter => 'Por reproducir';
}
// Path: draft
class _Translations$draft$es extends Translations$draft$en {
_Translations$draft$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get capture => 'Capturar fotos';
@override String captured({required Object n}) => '${n} capturadas por catalogar';
@override String get triageTitle => 'Por catalogar';
@override String triageCount({required Object n}) => '${n} por catalogar';
@override String get untitled => 'Sin nombre';
@override String get nameField => 'Nombra esta semilla';
@override String get nameHint => '¿Qué es?';
@override String get suggestFromPhoto => 'Sugerir nombre de la foto';
@override String get discard => 'Descartar';
}
// Path: quickAdd
@ -282,6 +310,19 @@ class _Translations$germination$es extends Translations$germination$en {
@override String result({required Object percent}) => '${percent}%';
}
// Path: viability
class _Translations$viability$es extends Translations$viability$en {
_Translations$viability$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get expiringSoon => 'Úsala o multiplícala esta temporada';
@override String expiringSoonYears({required Object years}) => 'Úsala o multiplícala esta temporada · dura ~${years} años';
@override String get expired => 'Supera su viabilidad típica — a multiplicar';
@override String expiredYears({required Object years}) => 'Supera su viabilidad típica (~${years} años) — a multiplicar';
}
// Path: editVariety
class _Translations$editVariety$es extends Translations$editVariety$en {
_Translations$editVariety$es._(TranslationsEs root) : this._root = root, super.internal(root);
@ -295,6 +336,8 @@ class _Translations$editVariety$es extends Translations$editVariety$en {
@override String get notes => 'Notas';
@override String get species => 'Especie (del catálogo)';
@override String get speciesHint => 'Buscar una especie…';
@override String get organic => 'Ecológica';
@override String get organicHint => 'Cultivada de forma ecológica (eco)';
}
// Path: addLot
@ -367,6 +410,114 @@ class _Translations$presentation$es extends Translations$presentation$en {
@override String get rootBall => 'Cepellón';
}
// Path: provenance
class _Translations$provenance$es extends Translations$provenance$en {
_Translations$provenance$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get section => 'De dónde viene';
@override String get seedsFrom => 'Semillas de';
@override String get seedsFromHint => 'Quién las cultivó o las dio';
@override String get place => 'Lugar';
@override String get placeHint => 'De dónde vienen (con provincia)';
@override String get addSeedsFrom => 'Semillas de';
@override String get addPlace => 'Lugar';
}
// Path: abundance
class _Translations$abundance$es extends Translations$abundance$en {
_Translations$abundance$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get add => 'Cuánta tengo';
@override String get title => 'Cuánta tengo';
@override String get none => 'Sin indicar';
@override String get plentyToShare => 'De sobra para compartir';
@override String get enoughToShare => 'Bastante, para compartir con moderación';
@override String get enoughForMe => 'Suficiente para mí';
@override String get runningLow => 'Queda poca';
}
// Path: cropCalendar
class _Translations$cropCalendar$es extends Translations$cropCalendar$en {
_Translations$cropCalendar$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get add => 'Calendario de cultivo';
@override String get title => 'Calendario de cultivo';
@override String get sow => 'Siembra';
@override String get transplant => 'Trasplante';
@override String get flowering => 'Floración';
@override String get fruiting => 'Fructificación';
@override String get seedHarvest => 'Cosecha de semilla';
@override String get unset => '';
}
// Path: needsReproduction
class _Translations$needsReproduction$es extends Translations$needsReproduction$en {
_Translations$needsReproduction$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get label => 'Reproducir esta temporada';
@override String get hint => 'Cultívala antes de que se acabe la semilla';
@override String get badge => 'Por reproducir';
}
// Path: preservation
class _Translations$preservation$es extends Translations$preservation$en {
_Translations$preservation$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get add => 'Cómo se conserva';
@override String get title => 'Cómo se conserva';
@override String get none => 'Sin especificar';
@override String get jarWithDesiccant => 'Bote con desecante';
@override String get glassJar => 'Bote de cristal';
@override String get paperEnvelope => 'Sobre de papel';
@override String get paperBag => 'Bolsa de papel';
@override String get plasticBag => 'Bolsa de plástico';
}
// Path: conditionCheck
class _Translations$conditionCheck$es extends Translations$conditionCheck$en {
_Translations$conditionCheck$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get advanced => 'Conservación y detalles del banco';
@override String get title => 'Revisiones de conservación';
@override String get add => 'Añadir revisión';
@override String get containers => 'Botes / recipientes';
@override String get desiccant => 'Desecante';
@override String get none => 'Aún no hay revisiones.';
@override String summary({required Object count, required Object state}) => '${count} bote(s) · ${state}';
}
// Path: desiccant
class _Translations$desiccant$es extends Translations$desiccant$en {
_Translations$desiccant$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get none => 'No tiene';
@override String get add => 'Se pone';
@override String get replace => 'Se cambia';
@override String get dry => 'Azul — seco';
@override String get fresh => 'Recién puesto';
}
// Path: unit
class _Translations$unit$es extends Translations$unit$en {
_Translations$unit$es._(TranslationsEs root) : this._root = root, super.internal(root);
@ -778,23 +929,23 @@ extension on TranslationsEs {
'settings.aboutText' => 'Inventario local y cifrado para semillas tradicionales. AGPL-3.0.',
'settings.aboutOpen' => 'Acerca de Tanemaki',
'backup.title' => 'Copia de seguridad',
'backup.exportCsv' => 'Exportar a CSV',
'backup.exportCsvSubtitle' => 'Para hojas de cálculo — sin fotos',
'backup.exportJson' => 'Exportar a JSON',
'backup.exportJsonSubtitle' => 'Copia completa, se puede volver a importar',
'backup.importJson' => 'Importar desde JSON',
'backup.importJsonSubtitle' => 'Fusiona una copia guardada con este inventario',
'backup.importCsv' => 'Importar desde CSV',
'backup.importCsvSubtitle' => 'Añade una lista desde una hoja de cálculo',
'backup.importConfirmTitle' => '¿Importar una copia guardada?',
'backup.exportJson' => 'Guardar una copia de seguridad',
'backup.exportJsonSubtitle' => 'Una copia completa para guardar a salvo, restaurar luego o pasar a otro dispositivo',
'backup.importJson' => 'Restaurar una copia',
'backup.importJsonSubtitle' => 'Recupera una copia guardada — no se duplica nada',
'backup.exportCsv' => 'Exportar a una hoja de cálculo',
'backup.exportCsvSubtitle' => 'Una lista sencilla para Excel o LibreOffice — sin fotos',
'backup.importCsv' => 'Importar una lista',
'backup.importCsvSubtitle' => 'Añade entradas desde una hoja de cálculo',
'backup.importConfirmTitle' => '¿Restaurar una copia?',
'backup.importConfirmBody' => 'Las entradas se fusionan con tu inventario; si una entrada existe en ambos lados, gana la versión más reciente. No se duplica nada.',
'backup.importCsvConfirmTitle' => '¿Importar una lista CSV?',
'backup.importCsvConfirmTitle' => '¿Importar una lista?',
'backup.importCsvConfirmBody' => 'Cada fila se añade como una entrada nueva. No fusiona ni reemplaza, así que importar el mismo fichero dos veces lo añade dos veces.',
'backup.importAction' => 'Importar',
'backup.exportSaved' => 'Copia guardada',
'backup.cancelled' => 'Cancelado',
'backup.importDone' => ({required Object added, required Object updated}) => 'Importado: ${added} nuevas, ${updated} actualizadas',
'backup.importCsvDone' => ({required Object count}) => 'Importadas ${count} desde CSV',
'backup.importCsvDone' => ({required Object count}) => 'Añadidas ${count} entradas',
'backup.importFailed' => 'Este fichero no se pudo leer como una copia de Tanemaki',
'backup.failed' => 'Algo ha salido mal',
'about.title' => 'Acerca de',
@ -828,6 +979,16 @@ extension on TranslationsEs {
'inventory.noMatches' => 'Ninguna semilla coincide con los filtros.',
'inventory.clearFilters' => 'Quitar filtros',
'inventory.uncategorized' => 'Sin categoría',
'inventory.needsReproductionFilter' => 'Por reproducir',
'draft.capture' => 'Capturar fotos',
'draft.captured' => ({required Object n}) => '${n} capturadas por catalogar',
'draft.triageTitle' => 'Por catalogar',
'draft.triageCount' => ({required Object n}) => '${n} por catalogar',
'draft.untitled' => 'Sin nombre',
'draft.nameField' => 'Nombra esta semilla',
'draft.nameHint' => '¿Qué es?',
'draft.suggestFromPhoto' => 'Sugerir nombre de la foto',
'draft.discard' => 'Descartar',
'quickAdd.title' => 'Añadir una semilla',
'quickAdd.labelField' => 'Nombre',
'quickAdd.labelRequired' => 'Ponle un nombre',
@ -859,12 +1020,18 @@ extension on TranslationsEs {
'germination.germinated' => 'Germinadas',
'germination.none' => 'Aún no hay pruebas de germinación.',
'germination.result' => ({required Object percent}) => '${percent}%',
'viability.expiringSoon' => 'Úsala o multiplícala esta temporada',
'viability.expiringSoonYears' => ({required Object years}) => 'Úsala o multiplícala esta temporada · dura ~${years} años',
'viability.expired' => 'Supera su viabilidad típica — a multiplicar',
'viability.expiredYears' => ({required Object years}) => 'Supera su viabilidad típica (~${years} años) — a multiplicar',
'editVariety.title' => 'Editar semilla',
'editVariety.name' => 'Nombre',
'editVariety.category' => 'Categoría',
'editVariety.notes' => 'Notas',
'editVariety.species' => 'Especie (del catálogo)',
'editVariety.speciesHint' => 'Buscar una especie…',
'editVariety.organic' => 'Ecológica',
'editVariety.organicHint' => 'Cultivada de forma ecológica (eco)',
'addLot.title' => 'Añadir lote',
'addLot.year' => 'Fecha de cosecha',
'addLot.quantity' => '¿Cuánta?',
@ -897,6 +1064,51 @@ extension on TranslationsEs {
'presentation.plug' => 'Alvéolo',
'presentation.bareRoot' => 'Raíz desnuda',
'presentation.rootBall' => 'Cepellón',
'provenance.section' => 'De dónde viene',
'provenance.seedsFrom' => 'Semillas de',
'provenance.seedsFromHint' => 'Quién las cultivó o las dio',
'provenance.place' => 'Lugar',
'provenance.placeHint' => 'De dónde vienen (con provincia)',
'provenance.addSeedsFrom' => 'Semillas de',
'provenance.addPlace' => 'Lugar',
'abundance.add' => 'Cuánta tengo',
'abundance.title' => 'Cuánta tengo',
'abundance.none' => 'Sin indicar',
'abundance.plentyToShare' => 'De sobra para compartir',
'abundance.enoughToShare' => 'Bastante, para compartir con moderación',
'abundance.enoughForMe' => 'Suficiente para mí',
'abundance.runningLow' => 'Queda poca',
'cropCalendar.add' => 'Calendario de cultivo',
'cropCalendar.title' => 'Calendario de cultivo',
'cropCalendar.sow' => 'Siembra',
'cropCalendar.transplant' => 'Trasplante',
'cropCalendar.flowering' => 'Floración',
'cropCalendar.fruiting' => 'Fructificación',
'cropCalendar.seedHarvest' => 'Cosecha de semilla',
'cropCalendar.unset' => '',
'needsReproduction.label' => 'Reproducir esta temporada',
'needsReproduction.hint' => 'Cultívala antes de que se acabe la semilla',
'needsReproduction.badge' => 'Por reproducir',
'preservation.add' => 'Cómo se conserva',
'preservation.title' => 'Cómo se conserva',
'preservation.none' => 'Sin especificar',
'preservation.jarWithDesiccant' => 'Bote con desecante',
'preservation.glassJar' => 'Bote de cristal',
'preservation.paperEnvelope' => 'Sobre de papel',
'preservation.paperBag' => 'Bolsa de papel',
'preservation.plasticBag' => 'Bolsa de plástico',
'conditionCheck.advanced' => 'Conservación y detalles del banco',
'conditionCheck.title' => 'Revisiones de conservación',
'conditionCheck.add' => 'Añadir revisión',
'conditionCheck.containers' => 'Botes / recipientes',
'conditionCheck.desiccant' => 'Desecante',
'conditionCheck.none' => 'Aún no hay revisiones.',
'conditionCheck.summary' => ({required Object count, required Object state}) => '${count} bote(s) · ${state}',
'desiccant.none' => 'No tiene',
'desiccant.add' => 'Se pone',
'desiccant.replace' => 'Se cambia',
'desiccant.dry' => 'Azul — seco',
'desiccant.fresh' => 'Recién puesto',
'unit.aFew' => 'unas pocas',
'unit.some' => 'algunas',
'unit.plenty' => 'muchas',

View file

@ -5,16 +5,20 @@ import 'data/species_repository.dart';
import 'data/variety_repository.dart';
import 'di/injector.dart';
import 'i18n/strings.g.dart';
import 'services/onboarding_store.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
LocaleSettings.useDeviceLocaleSync();
await configureDependencies();
final onboarding = getIt<OnboardingStore>();
runApp(
TranslationProvider(
child: TaneApp(
repository: getIt<VarietyRepository>(),
species: getIt<SpeciesRepository>(),
onboarding: onboarding,
showIntro: !await onboarding.introSeen(),
),
),
);

View file

@ -0,0 +1,27 @@
import 'dart:typed_data';
/// Extracts a suggested variety name from a photo of its packet/label an
/// optional, best-effort enrichment for the draft-triage flow. Kept behind this
/// interface so the app degrades gracefully: platforms without an on-device OCR
/// engine (desktop, web) get [NoOpLabelTextExtractor], and the UI hides the
/// "suggest name" affordance when [isSupported] is false.
abstract class LabelTextExtractor {
/// Whether this build can actually run OCR (true only where a native engine
/// is wired). The UI uses it to decide whether to offer the suggestion.
bool get isSupported;
/// Returns a suggested name read from [photo], or null when nothing usable
/// was found or OCR is unavailable. Never throws failures yield null.
Future<String?> suggestLabel(Uint8List photo);
}
/// The fallback where no on-device OCR exists: always unsupported, always null.
class NoOpLabelTextExtractor implements LabelTextExtractor {
const NoOpLabelTextExtractor();
@override
bool get isSupported => false;
@override
Future<String?> suggestLabel(Uint8List photo) async => null;
}

View file

@ -0,0 +1,110 @@
/// Locale-aware selection of Tesseract OCR language packs.
///
/// Tane is international by design: the OCR must read seed packets in whatever
/// language the user runs the app, not a fixed `eng`/`spa`. This maps the app's
/// preferred locales to the traineddata packs we actually bundle and degrades
/// gracefully an unbundled locale simply falls back to the Latin baseline
/// instead of failing. As more packs are bundled (incl. RTL/CJK scripts) they
/// light up automatically with no code change here.
library;
import 'dart:convert';
const _trainedDataSuffix = '.traineddata';
/// ISO 639-1 (locale) Tesseract language code (mostly ISO 639-2/T). Covers the
/// scripts we plausibly bundle; extend as new packs are added. Kept
/// intentionally broad international first, not one region.
const Map<String, String> isoToTesseract = {
// Latin-script European
'en': 'eng',
'es': 'spa',
'ca': 'cat',
'gl': 'glg',
'eu': 'eus',
'pt': 'por',
'fr': 'fra',
'de': 'deu',
'it': 'ita',
'nl': 'nld',
'pl': 'pol',
'cs': 'ces',
'ro': 'ron',
'sv': 'swe',
'da': 'dan',
'fi': 'fin',
'tr': 'tur',
// Cyrillic / Greek
'ru': 'rus',
'uk': 'ukr',
'el': 'ell',
// RTL scripts
'ar': 'ara',
'he': 'heb',
'fa': 'fas',
'ur': 'urd',
// CJK & other non-Latin
'zh': 'chi_sim',
'ja': 'jpn',
'ko': 'kor',
'hi': 'hin',
'th': 'tha',
};
/// Parses the bundled OCR config (`assets/tessdata_config.json`) into the set of
/// available Tesseract language codes (basenames, e.g. `eng`, `spa`). The config
/// is the single source of truth for what is shipped, so this stays in sync with
/// the assets automatically.
Set<String> parseAvailableOcrLanguages(String configJson) {
final data = jsonDecode(configJson) as Map<String, dynamic>;
final files = (data['files'] as List?)?.cast<String>() ?? const [];
return files
.map(
(f) => f.endsWith(_trainedDataSuffix)
? f.substring(0, f.length - _trainedDataSuffix.length)
: f,
)
.where((code) => code.isNotEmpty)
.toSet();
}
/// Resolves the `+`-joined Tesseract language string for [preferredLocales]
/// (ordered most-preferred first; each an ISO 639-1 code or a fuller tag like
/// `es_ES`/`zh-Hans`), limited to the [available] bundled packs.
///
/// Rules, in order:
/// 1. Each preferred locale that maps to an available pack is included, in
/// order, de-duplicated.
/// 2. The Latin [baseline] (`eng`) is appended if bundled Latin text is
/// common on packets even in non-Latin locales, and it's a safe default.
/// 3. If nothing matched and the baseline is unavailable, fall back to every
/// available pack (sorted) so OCR still runs with whatever we shipped.
///
/// Returns an empty string only when [available] is empty.
String resolveOcrLanguages({
required List<String> preferredLocales,
required Set<String> available,
String baseline = 'eng',
}) {
final ordered = <String>[];
for (final locale in preferredLocales) {
final code = tesseractCodeForLocale(locale);
if (code != null && available.contains(code) && !ordered.contains(code)) {
ordered.add(code);
}
}
if (available.contains(baseline) && !ordered.contains(baseline)) {
ordered.add(baseline);
}
if (ordered.isEmpty) {
ordered.addAll(available.toList()..sort());
}
return ordered.join('+');
}
/// Maps a locale tag to its Tesseract code, or null if unmapped. Accepts full
/// tags (`es_ES`, `zh-Hans-CN`) and uses the primary language subtag.
String? tesseractCodeForLocale(String localeTag) {
final primary = localeTag.split(RegExp('[_-]')).first.toLowerCase();
return isoToTesseract[primary];
}

View file

@ -0,0 +1,212 @@
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter_tesseract_ocr/flutter_tesseract_ocr.dart';
import 'package:image/image.dart' as img;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'label_text_extractor.dart';
/// On-device OCR via Tesseract (offline, no cloud). Reads the **largest** text
/// on a packet/label photo the variety name is the biggest print, not the
/// longest line so boilerplate like "Organic Product" or "Herb Seeds" never
/// wins.
///
/// Security note: the plugin takes a file path, so the photo is written to a
/// private temp file and **deleted immediately** in `finally` it never lives
/// as plaintext at rest beyond the OCR call. The bundled traineddata the plugin
/// copies to app storage is public reference data, not user content.
class TesseractLabelExtractor implements LabelTextExtractor {
const TesseractLabelExtractor({
this.language = 'eng+spa',
this.rotations = const [0, 90, 180, 270, 45, 135, 225, 315],
});
/// Tesseract language(s), '+'-joined; needs matching bundled traineddata.
final String language;
/// Angles (degrees) to OCR the photo at, keeping the most confident read.
/// Tesseract only reads axis-aligned text, so this makes a packet shot
/// sideways, upside-down or ~45° tilted still work. More angles = slower
/// (one OCR pass each); trim to `[0, 90, 180, 270]` if latency matters.
final List<int> rotations;
@override
bool get isSupported => true;
@override
Future<String?> suggestLabel(Uint8List photo) async {
final base = _preprocess(photo);
if (base == null) return null;
File? temp;
try {
final dir = await getTemporaryDirectory();
temp = File(p.join(dir.path, 'ocr_${identityHashCode(photo)}.png'));
HocrLabel? best;
for (final angle in rotations) {
final image = angle == 0 ? base : img.copyRotate(base, angle: angle);
await temp.writeAsBytes(img.encodePng(image), flush: true);
// hOCR carries per-word bounding boxes + confidence, so we can rank by
// font size, drop low-confidence noise, and compare rotations.
final hocr = await FlutterTesseractOcr.extractHocr(
temp.path,
language: language,
args: {'preserve_interword_spaces': '1'},
);
final candidate = readHocrLabel(hocr);
if (candidate != null &&
(best == null || candidate.score > best.score)) {
best = candidate;
}
}
return best?.label;
} on Object {
return null;
} finally {
try {
if (temp != null && await temp.exists()) await temp.delete();
} on Object {
// Best effort: nothing to recover if cleanup fails.
}
}
}
}
/// Words in an hOCR document ignored as packet boilerplate (lower-cased,
/// accent-stripped compare). Kept deliberately small and generic.
const _boilerplate = <String>{
'organic', 'product', 'products', 'herb', 'herbs', 'seed', 'seeds',
'bio', 'net', 'weight', 'wt', 'www', 'com', 'gr', 'grs', 'grams',
'variety', 'quality',
};
/// Selects the variety name from Tesseract [hocr]: keep the words whose glyph
/// height is close to the tallest (the title print), drop boilerplate, and join
/// them in reading order (top-to-bottom, left-to-right). Returns null when
/// nothing usable remains. Pure unit-tested without a native engine.
/// A label read from one hOCR pass, with a [score] (sum of the kept words'
/// confidences) used to compare rotations the most confident wins.
class HocrLabel {
const HocrLabel(this.label, this.score);
final String label;
final int score;
}
/// Thin wrapper returning just the label (see [readHocrLabel]).
String? pickLabelFromHocr(String hocr, {int minConfidence = 20}) =>
readHocrLabel(hocr, minConfidence: minConfidence)?.label;
HocrLabel? readHocrLabel(String hocr, {int minConfidence = 20}) {
final wordRe = RegExp(
r"""class=['"]ocrx_word['"][^>]*\bbbox\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)(?:[^>]*?x_wconf\s+(\d+))?[^>]*>([^<]*)<""",
);
final words = <_Word>[];
for (final m in wordRe.allMatches(hocr)) {
final x0 = int.parse(m.group(1)!);
final y0 = int.parse(m.group(2)!);
final y1 = int.parse(m.group(4)!);
// Missing confidence treat as passing (some hOCR omits x_wconf).
final confidence = m.group(5) == null ? 100 : int.parse(m.group(5)!);
final text = _unescape(m.group(6)!).trim();
if (text.isEmpty) continue;
words.add(
_Word(x0: x0, y0: y0, height: y1 - y0, text: text, confidence: confidence),
);
}
if (words.isEmpty) return null;
final maxHeight = words.map((w) => w.height).reduce(math.max);
// Keep prominent words (>= 60% of the tallest) with 3 letters, above a low
// confidence floor, that are not boilerplate. The length rule kills 12 char
// OCR fragments (the "Re Ma" garbage) while keeping a plausibly-misread title
// word like "Sucumber" (which the user just edits to "Cucumber").
final kept = words
.where(
(w) =>
w.height >= 0.6 * maxHeight &&
w.confidence >= minConfidence &&
_letterCount(w.text) >= 3 &&
!_boilerplate.contains(_fold(w.text)),
)
.toList();
if (kept.isEmpty) return null;
// Reading order: group into rows by vertical position, then left-to-right.
final band = maxHeight * 0.6;
kept.sort((a, b) {
if ((a.y0 - b.y0).abs() > band) return a.y0.compareTo(b.y0);
return a.x0.compareTo(b.x0);
});
final chosen = kept.take(6).toList();
final label = _titleCase(_clean(chosen.map((w) => w.text).join(' ')));
if (label.isEmpty) return null;
final score = chosen.fold<int>(0, (sum, w) => sum + w.confidence);
return HocrLabel(label, score);
}
class _Word {
const _Word({
required this.x0,
required this.y0,
required this.height,
required this.text,
required this.confidence,
});
final int x0;
final int y0;
final int height;
final String text;
final int confidence;
}
/// Grayscale + contrast + upscale to help Tesseract on blurry/small photos.
/// Returns the decoded image (ready to rotate/encode), or null if it can't be
/// decoded.
img.Image? _preprocess(Uint8List bytes) {
final decoded = img.decodeImage(bytes);
if (decoded == null) return null;
var image = decoded;
// Upscale so glyphs are big enough for the LSTM engine.
if (image.width < 1200) {
image = img.copyResize(image, width: 1200);
}
image = img.grayscale(image);
return img.adjustColor(image, contrast: 1.4);
}
/// Case- and accent-folded token for boilerplate matching.
String _fold(String s) {
const from = 'áàäâãéèëêíìïîóòöôõúùüûñç';
const to = 'aaaaaeeeeiiiiooooouuuunc';
final lower = s.toLowerCase();
final buffer = StringBuffer();
for (final ch in lower.split('')) {
final i = from.indexOf(ch);
buffer.write(i == -1 ? ch : to[i]);
}
return buffer.toString().replaceAll(RegExp(r'[^a-z0-9]'), '');
}
int _letterCount(String s) =>
RegExp(r'\p{L}', unicode: true).allMatches(s).length;
String _clean(String s) => s
.replaceAll(RegExp(r"[^\p{L}\p{N}\s'-]", unicode: true), ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
String _titleCase(String s) => s
.toLowerCase()
.split(' ')
.map((w) => w.isEmpty ? w : '${w[0].toUpperCase()}${w.substring(1)}')
.join(' ');
String _unescape(String s) => s
.replaceAll('&amp;', '&')
.replaceAll('&#39;', "'")
.replaceAll('&quot;', '"')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>');

View file

@ -0,0 +1,18 @@
import '../security/secret_store.dart';
/// Remembers whether the intro/onboarding carousel has been shown, so it only
/// appears on first launch. Backed by the OS keystore (via [SecretStore]) to
/// honour the "no plaintext at rest" rule there is no shared_preferences here.
class OnboardingStore {
OnboardingStore(this._store);
final SecretStore _store;
static const _introSeenKey = 'tane.intro_seen';
/// Whether the user has already seen (or skipped) the intro carousel.
Future<bool> introSeen() async => await _store.read(_introSeenKey) == '1';
/// Records that the intro has been shown; subsequent launches skip it.
Future<void> markIntroSeen() => _store.write(_introSeenKey, '1');
}

View file

@ -12,13 +12,21 @@ import '../db/enums.dart';
class InventoryState extends Equatable {
const InventoryState({
this.items = const [],
this.drafts = const [],
this.query = '',
this.categoryFilter = const {},
this.typeFilter = const {},
this.organicOnly = false,
this.needsReproductionOnly = false,
this.loading = true,
});
final List<VarietyListItem> items;
/// Photo-first captures still awaiting a name the "to catalogue" tray,
/// shown above the list and never mixed into [items].
final List<VarietyListItem> drafts;
final String query;
/// Categories to keep; empty = all categories.
@ -28,6 +36,12 @@ class InventoryState extends Equatable {
/// least one lot of a selected form.
final Set<LotType> typeFilter;
/// When true, keep only varieties flagged organic ("eco").
final bool organicOnly;
/// When true, keep only varieties flagged "needs reproducing this season".
final bool needsReproductionOnly;
final bool loading;
/// Categories present across all items, in display order (deduped), so the
@ -53,22 +67,39 @@ class InventoryState extends Equatable {
if (typeFilter.isNotEmpty && i.lotTypes.intersection(typeFilter).isEmpty) {
return false;
}
if (organicOnly && !i.isOrganic) return false;
if (needsReproductionOnly && !i.needsReproduction) return false;
return true;
}).toList();
}
/// Whether any item is flagged organic, so the UI only offers the eco filter
/// chip when it would actually match something.
bool get hasOrganic => items.any((i) => i.isOrganic);
/// Whether any item is flagged "needs reproducing", so the UI only offers
/// that filter chip when it would match something.
bool get hasNeedsReproduction => items.any((i) => i.needsReproduction);
InventoryState copyWith({
List<VarietyListItem>? items,
List<VarietyListItem>? drafts,
String? query,
Set<String>? categoryFilter,
Set<LotType>? typeFilter,
bool? organicOnly,
bool? needsReproductionOnly,
bool? loading,
}) {
return InventoryState(
items: items ?? this.items,
drafts: drafts ?? this.drafts,
query: query ?? this.query,
categoryFilter: categoryFilter ?? this.categoryFilter,
typeFilter: typeFilter ?? this.typeFilter,
organicOnly: organicOnly ?? this.organicOnly,
needsReproductionOnly:
needsReproductionOnly ?? this.needsReproductionOnly,
loading: loading ?? this.loading,
);
}
@ -76,9 +107,12 @@ class InventoryState extends Equatable {
@override
List<Object?> get props => [
items,
drafts,
query,
categoryFilter,
typeFilter,
organicOnly,
needsReproductionOnly,
loading,
];
}
@ -90,10 +124,14 @@ class InventoryCubit extends Cubit<InventoryState> {
_sub = _repo.watchInventory().listen(
(items) => emit(state.copyWith(items: items, loading: false)),
);
_draftsSub = _repo.watchDrafts().listen(
(drafts) => emit(state.copyWith(drafts: drafts)),
);
}
final VarietyRepository _repo;
late final StreamSubscription<List<VarietyListItem>> _sub;
late final StreamSubscription<List<VarietyListItem>> _draftsSub;
void search(String query) => emit(state.copyWith(query: query));
@ -111,13 +149,29 @@ class InventoryCubit extends Cubit<InventoryState> {
emit(state.copyWith(typeFilter: next));
}
/// Clears both filters (search is left untouched).
void clearFilters() =>
emit(state.copyWith(categoryFilter: const {}, typeFilter: const {}));
/// Toggles the "organic only" filter.
void toggleOrganicOnly() =>
emit(state.copyWith(organicOnly: !state.organicOnly));
/// Toggles the "needs reproducing only" filter.
void toggleNeedsReproductionOnly() => emit(
state.copyWith(needsReproductionOnly: !state.needsReproductionOnly),
);
/// Clears all filters (search is left untouched).
void clearFilters() => emit(
state.copyWith(
categoryFilter: const {},
typeFilter: const {},
organicOnly: false,
needsReproductionOnly: false,
),
);
@override
Future<void> close() async {
await _sub.cancel();
await _draftsSub.cancel();
return super.close();
}
}

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:typed_data';
import 'package:commons_core/commons_core.dart';
import 'package:drift/drift.dart' show Value;
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -44,13 +45,30 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
final String varietyId;
late final StreamSubscription<VarietyDetail?> _sub;
Future<void> updateFields({String? label, String? category, String? notes}) =>
_repo.updateVariety(
id: varietyId,
label: label,
category: category,
notes: notes,
);
Future<void> updateFields({
String? label,
String? category,
String? notes,
bool? isOrganic,
bool? needsReproduction,
Value<int?> sowMonths = const Value.absent(),
Value<int?> transplantMonths = const Value.absent(),
Value<int?> floweringMonths = const Value.absent(),
Value<int?> fruitingMonths = const Value.absent(),
Value<int?> seedHarvestMonths = const Value.absent(),
}) => _repo.updateVariety(
id: varietyId,
label: label,
category: category,
notes: notes,
isOrganic: isOrganic,
needsReproduction: needsReproduction,
sowMonths: sowMonths,
transplantMonths: transplantMonths,
floweringMonths: floweringMonths,
fruitingMonths: fruitingMonths,
seedHarvestMonths: seedHarvestMonths,
);
Future<void> addLot({
LotType type = LotType.seed,
@ -58,6 +76,10 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
int? month,
Quantity? quantity,
Presentation? presentation,
String? originName,
String? originPlace,
Abundance? abundance,
PreservationFormat? preservationFormat,
}) => _repo.addLot(
varietyId: varietyId,
type: type,
@ -65,6 +87,10 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
harvestMonth: month,
quantity: quantity,
presentation: presentation,
originName: originName,
originPlace: originPlace,
abundance: abundance,
preservationFormat: preservationFormat,
);
Future<void> updateLot({
@ -74,6 +100,10 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
int? month,
Quantity? quantity,
Presentation? presentation,
String? originName,
String? originPlace,
Abundance? abundance,
PreservationFormat? preservationFormat,
}) => _repo.updateLot(
lotId: lotId,
type: type,
@ -81,6 +111,10 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
harvestMonth: month,
quantity: quantity,
presentation: presentation,
originName: originName,
originPlace: originPlace,
abundance: abundance,
preservationFormat: preservationFormat,
);
Future<void> deleteLot(String lotId) => _repo.softDeleteLot(lotId);
@ -120,6 +154,18 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
germinatedCount: germinatedCount,
);
Future<void> addConditionCheck({
required String lotId,
int? checkedOn,
int? containerCount,
DesiccantState? desiccantState,
}) => _repo.addConditionCheck(
lotId: lotId,
checkedOn: checkedOn,
containerCount: containerCount,
desiccantState: desiccantState,
);
Future<void> deleteVariety() async {
await _repo.softDeleteVariety(varietyId);
emit(

View file

@ -47,6 +47,14 @@ class AppDrawer extends StatelessWidget {
_DrawerItem(icon: const Icon(Icons.group), label: t.menu.following),
const Spacer(),
const Divider(height: 1),
_DrawerItem(
icon: const Icon(Icons.auto_stories_outlined),
label: t.intro.menuEntry,
onTap: () {
Navigator.of(context).pop();
context.push('/intro');
},
),
_DrawerItem(
icon: const Icon(Icons.settings),
label: t.menu.settings,

View file

@ -4,9 +4,11 @@ import '../di/injector.dart';
import '../i18n/strings.g.dart';
import '../services/export_import_service.dart';
/// The three backup actions shown in Settings: export CSV, export JSON and
/// import JSON. Import asks for confirmation first (it merges into the live
/// inventory), then reports what happened in a SnackBar.
/// The backup actions shown in Settings. The primary pair save/restore a
/// full copy comes first; the spreadsheet list export/import is a secondary
/// convenience below. File formats (JSON/CSV) are an implementation detail and
/// never surface in the UI. Restore/import asks for confirmation first (it
/// merges into the live inventory), then reports what happened in a SnackBar.
class BackupSection extends StatelessWidget {
const BackupSection({this.service, super.key});
@ -22,23 +24,24 @@ class BackupSection extends StatelessWidget {
return Column(
children: [
ListTile(
leading: const Icon(Icons.table_view_outlined),
title: Text(t.backup.exportCsv),
subtitle: Text(t.backup.exportCsvSubtitle),
onTap: () => _runExport(context, () => _service.exportCsv()),
),
ListTile(
leading: const Icon(Icons.file_download_outlined),
leading: const Icon(Icons.save_alt_outlined),
title: Text(t.backup.exportJson),
subtitle: Text(t.backup.exportJsonSubtitle),
onTap: () => _runExport(context, () => _service.exportJson()),
),
ListTile(
leading: const Icon(Icons.file_upload_outlined),
leading: const Icon(Icons.settings_backup_restore_outlined),
title: Text(t.backup.importJson),
subtitle: Text(t.backup.importJsonSubtitle),
onTap: () => _runImport(context),
),
const Divider(),
ListTile(
leading: const Icon(Icons.table_view_outlined),
title: Text(t.backup.exportCsv),
subtitle: Text(t.backup.exportCsvSubtitle),
onTap: () => _runExport(context, () => _service.exportCsv()),
),
ListTile(
leading: const Icon(Icons.playlist_add_outlined),
title: Text(t.backup.importCsv),

View file

@ -0,0 +1,245 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../data/variety_repository.dart';
import '../i18n/strings.g.dart';
import '../services/ocr/label_text_extractor.dart';
import 'photo_pick.dart';
/// Rapid "capture now, catalogue later": shoot/pick a burst of photos and turn
/// each into a draft variety. Returns how many were captured.
Future<int> captureDraftBurst(
BuildContext context,
VarietyRepository repository,
) async {
final photos = await pickPhotos(context);
for (final bytes in photos) {
await repository.addDraftVariety(bytes);
}
return photos.length;
}
/// The "to catalogue" tray: lists photo-first drafts (live from the DB); tapping
/// one prompts for a name and promotes it into the inventory.
Future<void> showTriageSheet(
BuildContext context,
VarietyRepository repository,
) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => _TriageSheet(repository: repository),
);
}
class _TriageSheet extends StatelessWidget {
const _TriageSheet({required this.repository});
final VarietyRepository repository;
@override
Widget build(BuildContext context) {
final t = context.t;
return StreamBuilder<List<VarietyListItem>>(
stream: repository.watchDrafts(),
builder: (context, snapshot) {
final drafts = snapshot.data ?? const <VarietyListItem>[];
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
t.draft.triageTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
if (drafts.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(
t.inventory.empty,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: drafts.length,
itemBuilder: (context, i) => _DraftTile(
draft: drafts[i],
repository: repository,
),
),
),
],
),
);
},
);
}
}
class _DraftTile extends StatelessWidget {
const _DraftTile({required this.draft, required this.repository});
final VarietyListItem draft;
final VarietyRepository repository;
@override
Widget build(BuildContext context) {
final t = context.t;
final photo = draft.photo;
return ListTile(
key: Key('draft.tile.${draft.id}'),
leading: photo == null
? const Icon(Icons.image_outlined)
: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(
photo,
width: 48,
height: 48,
fit: BoxFit.cover,
),
),
title: Text(t.draft.untitled),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: t.draft.discard,
onPressed: () => repository.softDeleteVariety(draft.id),
),
onTap: () async {
final name = await showDialog<String>(
context: context,
builder: (_) => NameDraftDialog(photo: photo),
);
if (name != null && name.trim().isNotEmpty) {
await repository.nameDraft(draft.id, name.trim());
}
},
);
}
}
/// Prompts for a draft's name, showing its photo. Where an on-device OCR engine
/// exists, a "Suggest name from photo" button pre-fills the field (always
/// editable, never auto-confirmed).
class NameDraftDialog extends StatefulWidget {
const NameDraftDialog({required this.photo, this.extractor, super.key});
final Uint8List? photo;
/// Injectable for tests; otherwise resolved from the service locator (or a
/// no-op when none is registered).
final LabelTextExtractor? extractor;
@override
State<NameDraftDialog> createState() => _NameDraftDialogState();
}
class _NameDraftDialogState extends State<NameDraftDialog> {
final _controller = TextEditingController();
bool _suggesting = false;
LabelTextExtractor get _extractor =>
widget.extractor ??
(GetIt.instance.isRegistered<LabelTextExtractor>()
? GetIt.instance<LabelTextExtractor>()
: const NoOpLabelTextExtractor());
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _suggest() async {
final photo = widget.photo;
if (photo == null) return;
setState(() => _suggesting = true);
final suggestion = await _extractor.suggestLabel(photo);
if (!mounted) return;
setState(() {
_suggesting = false;
if (suggestion != null && suggestion.trim().isNotEmpty) {
_controller.text = suggestion.trim();
}
});
}
@override
Widget build(BuildContext context) {
final t = context.t;
final canSuggest = widget.photo != null && _extractor.isSupported;
return AlertDialog(
title: Text(t.draft.nameField),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.photo != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(
widget.photo!,
height: 140,
fit: BoxFit.cover,
),
),
const SizedBox(height: 12),
TextField(
key: const Key('draft.nameField'),
controller: _controller,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
hintText: t.draft.nameHint,
border: const OutlineInputBorder(),
),
onSubmitted: (value) => Navigator.of(context).pop(value),
),
if (canSuggest) ...[
const SizedBox(height: 8),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
key: const Key('draft.suggestFromPhoto'),
onPressed: _suggesting ? null : _suggest,
icon: _suggesting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.auto_fix_high_outlined),
label: Text(t.draft.suggestFromPhoto),
),
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(t.common.cancel),
),
FilledButton(
key: const Key('draft.nameSave'),
onPressed: () => Navigator.of(context).pop(_controller.text),
child: Text(t.common.save),
),
],
);
}
}

View file

@ -51,12 +51,12 @@ class HarvestDateField extends StatelessWidget {
},
icon: const Icon(Icons.event_outlined, size: 20),
label: Align(
alignment: Alignment.centerLeft,
alignment: AlignmentDirectional.centerStart,
child: Text(harvestDateLabel(t, year: year, month: month)),
),
style: OutlinedButton.styleFrom(
foregroundColor: seedGreenDark,
alignment: Alignment.centerLeft,
alignment: AlignmentDirectional.centerStart,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
minimumSize: const Size.fromHeight(0),
),

View file

@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../i18n/strings.g.dart';
import 'seed_glyph.dart';
import 'theme.dart';
/// One onboarding page: a large emblem (a bundled seed glyph or a Material
/// symbol), a title and a short body. Illustrations use the app's own icon set
/// rather than photos no image licensing, and it keeps the bundle light.
class _Slide {
const _Slide({
this.glyph,
this.icon,
required this.title,
required this.body,
});
/// A `seedks` glyph char (see [SeedGlyphs]); mutually exclusive with [icon].
final String? glyph;
/// A Material symbol, used where no seed glyph fits (privacy, sprout).
final IconData? icon;
final String title;
final String body;
}
/// The first-run intro carousel (also reachable later from the drawer): a few
/// swipeable cards explaining what Tanemaki is, how privacy works, how sharing
/// works and the Plantare. [onDone] fires when the user skips or finishes, and
/// is where the caller marks the intro as seen and leaves the screen.
class IntroScreen extends StatefulWidget {
const IntroScreen({required this.onDone, super.key});
final VoidCallback onDone;
@override
State<IntroScreen> createState() => _IntroScreenState();
}
class _IntroScreenState extends State<IntroScreen> {
final _controller = PageController();
int _page = 0;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
List<_Slide> _slides(Translations t) => [
_Slide(
glyph: SeedGlyphs.scattered,
title: t.intro.slides.welcome.title,
body: t.intro.slides.welcome.body,
),
_Slide(
glyph: SeedGlyphs.jars,
title: t.intro.slides.inventory.title,
body: t.intro.slides.inventory.body,
),
_Slide(
icon: Icons.lock_outline,
title: t.intro.slides.privacy.title,
body: t.intro.slides.privacy.body,
),
_Slide(
glyph: SeedGlyphs.share,
title: t.intro.slides.share.title,
body: t.intro.slides.share.body,
),
_Slide(
icon: Symbols.potted_plant,
title: t.intro.slides.plantare.title,
body: t.intro.slides.plantare.body,
),
];
void _next(int count) {
if (_page >= count - 1) {
widget.onDone();
return;
}
_controller.nextPage(
duration: const Duration(milliseconds: 280),
curve: Curves.easeInOut,
);
}
@override
Widget build(BuildContext context) {
final t = context.t;
final slides = _slides(t);
final isLast = _page == slides.length - 1;
return Scaffold(
backgroundColor: seedCanvas,
body: SafeArea(
child: Column(
children: [
Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton(
onPressed: widget.onDone,
child: Text(t.intro.skip),
),
),
Expanded(
child: PageView.builder(
controller: _controller,
onPageChanged: (i) => setState(() => _page = i),
itemCount: slides.length,
itemBuilder: (context, i) => _SlideView(slide: slides[i]),
),
),
_Dots(count: slides.length, active: _page),
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
child: SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => _next(slides.length),
child: Text(isLast ? t.intro.start : t.intro.next),
),
),
),
],
),
),
);
}
}
/// The centred emblem + title + body for a single [slide].
class _SlideView extends StatelessWidget {
const _SlideView({required this.slide});
final _Slide slide;
@override
Widget build(BuildContext context) {
// Centre when there's room, scroll when the viewport is short (small phones
// in landscape, split-screen) so the card never overflows.
return LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 160,
height: 160,
decoration: const BoxDecoration(
color: seedPrimaryContainer,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: slide.glyph != null
? SeedGlyph(slide.glyph!, size: 76, color: seedGreen)
: Icon(slide.icon, size: 72, color: seedGreen),
),
const SizedBox(height: 36),
Text(
slide.title,
textAlign: TextAlign.center,
style: const TextStyle(
color: seedTitle,
fontSize: 24,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
const SizedBox(height: 14),
Text(
slide.body,
textAlign: TextAlign.center,
style: const TextStyle(
color: seedOnSurfaceVariant,
fontSize: 16,
height: 1.45,
),
),
],
),
),
),
),
),
),
);
}
}
/// The page-position dots: the active one is a wider green pill.
class _Dots extends StatelessWidget {
const _Dots({required this.count, required this.active});
final int count;
final int active;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < count; i++)
AnimatedContainer(
duration: const Duration(milliseconds: 220),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: i == active ? 22 : 8,
height: 8,
decoration: BoxDecoration(
color: i == active ? seedGreen : seedOutline,
borderRadius: BorderRadius.circular(4),
),
),
],
);
}
}

View file

@ -4,9 +4,11 @@ import 'package:go_router/go_router.dart';
import '../data/variety_repository.dart';
import '../db/enums.dart';
import '../domain/seed_viability.dart';
import '../i18n/strings.g.dart';
import '../state/inventory_cubit.dart';
import 'app_drawer.dart';
import 'draft_triage.dart';
import 'quantity_picker.dart';
import 'quick_add_sheet.dart';
import 'seed_glyph.dart';
@ -21,7 +23,17 @@ class InventoryListScreen extends StatelessWidget {
Widget build(BuildContext context) {
final t = context.t;
return Scaffold(
appBar: AppBar(title: Text(t.inventory.title)),
appBar: AppBar(
title: Text(t.inventory.title),
actions: [
IconButton(
key: const Key('inventory.captureBurst'),
icon: const Icon(Icons.add_a_photo_outlined),
tooltip: t.draft.capture,
onPressed: () => _captureBurst(context),
),
],
),
drawer: const AppDrawer(),
floatingActionButton: FloatingActionButton(
key: const Key('inventory.addFab'),
@ -39,6 +51,8 @@ class InventoryListScreen extends StatelessWidget {
}
return Column(
children: [
if (state.drafts.isNotEmpty)
_TriageBanner(count: state.drafts.length),
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
child: TextField(
@ -75,6 +89,45 @@ class InventoryListScreen extends StatelessWidget {
),
);
}
Future<void> _captureBurst(BuildContext context) async {
final t = context.t;
final repository = context.read<VarietyRepository>();
final messenger = ScaffoldMessenger.of(context);
final count = await captureDraftBurst(context, repository);
if (count > 0) {
messenger.showSnackBar(
SnackBar(content: Text(t.draft.captured(n: count))),
);
}
}
}
/// A tappable banner announcing how many photo-first captures are waiting to be
/// named. Opens the "to catalogue" tray.
class _TriageBanner extends StatelessWidget {
const _TriageBanner({required this.count});
final int count;
@override
Widget build(BuildContext context) {
final t = context.t;
return Material(
color: seedAvatar,
child: ListTile(
key: const Key('inventory.triageBanner'),
leading: const Icon(Icons.inventory_2_outlined, color: seedGreen),
title: Text(
t.draft.triageCount(n: count),
style: const TextStyle(fontWeight: FontWeight.w500),
),
trailing: const Icon(Icons.chevron_right),
onTap: () =>
showTriageSheet(context, context.read<VarietyRepository>()),
),
);
}
}
/// A horizontally scrolling row of filter chips: one per category in use plus
@ -94,9 +147,42 @@ class _FilterBar extends StatelessWidget {
for (final type in LotType.values)
if (state.items.any((i) => i.lotTypes.contains(type))) type,
];
if (categories.isEmpty && forms.isEmpty) return const SizedBox.shrink();
// Only offer the eco chip when some variety is flagged organic.
final hasOrganic = state.hasOrganic;
// Only offer the "to regrow" chip when something is flagged for it.
final hasNeedsReproduction = state.hasNeedsReproduction;
if (categories.isEmpty &&
forms.isEmpty &&
!hasOrganic &&
!hasNeedsReproduction) {
return const SizedBox.shrink();
}
final chips = <Widget>[
if (hasOrganic)
FilterChip(
key: const Key('inventory.filter.organic'),
avatar: Icon(
Icons.eco,
size: 18,
color: state.organicOnly ? null : seedGreen,
),
label: Text(t.editVariety.organic),
selected: state.organicOnly,
onSelected: (_) => cubit.toggleOrganicOnly(),
),
if (hasNeedsReproduction)
FilterChip(
key: const Key('inventory.filter.needsReproduction'),
avatar: Icon(
Icons.autorenew,
size: 18,
color: state.needsReproductionOnly ? null : seedGreen,
),
label: Text(t.inventory.needsReproductionFilter),
selected: state.needsReproductionOnly,
onSelected: (_) => cubit.toggleNeedsReproductionOnly(),
),
for (final category in categories)
FilterChip(
key: Key('inventory.filter.category.$category'),
@ -113,7 +199,10 @@ class _FilterBar extends StatelessWidget {
),
];
final hasActiveFilter =
state.categoryFilter.isNotEmpty || state.typeFilter.isNotEmpty;
state.categoryFilter.isNotEmpty ||
state.typeFilter.isNotEmpty ||
state.organicOnly ||
state.needsReproductionOnly;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
@ -122,7 +211,7 @@ class _FilterBar extends StatelessWidget {
children: [
for (final chip in chips)
Padding(
padding: const EdgeInsets.only(right: 8),
padding: const EdgeInsetsDirectional.only(end: 8),
child: chip,
),
if (hasActiveFilter)
@ -231,18 +320,61 @@ class _VarietyTile extends StatelessWidget {
item.scientificName!,
style: const TextStyle(fontStyle: FontStyle.italic),
),
trailing: IconButton(
icon: const Icon(Icons.edit_outlined),
// Action colour: this is a tap target, not secondary text.
color: seedGreen,
tooltip: context.t.common.edit,
onPressed: open,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (item.isOrganic)
Padding(
padding: const EdgeInsetsDirectional.only(end: 4),
child: Tooltip(
key: Key('inventory.organic.${item.id}'),
message: context.t.editVariety.organic,
child: const Icon(Icons.eco, size: 20, color: seedGreen),
),
),
_ViabilityDot(item.viability),
IconButton(
icon: const Icon(Icons.edit_outlined),
// Action colour: this is a tap target, not secondary text.
color: seedGreen,
tooltip: context.t.common.edit,
onPressed: open,
),
],
),
onTap: open,
);
}
}
/// A small aging indicator on a list tile: an amber clock when some seed lot is
/// in its last viable year, a red alert when one is past typical viability.
/// Renders nothing for fresh or unknown lots.
class _ViabilityDot extends StatelessWidget {
const _ViabilityDot(this.status);
final SeedViability status;
@override
Widget build(BuildContext context) {
final t = context.t;
final expired = status == SeedViability.expired;
if (status != SeedViability.expiringSoon && !expired) {
return const SizedBox.shrink();
}
final scheme = Theme.of(context).colorScheme;
return Tooltip(
key: Key('inventory.viability.${expired ? 'expired' : 'soon'}'),
message: expired ? t.viability.expired : t.viability.expiringSoon,
child: Icon(
expired ? Icons.error_outline : Icons.schedule,
size: 20,
color: expired ? scheme.error : const Color(0xFFB26A00),
),
);
}
}
/// Rounded-square photo thumbnail, or a green initial circle when there is none.
class _Avatar extends StatelessWidget {
const _Avatar({required this.item});

View file

@ -44,3 +44,58 @@ Future<Uint8List?> pickPhoto(BuildContext context) async {
return null;
}
}
/// Rapid multi-capture for the "capture now, catalogue later" flow: pick many
/// photos from the gallery at once, or shoot a burst with the camera (it
/// reopens after each shot until the user cancels). Returns every captured
/// image's bytes, or an empty list if cancelled/unavailable.
Future<List<Uint8List>> pickPhotos(BuildContext context) async {
final t = context.t;
final source = await showModalBottomSheet<ImageSource>(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
key: const Key('photos.source.camera'),
leading: const Icon(Icons.photo_camera_outlined),
title: Text(t.photo.camera),
onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera),
),
ListTile(
key: const Key('photos.source.gallery'),
leading: const Icon(Icons.photo_library_outlined),
title: Text(t.photo.gallery),
onTap: () => Navigator.of(sheetContext).pop(ImageSource.gallery),
),
],
),
),
);
if (source == null) return const [];
final picker = ImagePicker();
try {
if (source == ImageSource.gallery) {
final files = await picker.pickMultiImage(
maxWidth: 1280,
imageQuality: 80,
);
return [for (final file in files) await file.readAsBytes()];
}
// Camera burst: keep reopening the camera until the user cancels.
final shots = <Uint8List>[];
while (true) {
final file = await picker.pickImage(
source: ImageSource.camera,
maxWidth: 1280,
imageQuality: 80,
);
if (file == null) break;
shots.add(await file.readAsBytes());
}
return shots;
} on Object {
return const [];
}
}

View file

@ -72,7 +72,7 @@ class QuickAddSheet extends StatelessWidget {
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
onPressed: cubit.toggleExpanded,
icon: Icon(
@ -197,7 +197,7 @@ class _MoreSection extends StatelessWidget {
),
),
Align(
alignment: Alignment.centerLeft,
alignment: AlignmentDirectional.centerStart,
child: OutlinedButton.icon(
onPressed: () async {
final bytes = await pickPhoto(context);

View file

@ -56,7 +56,7 @@ String? seedGlyphForKind(QuantityKind kind) => switch (kind) {
QuantityKind.cup => SeedGlyphs.mug,
QuantityKind.jar => SeedGlyphs.jar,
QuantityKind.sack => SeedGlyphs.sack,
QuantityKind.packet => SeedGlyphs.envelope,
QuantityKind.packet => SeedGlyphs.pouring,
_ => null,
};

View file

@ -1,5 +1,6 @@
import 'dart:typed_data';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart';
@ -7,6 +8,8 @@ import 'package:url_launcher/url_launcher.dart';
import '../data/species_repository.dart';
import '../data/variety_repository.dart';
import '../db/enums.dart';
import '../domain/crop_calendar.dart';
import '../domain/seed_viability.dart';
import '../i18n/strings.g.dart';
import '../state/variety_detail_cubit.dart';
import 'harvest_date_picker.dart';
@ -108,6 +111,11 @@ class _DetailView extends StatelessWidget {
_SectionTitle(t.detail.notes),
Text(detail.notes!),
],
if (detail.hasCropCalendar) ...[
const SizedBox(height: 16),
_SectionTitle(t.cropCalendar.title),
_CropCalendarView(detail: detail),
],
const SizedBox(height: 16),
_SectionTitle(t.detail.links),
Wrap(
@ -149,40 +157,10 @@ class _DetailView extends StatelessWidget {
Text(t.detail.noLots)
else
for (final lot in detail.lots)
ListTile(
key: Key('lot.${lot.id}'),
contentPadding: EdgeInsets.zero,
onTap: () => _showLotSheet(context, cubit, existing: lot),
leading: lot.quantity == null
? const Icon(Icons.inventory_2_outlined)
: QuantityKindIcon(lot.quantity!.kind, size: 28),
title: Row(
children: [
_LotTypeChip(type: lot.type),
const SizedBox(width: 8),
Expanded(child: Text(_lotSubtitle(t, lot))),
],
),
subtitle: lot.storageLocation == null
? null
: Text(lot.storageLocation!),
// Germination only applies to seed lots, not to plantel.
trailing: lot.type != LotType.seed
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (lot.latestGerminationRate != null)
_GerminationBadge(rate: lot.latestGerminationRate!),
IconButton(
key: Key('lot.germination.${lot.id}'),
icon: const Icon(Icons.grass_outlined),
tooltip: t.germination.title,
onPressed: () =>
_showGerminationSheet(context, cubit, lot),
),
],
),
_LotTile(
cubit: cubit,
lot: lot,
viabilityYears: detail.viabilityYears,
),
],
),
@ -196,15 +174,167 @@ String _lotSubtitle(Translations t, VarietyLot lot) {
harvestDateLabel(t, year: lot.harvestYear!, month: lot.harvestMonth)
else
t.detail.noYear,
if (lot.quantity != null) quantityDisplay(t, lot.quantity!),
if (lot.quantity != null)
quantityDisplay(t, lot.quantity!)
else if (lot.abundance != null)
abundanceLabel(t, lot.abundance!),
if (lot.presentation != null) presentationLabel(t, lot.presentation!),
];
return parts.join(' · ');
}
/// The provenance line for a lot ("from · place"), or null when neither is set.
String? _lotOrigin(VarietyLot lot) {
final parts = <String>[?lot.originName, ?lot.originPlace];
return parts.isEmpty ? null : parts.join(' · ');
}
String _germinationPercent(Translations t, double rate) =>
t.germination.result(percent: (rate * 100).round());
/// One lot row: kind, harvest/quantity summary, storage location, an aging
/// (viability) warning for seed lots, and the germination test affordance.
class _LotTile extends StatelessWidget {
const _LotTile({
required this.cubit,
required this.lot,
required this.viabilityYears,
});
final VarietyDetailCubit cubit;
final VarietyLot lot;
/// Typical seed longevity of the variety's species (years), or null.
final int? viabilityYears;
@override
Widget build(BuildContext context) {
final t = context.t;
// Aging only makes sense for seed lots (living material is not stored dry).
final viability = lot.type == LotType.seed
? seedViability(
harvestYear: lot.harvestYear,
viabilityYears: viabilityYears,
currentYear: DateTime.now().year,
)
: SeedViability.unknown;
final warning = _ViabilityWarning.forStatus(viability, viabilityYears);
return ListTile(
key: Key('lot.${lot.id}'),
contentPadding: EdgeInsets.zero,
onTap: () => _showLotSheet(context, cubit, existing: lot),
leading: lot.quantity == null
? const Icon(Icons.inventory_2_outlined)
: QuantityKindIcon(lot.quantity!.kind, size: 28),
title: Row(
children: [
_LotTypeChip(type: lot.type),
const SizedBox(width: 8),
Expanded(child: Text(_lotSubtitle(t, lot))),
],
),
subtitle:
(lot.storageLocation == null &&
warning == null &&
_lotOrigin(lot) == null)
? null
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_lotOrigin(lot) case final origin?)
Row(
children: [
const Icon(Icons.place_outlined, size: 14),
const SizedBox(width: 4),
Expanded(child: Text(origin)),
],
),
if (lot.storageLocation case final loc?) Text(loc),
?warning,
],
),
// Germination only applies to seed lots, not to plantel.
trailing: lot.type != LotType.seed
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (lot.latestGerminationRate != null)
_GerminationBadge(rate: lot.latestGerminationRate!),
IconButton(
key: Key('lot.germination.${lot.id}'),
icon: const Icon(Icons.grass_outlined),
tooltip: t.germination.title,
onPressed: () => _showGerminationSheet(context, cubit, lot),
),
],
),
);
}
}
/// An inline "sow/reproduce this seed soon" line for an aging seed lot. Renders
/// nothing (returns null) when the lot is fresh or its age can't be judged.
class _ViabilityWarning extends StatelessWidget {
const _ViabilityWarning._({
required this.status,
required this.viabilityYears,
});
static _ViabilityWarning? forStatus(SeedViability status, int? years) {
if (status != SeedViability.expiringSoon &&
status != SeedViability.expired) {
return null;
}
return _ViabilityWarning._(status: status, viabilityYears: years);
}
final SeedViability status;
final int? viabilityYears;
@override
Widget build(BuildContext context) {
final t = context.t;
final scheme = Theme.of(context).colorScheme;
final expired = status == SeedViability.expired;
final color = expired ? scheme.error : const Color(0xFFB26A00);
final years = viabilityYears;
final text = expired
? (years == null
? t.viability.expired
: t.viability.expiredYears(years: years))
: (years == null
? t.viability.expiringSoon
: t.viability.expiringSoonYears(years: years));
return Padding(
key: Key('lot.viability.${expired ? 'expired' : 'soon'}'),
padding: const EdgeInsets.only(top: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
expired ? Icons.error_outline : Icons.schedule,
size: 14,
color: color,
),
const SizedBox(width: 4),
Flexible(
child: Text(
text,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}
/// A small pill making a lot's kind (seeds vs plants/plantel) unmistakable.
class _LotTypeChip extends StatelessWidget {
const _LotTypeChip({required this.type});
@ -447,6 +577,14 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
List<SpeciesMatch> _suggestions = const [];
String? _pickedSpeciesId;
late bool _isOrganic = widget.detail.isOrganic;
late bool _needsReproduction = widget.detail.needsReproduction;
late int? _sowMonths = widget.detail.sowMonths;
late int? _transplantMonths = widget.detail.transplantMonths;
late int? _floweringMonths = widget.detail.floweringMonths;
late int? _fruitingMonths = widget.detail.fruitingMonths;
late int? _seedHarvestMonths = widget.detail.seedHarvestMonths;
late bool _calendarOpen = widget.detail.hasCropCalendar;
@override
void dispose() {
@ -477,6 +615,13 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
label: name.isEmpty ? null : name,
category: _nullIfBlank(_category.text),
notes: _nullIfBlank(_notes.text),
isOrganic: _isOrganic,
needsReproduction: _needsReproduction,
sowMonths: Value(_sowMonths),
transplantMonths: Value(_transplantMonths),
floweringMonths: Value(_floweringMonths),
fruitingMonths: Value(_fruitingMonths),
seedHarvestMonths: Value(_seedHarvestMonths),
);
if (_pickedSpeciesId != null) {
widget.cubit.linkSpecies(_pickedSpeciesId!);
@ -507,6 +652,7 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
TextField(
key: const Key('editVariety.name'),
controller: _name,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: t.editVariety.name,
border: const OutlineInputBorder(
@ -539,6 +685,7 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
const SizedBox(height: 12),
TextField(
controller: _category,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: t.editVariety.category,
border: const OutlineInputBorder(
@ -558,6 +705,62 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
),
),
),
const SizedBox(height: 4),
SwitchListTile(
key: const Key('editVariety.organic'),
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.eco_outlined, color: seedGreen),
title: Text(t.editVariety.organic),
subtitle: Text(t.editVariety.organicHint),
value: _isOrganic,
onChanged: (v) => setState(() => _isOrganic = v),
),
SwitchListTile(
key: const Key('editVariety.needsReproduction'),
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.autorenew, color: seedGreen),
title: Text(t.needsReproduction.label),
subtitle: Text(t.needsReproduction.hint),
value: _needsReproduction,
onChanged: (v) => setState(() => _needsReproduction = v),
),
// Crop calendar: one collapsed reveal, not five dropdowns up front.
ExpansionTile(
key: const Key('editVariety.cropCalendar'),
initiallyExpanded: _calendarOpen,
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 8),
leading: const Icon(Icons.calendar_month_outlined),
title: Text(t.cropCalendar.title),
onExpansionChanged: (v) => _calendarOpen = v,
children: [
_MonthMultiSelect(
label: t.cropCalendar.sow,
mask: _sowMonths,
onChanged: (m) => setState(() => _sowMonths = m),
),
_MonthMultiSelect(
label: t.cropCalendar.transplant,
mask: _transplantMonths,
onChanged: (m) => setState(() => _transplantMonths = m),
),
_MonthMultiSelect(
label: t.cropCalendar.flowering,
mask: _floweringMonths,
onChanged: (m) => setState(() => _floweringMonths = m),
),
_MonthMultiSelect(
label: t.cropCalendar.fruiting,
mask: _fruitingMonths,
onChanged: (m) => setState(() => _fruitingMonths = m),
),
_MonthMultiSelect(
label: t.cropCalendar.seedHarvest,
mask: _seedHarvestMonths,
onChanged: (m) => setState(() => _seedHarvestMonths = m),
),
],
),
const SizedBox(height: 16),
Row(
children: [
@ -580,6 +783,322 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
}
}
/// A labelled multi-select of the 12 months for one crop-calendar phase a
/// phase can span several months. [mask] is a 12-bit month mask; tapping a
/// month toggles its bit.
class _MonthMultiSelect extends StatelessWidget {
const _MonthMultiSelect({
required this.label,
required this.mask,
required this.onChanged,
});
final String label;
final int? mask;
final ValueChanged<int?> onChanged;
@override
Widget build(BuildContext context) {
final t = context.t;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
for (var m = 1; m <= 12; m++)
FilterChip(
key: Key('calendar.$label.$m'),
visualDensity: VisualDensity.compact,
labelPadding: const EdgeInsets.symmetric(horizontal: 2),
label: Text(_monthAbbrev(t.harvest.monthNames[m - 1])),
selected: maskHasMonth(mask, m),
onSelected: (_) => onChanged(toggleMonth(mask, m)),
),
],
),
],
),
);
}
}
/// A short (3-char) label for a month name, for the compact calendar chips.
String _monthAbbrev(String name) =>
name.length <= 3 ? name : name.substring(0, 3);
/// Read-only crop calendar: one line per recorded phase ("Sow · Mar, Apr, Sep").
/// Renders only the phases that have months set.
class _CropCalendarView extends StatelessWidget {
const _CropCalendarView({required this.detail});
final VarietyDetail detail;
@override
Widget build(BuildContext context) {
final t = context.t;
final phases = <(String, int?)>[
(t.cropCalendar.sow, detail.sowMonths),
(t.cropCalendar.transplant, detail.transplantMonths),
(t.cropCalendar.flowering, detail.floweringMonths),
(t.cropCalendar.fruiting, detail.fruitingMonths),
(t.cropCalendar.seedHarvest, detail.seedHarvestMonths),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final (label, mask) in phases)
if (maskToMonths(mask).isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: '$label · ',
style: const TextStyle(fontWeight: FontWeight.w600),
),
TextSpan(
text: maskToMonths(mask)
.map((m) => _monthAbbrev(t.harvest.monthNames[m - 1]))
.join(', '),
),
],
),
),
),
],
);
}
}
/// Human label for a coarse-abundance level.
String abundanceLabel(Translations t, Abundance a) => switch (a) {
Abundance.plentyToShare => t.abundance.plentyToShare,
Abundance.enoughToShare => t.abundance.enoughToShare,
Abundance.enoughForMe => t.abundance.enoughForMe,
Abundance.runningLow => t.abundance.runningLow,
};
/// Human label for a seed preservation format.
String preservationLabel(Translations t, PreservationFormat p) => switch (p) {
PreservationFormat.jarWithDesiccant => t.preservation.jarWithDesiccant,
PreservationFormat.glassJar => t.preservation.glassJar,
PreservationFormat.paperEnvelope => t.preservation.paperEnvelope,
PreservationFormat.paperBag => t.preservation.paperBag,
PreservationFormat.plasticBag => t.preservation.plasticBag,
};
/// Human label for a drying-agent (silica) state.
String desiccantLabel(Translations t, DesiccantState d) => switch (d) {
DesiccantState.none => t.desiccant.none,
DesiccantState.add => t.desiccant.add,
DesiccantState.replace => t.desiccant.replace,
DesiccantState.dry => t.desiccant.dry,
DesiccantState.fresh => t.desiccant.fresh,
};
/// Single-select chips for the coarse-abundance level; tapping the current one
/// clears it (null).
class _AbundanceSelector extends StatelessWidget {
const _AbundanceSelector({required this.value, required this.onChanged});
final Abundance? value;
final ValueChanged<Abundance?> onChanged;
@override
Widget build(BuildContext context) {
final t = context.t;
return Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final a in Abundance.values)
ChoiceChip(
key: Key('abundance.${a.name}'),
label: Text(abundanceLabel(t, a)),
selected: value == a,
onSelected: (sel) => onChanged(sel ? a : null),
),
],
);
}
}
/// Single-select chips for the preservation format; tapping the current one
/// clears it (null).
class _PreservationSelector extends StatelessWidget {
const _PreservationSelector({required this.value, required this.onChanged});
final PreservationFormat? value;
final ValueChanged<PreservationFormat?> onChanged;
@override
Widget build(BuildContext context) {
final t = context.t;
return Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final p in PreservationFormat.values)
ChoiceChip(
key: Key('preservation.${p.name}'),
label: Text(preservationLabel(t, p)),
selected: value == p,
onSelected: (sel) => onChanged(sel ? p : null),
),
],
);
}
}
/// Lists a lot's storage-condition checks (newest first) and offers to add one.
class _ConditionChecksBlock extends StatelessWidget {
const _ConditionChecksBlock({required this.cubit, required this.lot});
final VarietyDetailCubit cubit;
final VarietyLot lot;
@override
Widget build(BuildContext context) {
final t = context.t;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
t.conditionCheck.title,
style: Theme.of(context).textTheme.labelLarge,
),
),
if (lot.conditionChecks.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(t.conditionCheck.none),
)
else
for (final check in lot.conditionChecks)
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.inventory_2_outlined),
title: Text(
t.conditionCheck.summary(
count: check.containerCount?.toString() ?? '',
state: check.desiccantState == null
? ''
: desiccantLabel(t, check.desiccantState!),
),
),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
key: const Key('conditionCheck.open'),
icon: const Icon(Icons.add),
label: Text(t.conditionCheck.add),
onPressed: () => _showConditionSheet(context, cubit, lot),
),
),
],
);
}
}
Future<void> _showConditionSheet(
BuildContext context,
VarietyDetailCubit cubit,
VarietyLot lot,
) {
final t = context.t;
final containers = TextEditingController();
DesiccantState? state;
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (sheetContext) => StatefulBuilder(
builder: (sheetContext, setState) => Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
t.conditionCheck.title,
style: Theme.of(sheetContext).textTheme.titleLarge,
),
const SizedBox(height: 12),
TextField(
key: const Key('conditionCheck.containers'),
controller: containers,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: t.conditionCheck.containers,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
const SizedBox(height: 16),
Text(
t.conditionCheck.desiccant,
style: Theme.of(sheetContext).textTheme.labelLarge,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final d in DesiccantState.values)
ChoiceChip(
key: Key('desiccant.${d.name}'),
label: Text(desiccantLabel(t, d)),
selected: state == d,
onSelected: (sel) => setState(() => state = sel ? d : null),
),
],
),
const SizedBox(height: 16),
Row(
children: [
TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
child: Text(t.common.cancel),
),
const Spacer(),
FilledButton(
key: const Key('conditionCheck.save'),
onPressed: () {
cubit.addConditionCheck(
lotId: lot.id,
checkedOn: DateTime.now().millisecondsSinceEpoch,
containerCount: int.tryParse(containers.text.trim()),
desiccantState: state,
);
Navigator.of(sheetContext).pop();
},
child: Text(t.conditionCheck.add),
),
],
),
],
),
),
),
);
}
Future<void> _showLotSheet(
BuildContext context,
VarietyDetailCubit cubit, {
@ -592,6 +1111,17 @@ Future<void> _showLotSheet(
var selectedQuantity = existing?.quantity;
var selectedPresentation = existing?.presentation;
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 originPlaceCtrl = TextEditingController(
text: existing?.originPlace ?? '',
);
var selectedAbundance = existing?.abundance;
var selectedPreservation = existing?.preservationFormat;
var showOrigin =
existing?.originName != null || existing?.originPlace != null;
var showAbundance = existing?.abundance != null;
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
@ -682,6 +1212,99 @@ Future<void> _showLotSheet(
value: selectedQuantity,
onChanged: (q) => selectedQuantity = q,
),
const SizedBox(height: 12),
// Layer 1: reveal-on-tap extras, one field per chip.
Wrap(
spacing: 8,
children: [
if (!showOrigin)
ActionChip(
key: const Key('lot.addOrigin'),
avatar: const Icon(Icons.place_outlined, size: 18),
label: Text(t.provenance.section),
onPressed: () => setState(() => showOrigin = true),
),
if (!showAbundance)
ActionChip(
key: const Key('lot.addAbundance'),
avatar: const Icon(Icons.inventory_2_outlined, size: 18),
label: Text(t.abundance.add),
onPressed: () =>
setState(() => showAbundance = true),
),
],
),
if (showOrigin) ...[
const SizedBox(height: 12),
TextField(
key: const Key('lot.originName'),
controller: originNameCtrl,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: t.provenance.seedsFrom,
helperText: t.provenance.seedsFromHint,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
const SizedBox(height: 12),
TextField(
key: const Key('lot.originPlace'),
controller: originPlaceCtrl,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: t.provenance.place,
helperText: t.provenance.placeHint,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
],
if (showAbundance) ...[
const SizedBox(height: 16),
Text(
t.abundance.title,
style: Theme.of(sheetContext).textTheme.labelLarge,
),
const SizedBox(height: 8),
_AbundanceSelector(
value: selectedAbundance,
onChanged: (a) => setState(() => selectedAbundance = a),
),
],
// Layer 2: advanced seed-bank details, collapsed by default.
// Only for seed lots, and (condition checks) an existing lot.
if (selectedType == LotType.seed)
ExpansionTile(
key: const Key('lot.advanced'),
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 8),
leading: const Icon(Icons.inventory_outlined),
title: Text(t.conditionCheck.advanced),
children: [
const SizedBox(height: 4),
Align(
alignment: Alignment.centerLeft,
child: Text(
t.preservation.title,
style:
Theme.of(sheetContext).textTheme.labelLarge,
),
),
const SizedBox(height: 8),
_PreservationSelector(
value: selectedPreservation,
onChanged: (p) =>
setState(() => selectedPreservation = p),
),
if (editing) ...[
const SizedBox(height: 8),
_ConditionChecksBlock(cubit: cubit, lot: existing),
],
],
),
],
),
),
@ -697,6 +1320,8 @@ Future<void> _showLotSheet(
FilledButton(
key: const Key('addLot.save'),
onPressed: () {
final originName = _nullIfBlank(originNameCtrl.text);
final originPlace = _nullIfBlank(originPlaceCtrl.text);
if (editing) {
cubit.updateLot(
lotId: existing.id,
@ -705,6 +1330,10 @@ Future<void> _showLotSheet(
month: selectedMonth,
quantity: selectedQuantity,
presentation: selectedPresentation,
originName: originName,
originPlace: originPlace,
abundance: selectedAbundance,
preservationFormat: selectedPreservation,
);
} else {
cubit.addLot(
@ -713,6 +1342,10 @@ Future<void> _showLotSheet(
month: selectedMonth,
quantity: selectedQuantity,
presentation: selectedPresentation,
originName: originName,
originPlace: originPlace,
abundance: selectedAbundance,
preservationFormat: selectedPreservation,
);
}
Navigator.of(sheetContext).pop();
@ -748,6 +1381,7 @@ Future<void> _showAddNameDialog(
key: const Key('name.field'),
controller: controller,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(labelText: t.editVariety.name),
onSubmitted: (_) => submit(dialogContext),
),
@ -840,7 +1474,11 @@ class _DetailHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hasText = detail.category != null || detail.scientificName != null;
final hasText =
detail.category != null ||
detail.scientificName != null ||
detail.isOrganic ||
detail.needsReproduction;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -848,6 +1486,14 @@ class _DetailHeader extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (detail.isOrganic) ...[
_OrganicChip(),
const SizedBox(height: 6),
],
if (detail.needsReproduction) ...[
_NeedsReproductionChip(),
const SizedBox(height: 6),
],
if (detail.category != null)
Row(
mainAxisSize: MainAxisSize.min,
@ -889,6 +1535,66 @@ class _DetailHeader extends StatelessWidget {
}
}
/// A small green "Organic" pill shown on an organic variety's detail header.
class _OrganicChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
key: const Key('detail.organic'),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: seedGreen.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.eco, size: 15, color: seedGreen),
const SizedBox(width: 4),
Text(
context.t.editVariety.organic,
style: const TextStyle(
color: seedGreen,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
);
}
}
/// A small "to regrow" pill shown on a variety flagged as needing reproduction.
class _NeedsReproductionChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
key: const Key('detail.needsReproduction'),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: seedGreen.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.autorenew, size: 15, color: seedGreen),
const SizedBox(width: 4),
Text(
context.t.needsReproduction.badge,
style: const TextStyle(
color: seedGreen,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
);
}
}
/// A 140×140 photo carousel (mockup 07): swipe between photos, page dots, add a
/// photo, and delete the current one. Shows just an "add" button when empty.
class _PhotoGallery extends StatefulWidget {
@ -1131,6 +1837,11 @@ class _PhotoViewerState extends State<_PhotoViewer> {
IconButton(
key: const Key('photo.cover'),
icon: Icon(isCover ? Icons.star : Icons.star_border),
// The cover star is disabled; without an explicit disabled
// color it renders faded/dark on the black bar. Amber both
// keeps the contrast and reads as "selected".
color: Colors.white,
disabledColor: Colors.amber,
tooltip: isCover
? context.t.photo.isCover
: context.t.photo.setAsCover,