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).
|
||||
class VarietyDetail extends Equatable {
|
||||
const VarietyDetail({
|
||||
|
|
@ -113,7 +131,7 @@ class VarietyDetail extends Equatable {
|
|||
final String? speciesId;
|
||||
final String? scientificName;
|
||||
final List<VarietyLot> lots;
|
||||
final List<String> vernacularNames;
|
||||
final List<VernacularName> vernacularNames;
|
||||
final Uint8List? photo;
|
||||
|
||||
@override
|
||||
|
|
@ -373,7 +391,16 @@ class VarietyRepository {
|
|||
speciesId: v.speciesId,
|
||||
scientificName: scientificName,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
/// the row survives for correct CRDT merges later.
|
||||
Future<void> softDeleteVariety(String id) async {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
"lots": "Lots",
|
||||
"noLots": "No lots yet.",
|
||||
"names": "Also known as",
|
||||
"addName": "Add name",
|
||||
"notes": "Notes",
|
||||
"addLot": "Add lot",
|
||||
"editLot": "Edit lot",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
"lots": "Lotes",
|
||||
"noLots": "Aún no hay lotes.",
|
||||
"names": "También conocida como",
|
||||
"addName": "Añadir nombre",
|
||||
"notes": "Notas",
|
||||
"addLot": "Añadir lote",
|
||||
"editLot": "Editar lote",
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// 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
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
|
|
|||
|
|
@ -162,6 +162,9 @@ class Translations$detail$en {
|
|||
/// en: 'Also known as'
|
||||
String get names => 'Also known as';
|
||||
|
||||
/// en: 'Add name'
|
||||
String get addName => 'Add name';
|
||||
|
||||
/// en: 'Notes'
|
||||
String get notes => 'Notes';
|
||||
|
||||
|
|
@ -659,6 +662,7 @@ extension on Translations {
|
|||
'detail.lots' => 'Lots',
|
||||
'detail.noLots' => 'No lots yet.',
|
||||
'detail.names' => 'Also known as',
|
||||
'detail.addName' => 'Add name',
|
||||
'detail.notes' => 'Notes',
|
||||
'detail.addLot' => 'Add 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 noLots => 'Aún no hay lotes.';
|
||||
@override String get names => 'También conocida como';
|
||||
@override String get addName => 'Añadir nombre';
|
||||
@override String get notes => 'Notas';
|
||||
@override String get addLot => 'Añadir lote';
|
||||
@override String get editLot => 'Editar lote';
|
||||
|
|
@ -473,6 +474,7 @@ extension on TranslationsEs {
|
|||
'detail.lots' => 'Lotes',
|
||||
'detail.noLots' => 'Aún no hay lotes.',
|
||||
'detail.names' => 'También conocida como',
|
||||
'detail.addName' => 'Añadir nombre',
|
||||
'detail.notes' => 'Notas',
|
||||
'detail.addLot' => 'Añadir lote',
|
||||
'detail.editLot' => 'Editar lote',
|
||||
|
|
|
|||
|
|
@ -76,6 +76,12 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
|
|||
|
||||
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) =>
|
||||
_repo.linkSpecies(varietyId, speciesId);
|
||||
|
||||
|
|
|
|||
|
|
@ -76,17 +76,28 @@ class _DetailView extends StatelessWidget {
|
|||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_DetailHeader(detail: detail),
|
||||
if (detail.vernacularNames.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_SectionTitle(t.detail.names),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final name in detail.vernacularNames)
|
||||
Chip(label: Text(name)),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_SectionTitle(t.detail.names),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
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) ...[
|
||||
const SizedBox(height: 16),
|
||||
_SectionTitle(t.detail.notes),
|
||||
|
|
@ -400,77 +411,79 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
|
|||
top: 16,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
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(),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
t.editVariety.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
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(
|
||||
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 SizedBox(height: 12),
|
||||
TextField(
|
||||
key: const Key('editVariety.name'),
|
||||
controller: _name,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.editVariety.name,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
key: const Key('editVariety.save'),
|
||||
onPressed: _save,
|
||||
child: Text(t.common.save),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
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(
|
||||
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) {
|
||||
final trimmed = value.trim();
|
||||
return trimmed.isEmpty ? null : trimmed;
|
||||
|
|
|
|||
|
|
@ -171,4 +171,17 @@ void main() {
|
|||
|
||||
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);
|
||||
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