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:
vjrj 2026-07-08 11:20:36 +02:00
parent f8d4d9a778
commit 9e5c82184b
10 changed files with 256 additions and 83 deletions

View file

@ -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 {

View file

@ -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",

View file

@ -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",

View file

@ -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

View file

@ -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',

View file

@ -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',

View file

@ -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);

View file

@ -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;

View file

@ -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);
});
} }

View file

@ -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);
});
} }