feat(inventory): editable vernacular (common) names
The "also known as" names were read-only; now they can be added and removed: - Repository: addVernacularName + removeVernacularName (soft delete); VarietyDetail.vernacularNames carries ids (new VernacularName model). - Cubit: addVernacularName / removeVernacularName. - Detail: deletable name chips + an "Add name" chip opening a small dialog; the section is always visible so names can be added when empty. Tests: repo add/remove + widget add/remove. 44 green.
This commit is contained in:
parent
f8d4d9a778
commit
9e5c82184b
10 changed files with 256 additions and 83 deletions
|
|
@ -92,6 +92,24 @@ class VarietyLot extends Equatable {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One vernacular (common) name of a variety.
|
||||||
|
class VernacularName extends Equatable {
|
||||||
|
const VernacularName({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.language,
|
||||||
|
this.region,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String? language;
|
||||||
|
final String? region;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, name, language, region];
|
||||||
|
}
|
||||||
|
|
||||||
/// The full detail of one variety (identity + its lots, names and first photo).
|
/// The full detail of one variety (identity + its lots, names and first photo).
|
||||||
class VarietyDetail extends Equatable {
|
class VarietyDetail extends Equatable {
|
||||||
const VarietyDetail({
|
const VarietyDetail({
|
||||||
|
|
@ -113,7 +131,7 @@ class VarietyDetail extends Equatable {
|
||||||
final String? speciesId;
|
final String? speciesId;
|
||||||
final String? scientificName;
|
final String? scientificName;
|
||||||
final List<VarietyLot> lots;
|
final List<VarietyLot> lots;
|
||||||
final List<String> vernacularNames;
|
final List<VernacularName> vernacularNames;
|
||||||
final Uint8List? photo;
|
final Uint8List? photo;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -373,7 +391,16 @@ class VarietyRepository {
|
||||||
speciesId: v.speciesId,
|
speciesId: v.speciesId,
|
||||||
scientificName: scientificName,
|
scientificName: scientificName,
|
||||||
lots: lots.map((l) => _toLot(l, testsByLot[l.id] ?? const [])).toList(),
|
lots: lots.map((l) => _toLot(l, testsByLot[l.id] ?? const [])).toList(),
|
||||||
vernacularNames: names.map((n) => n.name).toList(),
|
vernacularNames: names
|
||||||
|
.map(
|
||||||
|
(n) => VernacularName(
|
||||||
|
id: n.id,
|
||||||
|
name: n.name,
|
||||||
|
language: n.language,
|
||||||
|
region: n.region,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
photo: photos.isEmpty ? null : photos.first.bytes,
|
photo: photos.isEmpty ? null : photos.first.bytes,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -491,6 +518,46 @@ class VarietyRepository {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds a vernacular (common) name to a variety. Returns the new row id.
|
||||||
|
Future<String> addVernacularName(
|
||||||
|
String varietyId,
|
||||||
|
String name, {
|
||||||
|
String? language,
|
||||||
|
String? region,
|
||||||
|
}) async {
|
||||||
|
final (created, updated) = _stamp();
|
||||||
|
final id = idGen.newId();
|
||||||
|
await _db
|
||||||
|
.into(_db.varietyVernacularNames)
|
||||||
|
.insert(
|
||||||
|
VarietyVernacularNamesCompanion.insert(
|
||||||
|
id: id,
|
||||||
|
varietyId: varietyId,
|
||||||
|
name: name,
|
||||||
|
createdAt: created,
|
||||||
|
updatedAt: updated,
|
||||||
|
lastAuthor: nodeId,
|
||||||
|
language: Value(language),
|
||||||
|
region: Value(region),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-deletes a vernacular name (tombstone).
|
||||||
|
Future<void> removeVernacularName(String nameId) async {
|
||||||
|
final (_, updated) = _stamp();
|
||||||
|
await (_db.update(
|
||||||
|
_db.varietyVernacularNames,
|
||||||
|
)..where((n) => n.id.equals(nameId))).write(
|
||||||
|
VarietyVernacularNamesCompanion(
|
||||||
|
isDeleted: const Value(true),
|
||||||
|
updatedAt: Value(updated),
|
||||||
|
lastAuthor: Value(nodeId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Soft-deletes a variety (tombstone); it disappears from the inventory but
|
/// Soft-deletes a variety (tombstone); it disappears from the inventory but
|
||||||
/// the row survives for correct CRDT merges later.
|
/// the row survives for correct CRDT merges later.
|
||||||
Future<void> softDeleteVariety(String id) async {
|
Future<void> softDeleteVariety(String id) async {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
"lots": "Lots",
|
"lots": "Lots",
|
||||||
"noLots": "No lots yet.",
|
"noLots": "No lots yet.",
|
||||||
"names": "Also known as",
|
"names": "Also known as",
|
||||||
|
"addName": "Add name",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"addLot": "Add lot",
|
"addLot": "Add lot",
|
||||||
"editLot": "Edit lot",
|
"editLot": "Edit lot",
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
"lots": "Lotes",
|
"lots": "Lotes",
|
||||||
"noLots": "Aún no hay lotes.",
|
"noLots": "Aún no hay lotes.",
|
||||||
"names": "También conocida como",
|
"names": "También conocida como",
|
||||||
|
"addName": "Añadir nombre",
|
||||||
"notes": "Notas",
|
"notes": "Notas",
|
||||||
"addLot": "Añadir lote",
|
"addLot": "Añadir lote",
|
||||||
"editLot": "Editar lote",
|
"editLot": "Editar lote",
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 2
|
/// Locales: 2
|
||||||
/// Strings: 184 (92 per locale)
|
/// Strings: 186 (93 per locale)
|
||||||
///
|
///
|
||||||
/// Built on 2026-07-08 at 09:07 UTC
|
/// Built on 2026-07-08 at 09:16 UTC
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,9 @@ class Translations$detail$en {
|
||||||
/// en: 'Also known as'
|
/// en: 'Also known as'
|
||||||
String get names => 'Also known as';
|
String get names => 'Also known as';
|
||||||
|
|
||||||
|
/// en: 'Add name'
|
||||||
|
String get addName => 'Add name';
|
||||||
|
|
||||||
/// en: 'Notes'
|
/// en: 'Notes'
|
||||||
String get notes => 'Notes';
|
String get notes => 'Notes';
|
||||||
|
|
||||||
|
|
@ -659,6 +662,7 @@ extension on Translations {
|
||||||
'detail.lots' => 'Lots',
|
'detail.lots' => 'Lots',
|
||||||
'detail.noLots' => 'No lots yet.',
|
'detail.noLots' => 'No lots yet.',
|
||||||
'detail.names' => 'Also known as',
|
'detail.names' => 'Also known as',
|
||||||
|
'detail.addName' => 'Add name',
|
||||||
'detail.notes' => 'Notes',
|
'detail.notes' => 'Notes',
|
||||||
'detail.addLot' => 'Add lot',
|
'detail.addLot' => 'Add lot',
|
||||||
'detail.editLot' => 'Edit lot',
|
'detail.editLot' => 'Edit lot',
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ class _Translations$detail$es extends Translations$detail$en {
|
||||||
@override String get lots => 'Lotes';
|
@override String get lots => 'Lotes';
|
||||||
@override String get noLots => 'Aún no hay lotes.';
|
@override String get noLots => 'Aún no hay lotes.';
|
||||||
@override String get names => 'También conocida como';
|
@override String get names => 'También conocida como';
|
||||||
|
@override String get addName => 'Añadir nombre';
|
||||||
@override String get notes => 'Notas';
|
@override String get notes => 'Notas';
|
||||||
@override String get addLot => 'Añadir lote';
|
@override String get addLot => 'Añadir lote';
|
||||||
@override String get editLot => 'Editar lote';
|
@override String get editLot => 'Editar lote';
|
||||||
|
|
@ -473,6 +474,7 @@ extension on TranslationsEs {
|
||||||
'detail.lots' => 'Lotes',
|
'detail.lots' => 'Lotes',
|
||||||
'detail.noLots' => 'Aún no hay lotes.',
|
'detail.noLots' => 'Aún no hay lotes.',
|
||||||
'detail.names' => 'También conocida como',
|
'detail.names' => 'También conocida como',
|
||||||
|
'detail.addName' => 'Añadir nombre',
|
||||||
'detail.notes' => 'Notas',
|
'detail.notes' => 'Notas',
|
||||||
'detail.addLot' => 'Añadir lote',
|
'detail.addLot' => 'Añadir lote',
|
||||||
'detail.editLot' => 'Editar lote',
|
'detail.editLot' => 'Editar lote',
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,12 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
|
||||||
|
|
||||||
Future<void> deleteLot(String lotId) => _repo.softDeleteLot(lotId);
|
Future<void> deleteLot(String lotId) => _repo.softDeleteLot(lotId);
|
||||||
|
|
||||||
|
Future<void> addVernacularName(String name) =>
|
||||||
|
_repo.addVernacularName(varietyId, name);
|
||||||
|
|
||||||
|
Future<void> removeVernacularName(String nameId) =>
|
||||||
|
_repo.removeVernacularName(nameId);
|
||||||
|
|
||||||
Future<void> linkSpecies(String speciesId) =>
|
Future<void> linkSpecies(String speciesId) =>
|
||||||
_repo.linkSpecies(varietyId, speciesId);
|
_repo.linkSpecies(varietyId, speciesId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,17 +76,28 @@ class _DetailView extends StatelessWidget {
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
_DetailHeader(detail: detail),
|
_DetailHeader(detail: detail),
|
||||||
if (detail.vernacularNames.isNotEmpty) ...[
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 16),
|
_SectionTitle(t.detail.names),
|
||||||
_SectionTitle(t.detail.names),
|
Wrap(
|
||||||
Wrap(
|
spacing: 8,
|
||||||
spacing: 8,
|
runSpacing: 4,
|
||||||
children: [
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
for (final name in detail.vernacularNames)
|
children: [
|
||||||
Chip(label: Text(name)),
|
for (final name in detail.vernacularNames)
|
||||||
],
|
InputChip(
|
||||||
),
|
key: Key('name.${name.id}'),
|
||||||
],
|
label: Text(name.name),
|
||||||
|
deleteIcon: const Icon(Icons.close, size: 18),
|
||||||
|
onDeleted: () => cubit.removeVernacularName(name.id),
|
||||||
|
),
|
||||||
|
ActionChip(
|
||||||
|
key: const Key('name.add'),
|
||||||
|
avatar: const Icon(Icons.add, size: 18),
|
||||||
|
label: Text(t.detail.addName),
|
||||||
|
onPressed: () => _showAddNameDialog(context, cubit),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
if (detail.notes != null && detail.notes!.trim().isNotEmpty) ...[
|
if (detail.notes != null && detail.notes!.trim().isNotEmpty) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_SectionTitle(t.detail.notes),
|
_SectionTitle(t.detail.notes),
|
||||||
|
|
@ -400,77 +411,79 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
|
||||||
top: 16,
|
top: 16,
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: SingleChildScrollView(
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
Text(
|
children: [
|
||||||
t.editVariety.title,
|
Text(
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
t.editVariety.title,
|
||||||
),
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
|
||||||
key: const Key('editVariety.name'),
|
|
||||||
controller: _name,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: t.editVariety.name,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
const SizedBox(height: 12),
|
TextField(
|
||||||
TextField(
|
key: const Key('editVariety.name'),
|
||||||
key: const Key('editVariety.species'),
|
controller: _name,
|
||||||
controller: _speciesField,
|
decoration: InputDecoration(
|
||||||
decoration: InputDecoration(
|
labelText: t.editVariety.name,
|
||||||
labelText: t.editVariety.species,
|
border: const OutlineInputBorder(),
|
||||||
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(
|
|
||||||
labelText: t.editVariety.category,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextField(
|
|
||||||
controller: _notes,
|
|
||||||
minLines: 2,
|
|
||||||
maxLines: 5,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: t.editVariety.notes,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
child: Text(t.common.cancel),
|
|
||||||
),
|
),
|
||||||
const Spacer(),
|
),
|
||||||
FilledButton(
|
const SizedBox(height: 12),
|
||||||
key: const Key('editVariety.save'),
|
TextField(
|
||||||
onPressed: _save,
|
key: const Key('editVariety.species'),
|
||||||
child: Text(t.common.save),
|
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(
|
||||||
|
labelText: t.editVariety.category,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _notes,
|
||||||
|
minLines: 2,
|
||||||
|
maxLines: 5,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: t.editVariety.notes,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text(t.common.cancel),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
FilledButton(
|
||||||
|
key: const Key('editVariety.save'),
|
||||||
|
onPressed: _save,
|
||||||
|
child: Text(t.common.save),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -598,6 +611,44 @@ Future<void> _showLotSheet(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showAddNameDialog(
|
||||||
|
BuildContext context,
|
||||||
|
VarietyDetailCubit cubit,
|
||||||
|
) {
|
||||||
|
final t = context.t;
|
||||||
|
final controller = TextEditingController();
|
||||||
|
void submit(BuildContext dialogContext) {
|
||||||
|
final name = controller.text.trim();
|
||||||
|
if (name.isNotEmpty) cubit.addVernacularName(name);
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: Text(t.detail.addName),
|
||||||
|
content: TextField(
|
||||||
|
key: const Key('name.field'),
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: InputDecoration(labelText: t.editVariety.name),
|
||||||
|
onSubmitted: (_) => submit(dialogContext),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: Text(t.common.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
key: const Key('name.save'),
|
||||||
|
onPressed: () => submit(dialogContext),
|
||||||
|
child: Text(t.common.save),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
String? _nullIfBlank(String value) {
|
String? _nullIfBlank(String value) {
|
||||||
final trimmed = value.trim();
|
final trimmed = value.trim();
|
||||||
return trimmed.isEmpty ? null : trimmed;
|
return trimmed.isEmpty ? null : trimmed;
|
||||||
|
|
|
||||||
|
|
@ -171,4 +171,17 @@ void main() {
|
||||||
|
|
||||||
expect((await repo.watchVariety(id).first)!.lots, isEmpty);
|
expect((await repo.watchVariety(id).first)!.lots, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('add and remove vernacular names', () async {
|
||||||
|
final id = await repo.addQuickVariety(label: 'Maize');
|
||||||
|
final nameId = await repo.addVernacularName(id, 'Maíz');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await repo.watchVariety(id).first)!.vernacularNames.map((n) => n.name),
|
||||||
|
['Maíz'],
|
||||||
|
);
|
||||||
|
|
||||||
|
await repo.removeVernacularName(nameId);
|
||||||
|
expect((await repo.watchVariety(id).first)!.vernacularNames, isEmpty);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -255,4 +255,32 @@ void main() {
|
||||||
expect(find.text('No lots yet.'), findsOneWidget);
|
expect(find.text('No lots yet.'), findsOneWidget);
|
||||||
await disposeTree(tester);
|
await disposeTree(tester);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('adding and removing a vernacular name', (tester) async {
|
||||||
|
final repo = newTestRepository(db);
|
||||||
|
final id = await repo.addQuickVariety(label: 'Maize');
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrapDetail(
|
||||||
|
repository: repo,
|
||||||
|
varietyId: id,
|
||||||
|
species: newTestSpeciesRepository(db),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('name.add')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byKey(const Key('name.field')), 'Maíz');
|
||||||
|
await tester.tap(find.byKey(const Key('name.save')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Maíz'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.close)); // the chip's delete icon
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Maíz'), findsNothing);
|
||||||
|
await disposeTree(tester);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue