feat(block1): bundled species catalog + autocomplete link
Add a small curated catalog of Iberian horticultural species and let a variety be linked to it from the edit sheet. - assets/catalog/species.json: 14 species with botanical family and ES/EN common names (wikidata_qid/gbif_key deferred to the varilla enrichment). - SpeciesRepository: idempotent seedBundled (is_bundled rows, keyed by scientific name) + search by scientific/common name with a locale-best label. Seeded on startup from DI. - VarietyRepository.linkSpecies: sets species_id and prefills category from the species' family when empty (never overwrites an existing category). VarietyDetail now carries the scientific name. - Edit sheet gains a live species-search field; the detail view shows the scientific name (italic). i18n strings added (ES/EN). Tests: catalog parse, idempotent seeding, search by scientific/common name, linkSpecies prefill semantics, and a widget test for the autocomplete → link → scientific-name-shown flow. Full suite: 32 passing, 0 skipped.
This commit is contained in:
parent
7ff4e38a15
commit
4e8b8293e0
22 changed files with 700 additions and 47 deletions
|
|
@ -19,9 +19,10 @@ cache:
|
||||||
- .pub-cache/
|
- .pub-cache/
|
||||||
|
|
||||||
before_script:
|
before_script:
|
||||||
# SQLCipher runtime lib so the "no plaintext at rest" security test actually
|
# SQLCipher so the "no plaintext at rest" security test actually runs (it
|
||||||
# runs (it skips where the library is absent).
|
# skips where the library is absent). The -dev package ships the unversioned
|
||||||
- apt-get update -qq && apt-get install -y -qq libsqlcipher0
|
# libsqlcipher.so symlink, which package:sqlite3 loads reliably.
|
||||||
|
- apt-get update -qq && apt-get install -y -qq libsqlcipher-dev
|
||||||
- flutter --version
|
- flutter --version
|
||||||
- flutter pub get
|
- flutter pub get
|
||||||
|
|
||||||
|
|
|
||||||
76
apps/app_seeds/assets/catalog/species.json
Normal file
76
apps/app_seeds/assets/catalog/species.json
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"note": "Curated starter set of Iberian horticultural species. Family and common names are stable; wikidata_qid/gbif_key are intentionally omitted here and enriched later from the varilla (Wikidata CC0 + GBIF) work.",
|
||||||
|
"species": [
|
||||||
|
{
|
||||||
|
"scientific_name": "Solanum lycopersicum",
|
||||||
|
"family": "Solanaceae",
|
||||||
|
"common": { "es": ["Tomate"], "en": ["Tomato"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scientific_name": "Solanum melongena",
|
||||||
|
"family": "Solanaceae",
|
||||||
|
"common": { "es": ["Berenjena"], "en": ["Aubergine", "Eggplant"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scientific_name": "Capsicum annuum",
|
||||||
|
"family": "Solanaceae",
|
||||||
|
"common": { "es": ["Pimiento", "Guindilla"], "en": ["Pepper", "Chilli"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scientific_name": "Phaseolus vulgaris",
|
||||||
|
"family": "Fabaceae",
|
||||||
|
"common": { "es": ["Judía", "Alubia", "Habichuela"], "en": ["Common bean"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scientific_name": "Vicia faba",
|
||||||
|
"family": "Fabaceae",
|
||||||
|
"common": { "es": ["Haba"], "en": ["Broad bean", "Fava bean"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scientific_name": "Pisum sativum",
|
||||||
|
"family": "Fabaceae",
|
||||||
|
"common": { "es": ["Guisante"], "en": ["Pea"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scientific_name": "Zea mays",
|
||||||
|
"family": "Poaceae",
|
||||||
|
"common": { "es": ["Maíz"], "en": ["Maize", "Corn"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scientific_name": "Cucurbita pepo",
|
||||||
|
"family": "Cucurbitaceae",
|
||||||
|
"common": { "es": ["Calabacín", "Calabaza"], "en": ["Courgette", "Zucchini", "Squash"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scientific_name": "Cucumis sativus",
|
||||||
|
"family": "Cucurbitaceae",
|
||||||
|
"common": { "es": ["Pepino"], "en": ["Cucumber"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scientific_name": "Lactuca sativa",
|
||||||
|
"family": "Asteraceae",
|
||||||
|
"common": { "es": ["Lechuga"], "en": ["Lettuce"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scientific_name": "Beta vulgaris",
|
||||||
|
"family": "Amaranthaceae",
|
||||||
|
"common": { "es": ["Acelga", "Remolacha"], "en": ["Chard", "Beetroot"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scientific_name": "Allium cepa",
|
||||||
|
"family": "Amaryllidaceae",
|
||||||
|
"common": { "es": ["Cebolla"], "en": ["Onion"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scientific_name": "Allium sativum",
|
||||||
|
"family": "Amaryllidaceae",
|
||||||
|
"common": { "es": ["Ajo"], "en": ["Garlic"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scientific_name": "Daucus carota",
|
||||||
|
"family": "Apiaceae",
|
||||||
|
"common": { "es": ["Zanahoria"], "en": ["Carrot"] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'data/species_repository.dart';
|
||||||
import 'data/variety_repository.dart';
|
import 'data/variety_repository.dart';
|
||||||
import 'i18n/strings.g.dart';
|
import 'i18n/strings.g.dart';
|
||||||
import 'state/inventory_cubit.dart';
|
import 'state/inventory_cubit.dart';
|
||||||
|
|
@ -14,10 +15,11 @@ import 'ui/variety_detail_screen.dart';
|
||||||
/// go_router. The list is `/`; `/variety/:id` is a placeholder detail route
|
/// go_router. The list is `/`; `/variety/:id` is a placeholder detail route
|
||||||
/// (the full item screen is a follow-on story).
|
/// (the full item screen is a follow-on story).
|
||||||
class TaneApp extends StatelessWidget {
|
class TaneApp extends StatelessWidget {
|
||||||
TaneApp({required this.repository, super.key})
|
TaneApp({required this.repository, required this.species, super.key})
|
||||||
: _router = _buildRouter(repository);
|
: _router = _buildRouter(repository);
|
||||||
|
|
||||||
final VarietyRepository repository;
|
final VarietyRepository repository;
|
||||||
|
final SpeciesRepository species;
|
||||||
final GoRouter _router;
|
final GoRouter _router;
|
||||||
|
|
||||||
static GoRouter _buildRouter(VarietyRepository repository) {
|
static GoRouter _buildRouter(VarietyRepository repository) {
|
||||||
|
|
@ -44,8 +46,11 @@ class TaneApp extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return RepositoryProvider.value(
|
return MultiRepositoryProvider(
|
||||||
value: repository,
|
providers: [
|
||||||
|
RepositoryProvider.value(value: repository),
|
||||||
|
RepositoryProvider.value(value: species),
|
||||||
|
],
|
||||||
child: MaterialApp.router(
|
child: MaterialApp.router(
|
||||||
onGenerateTitle: (context) => context.t.app.title,
|
onGenerateTitle: (context) => context.t.app.title,
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
|
|
|
||||||
30
apps/app_seeds/lib/data/species_catalog.dart
Normal file
30
apps/app_seeds/lib/data/species_catalog.dart
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
|
|
||||||
|
import 'species_repository.dart';
|
||||||
|
|
||||||
|
const _catalogAsset = 'assets/catalog/species.json';
|
||||||
|
|
||||||
|
/// Parses the bundled catalog JSON into [SpeciesSeed]s. Kept separate from asset
|
||||||
|
/// loading so it is trivially unit-testable without a Flutter binding.
|
||||||
|
List<SpeciesSeed> parseSpeciesCatalog(String jsonString) {
|
||||||
|
final data = jsonDecode(jsonString) as Map<String, dynamic>;
|
||||||
|
final entries = (data['species'] as List).cast<Map<String, dynamic>>();
|
||||||
|
return entries.map((e) {
|
||||||
|
final common = (e['common'] as Map<String, dynamic>? ?? const {}).map(
|
||||||
|
(lang, names) => MapEntry(lang, (names as List).cast<String>()),
|
||||||
|
);
|
||||||
|
return SpeciesSeed(
|
||||||
|
scientificName: e['scientific_name'] as String,
|
||||||
|
family: e['family'] as String?,
|
||||||
|
commonNames: common,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads and parses the bundled species catalog asset.
|
||||||
|
Future<List<SpeciesSeed>> loadBundledSpecies() async {
|
||||||
|
final jsonString = await rootBundle.loadString(_catalogAsset);
|
||||||
|
return parseSpeciesCatalog(jsonString);
|
||||||
|
}
|
||||||
151
apps/app_seeds/lib/data/species_repository.dart
Normal file
151
apps/app_seeds/lib/data/species_repository.dart
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
|
||||||
|
import '../db/database.dart';
|
||||||
|
|
||||||
|
/// One entry from the bundled catalog, before it is stored.
|
||||||
|
class SpeciesSeed {
|
||||||
|
const SpeciesSeed({
|
||||||
|
required this.scientificName,
|
||||||
|
this.family,
|
||||||
|
this.commonNames = const {},
|
||||||
|
});
|
||||||
|
|
||||||
|
final String scientificName;
|
||||||
|
final String? family;
|
||||||
|
|
||||||
|
/// language code → list of common names.
|
||||||
|
final Map<String, List<String>> commonNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A catalog match surfaced to the UI (with a best common name for the locale).
|
||||||
|
class SpeciesMatch {
|
||||||
|
const SpeciesMatch({
|
||||||
|
required this.id,
|
||||||
|
required this.scientificName,
|
||||||
|
this.family,
|
||||||
|
this.commonName,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String scientificName;
|
||||||
|
final String? family;
|
||||||
|
final String? commonName;
|
||||||
|
|
||||||
|
/// Label shown in the autocomplete: common name (scientific) when both exist.
|
||||||
|
String get displayLabel =>
|
||||||
|
commonName == null ? scientificName : '$commonName ($scientificName)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads and seeds the bundled species catalog. Bundled rows are marked
|
||||||
|
/// `is_bundled = true` and are not synced (data-model §2.2).
|
||||||
|
class SpeciesRepository {
|
||||||
|
SpeciesRepository(this._db, {required this.idGen, this.nodeId = 'bundle'});
|
||||||
|
|
||||||
|
final AppDatabase _db;
|
||||||
|
final IdGen idGen;
|
||||||
|
final String nodeId;
|
||||||
|
|
||||||
|
/// Idempotently inserts bundled species (keyed by scientific name). Safe to
|
||||||
|
/// call on every startup; existing entries are left untouched.
|
||||||
|
Future<void> seedBundled(List<SpeciesSeed> seeds) async {
|
||||||
|
final stamp = Hlc.zero(nodeId).pack();
|
||||||
|
await _db.transaction(() async {
|
||||||
|
for (final seed in seeds) {
|
||||||
|
final existing =
|
||||||
|
await (_db.select(_db.species)
|
||||||
|
..where((s) => s.scientificName.equals(seed.scientificName)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (existing != null) continue;
|
||||||
|
|
||||||
|
final speciesId = idGen.newId();
|
||||||
|
await _db
|
||||||
|
.into(_db.species)
|
||||||
|
.insert(
|
||||||
|
SpeciesCompanion.insert(
|
||||||
|
id: speciesId,
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: stamp,
|
||||||
|
lastAuthor: nodeId,
|
||||||
|
scientificName: seed.scientificName,
|
||||||
|
family: Value(seed.family),
|
||||||
|
isBundled: const Value(true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final entry in seed.commonNames.entries) {
|
||||||
|
for (final name in entry.value) {
|
||||||
|
await _db
|
||||||
|
.into(_db.speciesCommonNames)
|
||||||
|
.insert(
|
||||||
|
SpeciesCommonNamesCompanion.insert(
|
||||||
|
id: idGen.newId(),
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: stamp,
|
||||||
|
lastAuthor: nodeId,
|
||||||
|
speciesId: speciesId,
|
||||||
|
name: name,
|
||||||
|
language: Value(entry.key),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Searches species by scientific name or a common name (case-insensitive
|
||||||
|
/// substring). The catalog is small, so it is filtered in Dart. Returns up to
|
||||||
|
/// [limit] matches, each with a best common name for [languageCode].
|
||||||
|
Future<List<SpeciesMatch>> search(
|
||||||
|
String query, {
|
||||||
|
String languageCode = 'en',
|
||||||
|
int limit = 8,
|
||||||
|
}) async {
|
||||||
|
final q = query.trim().toLowerCase();
|
||||||
|
if (q.isEmpty) return const [];
|
||||||
|
|
||||||
|
final species = await (_db.select(
|
||||||
|
_db.species,
|
||||||
|
)..where((s) => s.isDeleted.equals(false))).get();
|
||||||
|
final commons = await (_db.select(
|
||||||
|
_db.speciesCommonNames,
|
||||||
|
)..where((n) => n.isDeleted.equals(false))).get();
|
||||||
|
|
||||||
|
final namesBySpecies = <String, List<({String name, String? language})>>{};
|
||||||
|
for (final c in commons) {
|
||||||
|
namesBySpecies.putIfAbsent(c.speciesId, () => []).add((
|
||||||
|
name: c.name,
|
||||||
|
language: c.language,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
final matches = <SpeciesMatch>[];
|
||||||
|
for (final s in species) {
|
||||||
|
final names = namesBySpecies[s.id] ?? const [];
|
||||||
|
final matchesScientific = s.scientificName.toLowerCase().contains(q);
|
||||||
|
final matchesCommon = names.any((n) => n.name.toLowerCase().contains(q));
|
||||||
|
if (matchesScientific || matchesCommon) {
|
||||||
|
matches.add(
|
||||||
|
SpeciesMatch(
|
||||||
|
id: s.id,
|
||||||
|
scientificName: s.scientificName,
|
||||||
|
family: s.family,
|
||||||
|
commonName: _bestName(names, languageCode),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matches.sort((a, b) => a.scientificName.compareTo(b.scientificName));
|
||||||
|
return matches.take(limit).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _bestName(
|
||||||
|
List<({String name, String? language})> names,
|
||||||
|
String languageCode,
|
||||||
|
) {
|
||||||
|
if (names.isEmpty) return null;
|
||||||
|
final inLocale = names.where((n) => n.language == languageCode);
|
||||||
|
return (inLocale.isNotEmpty ? inLocale.first : names.first).name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,8 @@ class VarietyDetail extends Equatable {
|
||||||
required this.label,
|
required this.label,
|
||||||
this.category,
|
this.category,
|
||||||
this.notes,
|
this.notes,
|
||||||
|
this.speciesId,
|
||||||
|
this.scientificName,
|
||||||
this.lots = const [],
|
this.lots = const [],
|
||||||
this.vernacularNames = const [],
|
this.vernacularNames = const [],
|
||||||
this.photo,
|
this.photo,
|
||||||
|
|
@ -52,6 +54,8 @@ class VarietyDetail extends Equatable {
|
||||||
final String label;
|
final String label;
|
||||||
final String? category;
|
final String? category;
|
||||||
final String? notes;
|
final String? notes;
|
||||||
|
final String? speciesId;
|
||||||
|
final String? scientificName;
|
||||||
final List<VarietyLot> lots;
|
final List<VarietyLot> lots;
|
||||||
final List<String> vernacularNames;
|
final List<String> vernacularNames;
|
||||||
final Uint8List? photo;
|
final Uint8List? photo;
|
||||||
|
|
@ -62,6 +66,8 @@ class VarietyDetail extends Equatable {
|
||||||
label,
|
label,
|
||||||
category,
|
category,
|
||||||
notes,
|
notes,
|
||||||
|
speciesId,
|
||||||
|
scientificName,
|
||||||
lots,
|
lots,
|
||||||
vernacularNames,
|
vernacularNames,
|
||||||
photo,
|
photo,
|
||||||
|
|
@ -202,6 +208,14 @@ class VarietyRepository {
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
|
|
||||||
|
String? scientificName;
|
||||||
|
if (v.speciesId != null) {
|
||||||
|
final species = await (_db.select(
|
||||||
|
_db.species,
|
||||||
|
)..where((s) => s.id.equals(v.speciesId!))).getSingleOrNull();
|
||||||
|
scientificName = species?.scientificName;
|
||||||
|
}
|
||||||
|
|
||||||
final lots =
|
final lots =
|
||||||
await (_db.select(_db.lots)
|
await (_db.select(_db.lots)
|
||||||
..where((l) => l.varietyId.equals(id) & l.isDeleted.equals(false))
|
..where((l) => l.varietyId.equals(id) & l.isDeleted.equals(false))
|
||||||
|
|
@ -227,12 +241,41 @@ class VarietyRepository {
|
||||||
label: v.label,
|
label: v.label,
|
||||||
category: v.category,
|
category: v.category,
|
||||||
notes: v.notes,
|
notes: v.notes,
|
||||||
|
speciesId: v.speciesId,
|
||||||
|
scientificName: scientificName,
|
||||||
lots: lots.map(_toLot).toList(),
|
lots: lots.map(_toLot).toList(),
|
||||||
vernacularNames: names.map((n) => n.name).toList(),
|
vernacularNames: names.map((n) => n.name).toList(),
|
||||||
photo: photos.isEmpty ? null : photos.first.bytes,
|
photo: photos.isEmpty ? null : photos.first.bytes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Links [varietyId] to a catalog [speciesId]. If the variety has no category
|
||||||
|
/// yet, prefill it from the species' botanical family (data-model §6).
|
||||||
|
Future<void> linkSpecies(String varietyId, String speciesId) async {
|
||||||
|
final (_, updated) = _stamp();
|
||||||
|
final species = await (_db.select(
|
||||||
|
_db.species,
|
||||||
|
)..where((s) => s.id.equals(speciesId))).getSingleOrNull();
|
||||||
|
final variety = await (_db.select(
|
||||||
|
_db.varieties,
|
||||||
|
)..where((v) => v.id.equals(varietyId))).getSingleOrNull();
|
||||||
|
|
||||||
|
final categoryIsEmpty =
|
||||||
|
variety?.category == null || variety!.category!.trim().isEmpty;
|
||||||
|
final prefill = categoryIsEmpty ? species?.family : null;
|
||||||
|
|
||||||
|
await (_db.update(
|
||||||
|
_db.varieties,
|
||||||
|
)..where((v) => v.id.equals(varietyId))).write(
|
||||||
|
VarietiesCompanion(
|
||||||
|
speciesId: Value(speciesId),
|
||||||
|
category: prefill == null ? const Value.absent() : Value(prefill),
|
||||||
|
updatedAt: Value(updated),
|
||||||
|
lastAuthor: Value(nodeId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Updates a variety's scalar fields (LWW). Passing null clears [category]
|
/// 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.
|
||||||
Future<void> updateVariety({
|
Future<void> updateVariety({
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,22 @@ void useSqlCipher() {
|
||||||
}
|
}
|
||||||
|
|
||||||
DynamicLibrary _openLinuxCipher() {
|
DynamicLibrary _openLinuxCipher() {
|
||||||
// The dev package ships `libsqlcipher.so`; the runtime package only the
|
// The dev package ships the `libsqlcipher.so` symlink; runtime packages ship
|
||||||
// versioned `libsqlcipher.so.0`. Accept either.
|
// only a versioned name (`.so.0`, `.so.1`, …). Try them in turn.
|
||||||
try {
|
const candidates = [
|
||||||
return DynamicLibrary.open('libsqlcipher.so');
|
'libsqlcipher.so',
|
||||||
} on ArgumentError {
|
'libsqlcipher.so.1',
|
||||||
return DynamicLibrary.open('libsqlcipher.so.0');
|
'libsqlcipher.so.0',
|
||||||
|
];
|
||||||
|
Object? lastError;
|
||||||
|
for (final name in candidates) {
|
||||||
|
try {
|
||||||
|
return DynamicLibrary.open(name);
|
||||||
|
} on ArgumentError catch (e) {
|
||||||
|
lastError = e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
throw StateError('Could not load SQLCipher (tried $candidates): $lastError');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens [file] as an encrypted database using the raw 256-bit [keyHex].
|
/// Opens [file] as an encrypted database using the raw 256-bit [keyHex].
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import 'package:get_it/get_it.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../data/species_catalog.dart';
|
||||||
|
import '../data/species_repository.dart';
|
||||||
import '../data/variety_repository.dart';
|
import '../data/variety_repository.dart';
|
||||||
import '../db/database.dart';
|
import '../db/database.dart';
|
||||||
import '../db/encrypted_executor.dart';
|
import '../db/encrypted_executor.dart';
|
||||||
|
|
@ -31,9 +33,14 @@ Future<void> configureDependencies() async {
|
||||||
// slice of the root seed. It becomes the user's public key in the social layer.
|
// slice of the root seed. It becomes the user's public key in the social layer.
|
||||||
final nodeId = rootSeedHex.substring(0, 16);
|
final nodeId = rootSeedHex.substring(0, 16);
|
||||||
|
|
||||||
|
// Seed the bundled species catalog (idempotent) before the UI opens.
|
||||||
|
final speciesRepository = SpeciesRepository(database, idGen: IdGen());
|
||||||
|
await speciesRepository.seedBundled(await loadBundledSpecies());
|
||||||
|
|
||||||
getIt
|
getIt
|
||||||
..registerSingleton<SecureKeyStore>(keyStore)
|
..registerSingleton<SecureKeyStore>(keyStore)
|
||||||
..registerSingleton<AppDatabase>(database)
|
..registerSingleton<AppDatabase>(database)
|
||||||
|
..registerSingleton<SpeciesRepository>(speciesRepository)
|
||||||
..registerSingleton<VarietyRepository>(
|
..registerSingleton<VarietyRepository>(
|
||||||
VarietyRepository(database, idGen: IdGen(), nodeId: nodeId),
|
VarietyRepository(database, idGen: IdGen(), nodeId: nodeId),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,9 @@
|
||||||
"title": "Edit seed",
|
"title": "Edit seed",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"category": "Category",
|
"category": "Category",
|
||||||
"notes": "Notes"
|
"notes": "Notes",
|
||||||
|
"species": "Species (from catalog)",
|
||||||
|
"speciesHint": "Search a species…"
|
||||||
},
|
},
|
||||||
"addLot": {
|
"addLot": {
|
||||||
"title": "Add lot",
|
"title": "Add lot",
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,9 @@
|
||||||
"title": "Editar semilla",
|
"title": "Editar semilla",
|
||||||
"name": "Nombre",
|
"name": "Nombre",
|
||||||
"category": "Categoría",
|
"category": "Categoría",
|
||||||
"notes": "Notas"
|
"notes": "Notas",
|
||||||
|
"species": "Especie (del catálogo)",
|
||||||
|
"speciesHint": "Buscar una especie…"
|
||||||
},
|
},
|
||||||
"addLot": {
|
"addLot": {
|
||||||
"title": "Añadir lote",
|
"title": "Añadir lote",
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 2
|
/// Locales: 2
|
||||||
/// Strings: 102 (51 per locale)
|
/// Strings: 106 (53 per locale)
|
||||||
///
|
///
|
||||||
/// Built on 2026-07-07 at 13:25 UTC
|
/// Built on 2026-07-07 at 19:21 UTC
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,12 @@ class Translations$editVariety$en {
|
||||||
|
|
||||||
/// en: 'Notes'
|
/// en: 'Notes'
|
||||||
String get notes => 'Notes';
|
String get notes => 'Notes';
|
||||||
|
|
||||||
|
/// en: 'Species (from catalog)'
|
||||||
|
String get species => 'Species (from catalog)';
|
||||||
|
|
||||||
|
/// en: 'Search a species…'
|
||||||
|
String get speciesHint => 'Search a species…';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: addLot
|
// Path: addLot
|
||||||
|
|
@ -313,6 +319,8 @@ extension on Translations {
|
||||||
'editVariety.name' => 'Name',
|
'editVariety.name' => 'Name',
|
||||||
'editVariety.category' => 'Category',
|
'editVariety.category' => 'Category',
|
||||||
'editVariety.notes' => 'Notes',
|
'editVariety.notes' => 'Notes',
|
||||||
|
'editVariety.species' => 'Species (from catalog)',
|
||||||
|
'editVariety.speciesHint' => 'Search a species…',
|
||||||
'addLot.title' => 'Add lot',
|
'addLot.title' => 'Add lot',
|
||||||
'addLot.year' => 'Harvest year',
|
'addLot.year' => 'Harvest year',
|
||||||
'addLot.quantity' => 'Quantity',
|
'addLot.quantity' => 'Quantity',
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,8 @@ class _Translations$editVariety$es extends Translations$editVariety$en {
|
||||||
@override String get name => 'Nombre';
|
@override String get name => 'Nombre';
|
||||||
@override String get category => 'Categoría';
|
@override String get category => 'Categoría';
|
||||||
@override String get notes => 'Notas';
|
@override String get notes => 'Notas';
|
||||||
|
@override String get species => 'Especie (del catálogo)';
|
||||||
|
@override String get speciesHint => 'Buscar una especie…';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: addLot
|
// Path: addLot
|
||||||
|
|
@ -210,6 +212,8 @@ extension on TranslationsEs {
|
||||||
'editVariety.name' => 'Nombre',
|
'editVariety.name' => 'Nombre',
|
||||||
'editVariety.category' => 'Categoría',
|
'editVariety.category' => 'Categoría',
|
||||||
'editVariety.notes' => 'Notas',
|
'editVariety.notes' => 'Notas',
|
||||||
|
'editVariety.species' => 'Especie (del catálogo)',
|
||||||
|
'editVariety.speciesHint' => 'Buscar una especie…',
|
||||||
'addLot.title' => 'Añadir lote',
|
'addLot.title' => 'Añadir lote',
|
||||||
'addLot.year' => 'Año de cosecha',
|
'addLot.year' => 'Año de cosecha',
|
||||||
'addLot.quantity' => 'Cantidad',
|
'addLot.quantity' => 'Cantidad',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'data/species_repository.dart';
|
||||||
import 'data/variety_repository.dart';
|
import 'data/variety_repository.dart';
|
||||||
import 'di/injector.dart';
|
import 'di/injector.dart';
|
||||||
import 'i18n/strings.g.dart';
|
import 'i18n/strings.g.dart';
|
||||||
|
|
@ -10,6 +11,11 @@ Future<void> main() async {
|
||||||
LocaleSettings.useDeviceLocaleSync();
|
LocaleSettings.useDeviceLocaleSync();
|
||||||
await configureDependencies();
|
await configureDependencies();
|
||||||
runApp(
|
runApp(
|
||||||
TranslationProvider(child: TaneApp(repository: getIt<VarietyRepository>())),
|
TranslationProvider(
|
||||||
|
child: TaneApp(
|
||||||
|
repository: getIt<VarietyRepository>(),
|
||||||
|
species: getIt<SpeciesRepository>(),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,9 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
|
||||||
Future<void> addLot({int? year, Quantity? quantity}) =>
|
Future<void> addLot({int? year, Quantity? quantity}) =>
|
||||||
_repo.addLot(varietyId: varietyId, harvestYear: year, quantity: quantity);
|
_repo.addLot(varietyId: varietyId, harvestYear: year, quantity: quantity);
|
||||||
|
|
||||||
|
Future<void> linkSpecies(String speciesId) =>
|
||||||
|
_repo.linkSpecies(varietyId, speciesId);
|
||||||
|
|
||||||
Future<void> deleteVariety() async {
|
Future<void> deleteVariety() async {
|
||||||
await _repo.softDeleteVariety(varietyId);
|
await _repo.softDeleteVariety(varietyId);
|
||||||
emit(
|
emit(
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:commons_core/commons_core.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
import '../data/species_repository.dart';
|
||||||
import '../data/variety_repository.dart';
|
import '../data/variety_repository.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../state/variety_detail_cubit.dart';
|
import '../state/variety_detail_cubit.dart';
|
||||||
|
|
@ -53,7 +54,12 @@ class _DetailView extends StatelessWidget {
|
||||||
key: const Key('detail.edit'),
|
key: const Key('detail.edit'),
|
||||||
icon: const Icon(Icons.edit_outlined),
|
icon: const Icon(Icons.edit_outlined),
|
||||||
tooltip: t.common.edit,
|
tooltip: t.common.edit,
|
||||||
onPressed: () => _showEditSheet(context, cubit, detail),
|
onPressed: () => _showEditSheet(
|
||||||
|
context,
|
||||||
|
cubit,
|
||||||
|
detail,
|
||||||
|
context.read<SpeciesRepository>(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
key: const Key('detail.delete'),
|
key: const Key('detail.delete'),
|
||||||
|
|
@ -66,6 +72,16 @@ class _DetailView extends StatelessWidget {
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
|
if (detail.scientificName != null) ...[
|
||||||
|
Text(
|
||||||
|
detail.scientificName!,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
if (detail.photo != null)
|
if (detail.photo != null)
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
|
@ -179,20 +195,95 @@ Future<void> _showEditSheet(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
VarietyDetailCubit cubit,
|
VarietyDetailCubit cubit,
|
||||||
VarietyDetail detail,
|
VarietyDetail detail,
|
||||||
|
SpeciesRepository species,
|
||||||
) {
|
) {
|
||||||
final t = context.t;
|
|
||||||
final nameController = TextEditingController(text: detail.label);
|
|
||||||
final categoryController = TextEditingController(text: detail.category ?? '');
|
|
||||||
final notesController = TextEditingController(text: detail.notes ?? '');
|
|
||||||
return showModalBottomSheet<void>(
|
return showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (sheetContext) => Padding(
|
builder: (_) =>
|
||||||
|
_EditVarietySheet(cubit: cubit, detail: detail, species: species),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The edit sheet, stateful so it can search the species catalog live and link
|
||||||
|
/// the picked species on save.
|
||||||
|
class _EditVarietySheet extends StatefulWidget {
|
||||||
|
const _EditVarietySheet({
|
||||||
|
required this.cubit,
|
||||||
|
required this.detail,
|
||||||
|
required this.species,
|
||||||
|
});
|
||||||
|
|
||||||
|
final VarietyDetailCubit cubit;
|
||||||
|
final VarietyDetail detail;
|
||||||
|
final SpeciesRepository species;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_EditVarietySheet> createState() => _EditVarietySheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EditVarietySheetState extends State<_EditVarietySheet> {
|
||||||
|
late final TextEditingController _name = TextEditingController(
|
||||||
|
text: widget.detail.label,
|
||||||
|
);
|
||||||
|
late final TextEditingController _category = TextEditingController(
|
||||||
|
text: widget.detail.category ?? '',
|
||||||
|
);
|
||||||
|
late final TextEditingController _notes = TextEditingController(
|
||||||
|
text: widget.detail.notes ?? '',
|
||||||
|
);
|
||||||
|
late final TextEditingController _speciesField = TextEditingController(
|
||||||
|
text: widget.detail.scientificName ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
List<SpeciesMatch> _suggestions = const [];
|
||||||
|
String? _pickedSpeciesId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_name.dispose();
|
||||||
|
_category.dispose();
|
||||||
|
_notes.dispose();
|
||||||
|
_speciesField.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSpeciesQuery(String query) async {
|
||||||
|
final lang = Localizations.localeOf(context).languageCode;
|
||||||
|
final results = await widget.species.search(query, languageCode: lang);
|
||||||
|
if (mounted) setState(() => _suggestions = results);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pick(SpeciesMatch match) {
|
||||||
|
setState(() {
|
||||||
|
_pickedSpeciesId = match.id;
|
||||||
|
_speciesField.text = match.displayLabel;
|
||||||
|
_suggestions = const [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _save() {
|
||||||
|
final name = _name.text.trim();
|
||||||
|
widget.cubit.updateFields(
|
||||||
|
label: name.isEmpty ? null : name,
|
||||||
|
category: _nullIfBlank(_category.text),
|
||||||
|
notes: _nullIfBlank(_notes.text),
|
||||||
|
);
|
||||||
|
if (_pickedSpeciesId != null) {
|
||||||
|
widget.cubit.linkSpecies(_pickedSpeciesId!);
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = context.t;
|
||||||
|
return Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
left: 16,
|
left: 16,
|
||||||
right: 16,
|
right: 16,
|
||||||
top: 16,
|
top: 16,
|
||||||
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 16,
|
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
@ -200,12 +291,12 @@ Future<void> _showEditSheet(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
t.editVariety.title,
|
t.editVariety.title,
|
||||||
style: Theme.of(sheetContext).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
key: const Key('editVariety.name'),
|
key: const Key('editVariety.name'),
|
||||||
controller: nameController,
|
controller: _name,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: t.editVariety.name,
|
labelText: t.editVariety.name,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
|
|
@ -213,7 +304,27 @@ Future<void> _showEditSheet(
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: categoryController,
|
key: const Key('editVariety.species'),
|
||||||
|
controller: _speciesField,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: t.editVariety.species,
|
||||||
|
hintText: t.editVariety.speciesHint,
|
||||||
|
prefixIcon: const Icon(Icons.eco_outlined),
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onChanged: _onSpeciesQuery,
|
||||||
|
),
|
||||||
|
for (final match in _suggestions)
|
||||||
|
ListTile(
|
||||||
|
dense: true,
|
||||||
|
key: Key('species.option.${match.id}'),
|
||||||
|
title: Text(match.displayLabel),
|
||||||
|
subtitle: match.family == null ? null : Text(match.family!),
|
||||||
|
onTap: () => _pick(match),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _category,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: t.editVariety.category,
|
labelText: t.editVariety.category,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
|
|
@ -221,7 +332,7 @@ Future<void> _showEditSheet(
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: notesController,
|
controller: _notes,
|
||||||
minLines: 2,
|
minLines: 2,
|
||||||
maxLines: 5,
|
maxLines: 5,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
|
|
@ -233,29 +344,21 @@ Future<void> _showEditSheet(
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(sheetContext).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
child: Text(t.common.cancel),
|
child: Text(t.common.cancel),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
key: const Key('editVariety.save'),
|
key: const Key('editVariety.save'),
|
||||||
onPressed: () {
|
onPressed: _save,
|
||||||
final name = nameController.text.trim();
|
|
||||||
cubit.updateFields(
|
|
||||||
label: name.isEmpty ? null : name,
|
|
||||||
category: _nullIfBlank(categoryController.text),
|
|
||||||
notes: _nullIfBlank(notesController.text),
|
|
||||||
);
|
|
||||||
Navigator.of(sheetContext).pop();
|
|
||||||
},
|
|
||||||
child: Text(t.common.save),
|
child: Text(t.common.save),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) {
|
Future<void> _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) {
|
||||||
|
|
|
||||||
|
|
@ -63,3 +63,5 @@ dev_dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
# Seed strings for slang live under lib/i18n (not a Flutter asset; compiled).
|
# Seed strings for slang live under lib/i18n (not a Flutter asset; compiled).
|
||||||
|
assets:
|
||||||
|
- assets/catalog/species.json
|
||||||
|
|
|
||||||
83
apps/app_seeds/test/data/species_repository_test.dart
Normal file
83
apps/app_seeds/test/data/species_repository_test.dart
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/data/species_catalog.dart';
|
||||||
|
import 'package:tane/data/species_repository.dart';
|
||||||
|
import 'package:tane/db/database.dart';
|
||||||
|
|
||||||
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
|
const _seeds = [
|
||||||
|
SpeciesSeed(
|
||||||
|
scientificName: 'Solanum lycopersicum',
|
||||||
|
family: 'Solanaceae',
|
||||||
|
commonNames: {
|
||||||
|
'en': ['Tomato'],
|
||||||
|
'es': ['Tomate'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SpeciesSeed(
|
||||||
|
scientificName: 'Phaseolus vulgaris',
|
||||||
|
family: 'Fabaceae',
|
||||||
|
commonNames: {
|
||||||
|
'en': ['Common bean'],
|
||||||
|
'es': ['Judía', 'Alubia'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
late SpeciesRepository repo;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
db = newTestDatabase();
|
||||||
|
repo = SpeciesRepository(db, idGen: IdGen());
|
||||||
|
});
|
||||||
|
tearDown(() => db.close());
|
||||||
|
|
||||||
|
test(
|
||||||
|
'parseSpeciesCatalog reads scientific name, family and common names',
|
||||||
|
() {
|
||||||
|
const json = '''
|
||||||
|
{"version":1,"species":[
|
||||||
|
{"scientific_name":"Zea mays","family":"Poaceae","common":{"en":["Maize"],"es":["Maíz"]}}
|
||||||
|
]}''';
|
||||||
|
final seeds = parseSpeciesCatalog(json);
|
||||||
|
expect(seeds.single.scientificName, 'Zea mays');
|
||||||
|
expect(seeds.single.family, 'Poaceae');
|
||||||
|
expect(seeds.single.commonNames['es'], ['Maíz']);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('seedBundled inserts species and is idempotent', () async {
|
||||||
|
await repo.seedBundled(_seeds);
|
||||||
|
await repo.seedBundled(_seeds); // second run must not duplicate
|
||||||
|
|
||||||
|
final species = await db.select(db.species).get();
|
||||||
|
expect(species.length, 2);
|
||||||
|
expect(species.every((s) => s.isBundled), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search matches by scientific name', () async {
|
||||||
|
await repo.seedBundled(_seeds);
|
||||||
|
final results = await repo.search('phaseolus');
|
||||||
|
expect(results.single.scientificName, 'Phaseolus vulgaris');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search matches by common name and returns a localized label', () async {
|
||||||
|
await repo.seedBundled(_seeds);
|
||||||
|
|
||||||
|
final es = await repo.search('alubia', languageCode: 'es');
|
||||||
|
expect(es.single.scientificName, 'Phaseolus vulgaris');
|
||||||
|
expect(es.single.commonName, anyOf('Judía', 'Alubia'));
|
||||||
|
|
||||||
|
final en = await repo.search('tom', languageCode: 'en');
|
||||||
|
expect(en.single.commonName, 'Tomato');
|
||||||
|
expect(en.single.displayLabel, 'Tomato (Solanum lycopersicum)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty query returns nothing', () async {
|
||||||
|
await repo.seedBundled(_seeds);
|
||||||
|
expect(await repo.search(' '), isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
import 'package:async/async.dart';
|
import 'package:async/async.dart';
|
||||||
import 'package:commons_core/commons_core.dart';
|
import 'package:commons_core/commons_core.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/data/species_repository.dart';
|
||||||
import 'package:tane/data/variety_repository.dart';
|
import 'package:tane/data/variety_repository.dart';
|
||||||
import 'package:tane/db/database.dart';
|
import 'package:tane/db/database.dart';
|
||||||
|
|
||||||
import '../support/test_support.dart';
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
|
const _tomatoSeed = SpeciesSeed(
|
||||||
|
scientificName: 'Solanum lycopersicum',
|
||||||
|
family: 'Solanaceae',
|
||||||
|
commonNames: {
|
||||||
|
'en': ['Tomato'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late AppDatabase db;
|
late AppDatabase db;
|
||||||
late VarietyRepository repo;
|
late VarietyRepository repo;
|
||||||
|
|
@ -60,4 +69,32 @@ void main() {
|
||||||
expect(await repo.watchInventory().first, isEmpty);
|
expect(await repo.watchInventory().first, isEmpty);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'linkSpecies exposes the scientific name and prefills empty category',
|
||||||
|
() async {
|
||||||
|
final species = newTestSpeciesRepository(db);
|
||||||
|
await species.seedBundled(const [_tomatoSeed]);
|
||||||
|
final match = (await species.search('tomato')).single;
|
||||||
|
|
||||||
|
final id = await repo.addQuickVariety(label: "Grandma's tomato");
|
||||||
|
await repo.linkSpecies(id, match.id);
|
||||||
|
|
||||||
|
final detail = await repo.watchVariety(id).first;
|
||||||
|
expect(detail!.scientificName, 'Solanum lycopersicum');
|
||||||
|
expect(detail.category, 'Solanaceae'); // prefilled from family
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('linkSpecies does not overwrite an existing category', () async {
|
||||||
|
final species = newTestSpeciesRepository(db);
|
||||||
|
await species.seedBundled(const [_tomatoSeed]);
|
||||||
|
final match = (await species.search('tomato')).single;
|
||||||
|
|
||||||
|
final id = await repo.addQuickVariety(label: 'T', category: 'My tomatoes');
|
||||||
|
await repo.linkSpecies(id, match.id);
|
||||||
|
|
||||||
|
final detail = await repo.watchVariety(id).first;
|
||||||
|
expect(detail!.category, 'My tomatoes');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/data/species_repository.dart';
|
||||||
import 'package:tane/data/variety_repository.dart';
|
import 'package:tane/data/variety_repository.dart';
|
||||||
import 'package:tane/db/database.dart';
|
import 'package:tane/db/database.dart';
|
||||||
import 'package:tane/i18n/strings.g.dart';
|
import 'package:tane/i18n/strings.g.dart';
|
||||||
|
|
@ -29,6 +30,9 @@ VarietyRepository newTestRepository(
|
||||||
String nodeId = 'test-node',
|
String nodeId = 'test-node',
|
||||||
}) => VarietyRepository(db, idGen: IdGen(), nodeId: nodeId);
|
}) => VarietyRepository(db, idGen: IdGen(), nodeId: nodeId);
|
||||||
|
|
||||||
|
SpeciesRepository newTestSpeciesRepository(AppDatabase db) =>
|
||||||
|
SpeciesRepository(db, idGen: IdGen());
|
||||||
|
|
||||||
/// Wraps [child] with the providers a screen expects (repository, inventory
|
/// Wraps [child] with the providers a screen expects (repository, inventory
|
||||||
/// cubit) plus i18n and Material localizations, pinned to [locale].
|
/// cubit) plus i18n and Material localizations, pinned to [locale].
|
||||||
Widget wrapScreen({
|
Widget wrapScreen({
|
||||||
|
|
@ -61,12 +65,16 @@ Widget wrapScreen({
|
||||||
Widget wrapDetail({
|
Widget wrapDetail({
|
||||||
required VarietyRepository repository,
|
required VarietyRepository repository,
|
||||||
required String varietyId,
|
required String varietyId,
|
||||||
|
SpeciesRepository? species,
|
||||||
AppLocale locale = AppLocale.en,
|
AppLocale locale = AppLocale.en,
|
||||||
}) {
|
}) {
|
||||||
LocaleSettings.setLocaleSync(locale);
|
LocaleSettings.setLocaleSync(locale);
|
||||||
return TranslationProvider(
|
return TranslationProvider(
|
||||||
child: RepositoryProvider.value(
|
child: MultiRepositoryProvider(
|
||||||
value: repository,
|
providers: [
|
||||||
|
RepositoryProvider.value(value: repository),
|
||||||
|
if (species != null) RepositoryProvider.value(value: species),
|
||||||
|
],
|
||||||
child: BlocProvider(
|
child: BlocProvider(
|
||||||
create: (_) => VarietyDetailCubit(repository, varietyId),
|
create: (_) => VarietyDetailCubit(repository, varietyId),
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,12 @@ void main() {
|
||||||
final repo = newTestRepository(db);
|
final repo = newTestRepository(db);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
TranslationProvider(child: TaneApp(repository: repo)),
|
TranslationProvider(
|
||||||
|
child: TaneApp(
|
||||||
|
repository: repo,
|
||||||
|
species: newTestSpeciesRepository(db),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:commons_core/commons_core.dart';
|
import 'package:commons_core/commons_core.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/data/species_repository.dart';
|
||||||
import 'package:tane/db/database.dart';
|
import 'package:tane/db/database.dart';
|
||||||
|
|
||||||
import '../support/test_support.dart';
|
import '../support/test_support.dart';
|
||||||
|
|
@ -22,7 +23,13 @@ void main() {
|
||||||
quantity: const Quantity(kind: QuantityKind.cob),
|
quantity: const Quantity(kind: QuantityKind.cob),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(wrapDetail(repository: repo, varietyId: id));
|
await tester.pumpWidget(
|
||||||
|
wrapDetail(
|
||||||
|
repository: repo,
|
||||||
|
varietyId: id,
|
||||||
|
species: newTestSpeciesRepository(db),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Maize'), findsOneWidget); // app bar title
|
expect(find.text('Maize'), findsOneWidget); // app bar title
|
||||||
|
|
@ -35,7 +42,13 @@ void main() {
|
||||||
final repo = newTestRepository(db);
|
final repo = newTestRepository(db);
|
||||||
final id = await repo.addQuickVariety(label: 'Old name');
|
final id = await repo.addQuickVariety(label: 'Old name');
|
||||||
|
|
||||||
await tester.pumpWidget(wrapDetail(repository: repo, varietyId: id));
|
await tester.pumpWidget(
|
||||||
|
wrapDetail(
|
||||||
|
repository: repo,
|
||||||
|
varietyId: id,
|
||||||
|
species: newTestSpeciesRepository(db),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('detail.edit')));
|
await tester.tap(find.byKey(const Key('detail.edit')));
|
||||||
|
|
@ -56,7 +69,13 @@ void main() {
|
||||||
final repo = newTestRepository(db);
|
final repo = newTestRepository(db);
|
||||||
final id = await repo.addQuickVariety(label: 'Bean');
|
final id = await repo.addQuickVariety(label: 'Bean');
|
||||||
|
|
||||||
await tester.pumpWidget(wrapDetail(repository: repo, varietyId: id));
|
await tester.pumpWidget(
|
||||||
|
wrapDetail(
|
||||||
|
repository: repo,
|
||||||
|
varietyId: id,
|
||||||
|
species: newTestSpeciesRepository(db),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('No lots yet.'), findsOneWidget);
|
expect(find.text('No lots yet.'), findsOneWidget);
|
||||||
|
|
@ -77,7 +96,13 @@ void main() {
|
||||||
final repo = newTestRepository(db);
|
final repo = newTestRepository(db);
|
||||||
final id = await repo.addQuickVariety(label: 'Doomed');
|
final id = await repo.addQuickVariety(label: 'Doomed');
|
||||||
|
|
||||||
await tester.pumpWidget(wrapDetail(repository: repo, varietyId: id));
|
await tester.pumpWidget(
|
||||||
|
wrapDetail(
|
||||||
|
repository: repo,
|
||||||
|
varietyId: id,
|
||||||
|
species: newTestSpeciesRepository(db),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('detail.delete')));
|
await tester.tap(find.byKey(const Key('detail.delete')));
|
||||||
|
|
@ -89,4 +114,47 @@ void main() {
|
||||||
expect(find.text('This seed is no longer here.'), findsOneWidget);
|
expect(find.text('This seed is no longer here.'), findsOneWidget);
|
||||||
await disposeTree(tester);
|
await disposeTree(tester);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'linking a species from the edit sheet shows its scientific name',
|
||||||
|
(tester) async {
|
||||||
|
final repo = newTestRepository(db);
|
||||||
|
final species = newTestSpeciesRepository(db);
|
||||||
|
await species.seedBundled(const [
|
||||||
|
SpeciesSeed(
|
||||||
|
scientificName: 'Solanum lycopersicum',
|
||||||
|
family: 'Solanaceae',
|
||||||
|
commonNames: {
|
||||||
|
'en': ['Tomato'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
final id = await repo.addQuickVariety(label: "Grandma's tomato");
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrapDetail(repository: repo, varietyId: id, species: species),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('detail.edit')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const Key('editVariety.species')),
|
||||||
|
'toma',
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Tomato (Solanum lycopersicum)'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('editVariety.save')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Detail now shows the scientific name and the family-prefilled category.
|
||||||
|
expect(find.text('Solanum lycopersicum'), findsOneWidget);
|
||||||
|
expect(find.text('Solanaceae'), findsOneWidget);
|
||||||
|
await disposeTree(tester);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue