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:
parent
12a2ee2d64
commit
6809dc6143
89 changed files with 17141 additions and 228 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
39
apps/app_seeds/lib/domain/crop_calendar.dart
Normal file
39
apps/app_seeds/lib/domain/crop_calendar.dart
Normal 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;
|
||||
}
|
||||
41
apps/app_seeds/lib/domain/seed_viability.dart
Normal file
41
apps/app_seeds/lib/domain/seed_viability.dart
Normal 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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
27
apps/app_seeds/lib/services/ocr/label_text_extractor.dart
Normal file
27
apps/app_seeds/lib/services/ocr/label_text_extractor.dart
Normal 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;
|
||||
}
|
||||
110
apps/app_seeds/lib/services/ocr/ocr_language.dart
Normal file
110
apps/app_seeds/lib/services/ocr/ocr_language.dart
Normal 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];
|
||||
}
|
||||
212
apps/app_seeds/lib/services/ocr/tesseract_label_extractor.dart
Normal file
212
apps/app_seeds/lib/services/ocr/tesseract_label_extractor.dart
Normal 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 1–2 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('&', '&')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
18
apps/app_seeds/lib/services/onboarding_store.dart
Normal file
18
apps/app_seeds/lib/services/onboarding_store.dart
Normal 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');
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
245
apps/app_seeds/lib/ui/draft_triage.dart
Normal file
245
apps/app_seeds/lib/ui/draft_triage.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
228
apps/app_seeds/lib/ui/intro_screen.dart
Normal file
228
apps/app_seeds/lib/ui/intro_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue