diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index 24e9fd8..d2aaba1 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -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 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 lots; - final List vernacularNames; + final List 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 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 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 softDeleteVariety(String id) async { diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 86364a5..11265d8 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -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", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 9af374c..e5bc1a6 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -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", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index d201f1d..5adc61f 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -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 diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index f7215d5..4cbd619 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -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', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index afe411b..8f101a5 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -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', diff --git a/apps/app_seeds/lib/state/variety_detail_cubit.dart b/apps/app_seeds/lib/state/variety_detail_cubit.dart index 0ba3ef5..7c43c9a 100644 --- a/apps/app_seeds/lib/state/variety_detail_cubit.dart +++ b/apps/app_seeds/lib/state/variety_detail_cubit.dart @@ -76,6 +76,12 @@ class VarietyDetailCubit extends Cubit { Future deleteLot(String lotId) => _repo.softDeleteLot(lotId); + Future addVernacularName(String name) => + _repo.addVernacularName(varietyId, name); + + Future removeVernacularName(String nameId) => + _repo.removeVernacularName(nameId); + Future linkSpecies(String speciesId) => _repo.linkSpecies(varietyId, speciesId); diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index 79f108d..f732527 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -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 _showLotSheet( ); } +Future _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( + 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; diff --git a/apps/app_seeds/test/data/variety_detail_test.dart b/apps/app_seeds/test/data/variety_detail_test.dart index eea8f55..3dd8ebd 100644 --- a/apps/app_seeds/test/data/variety_detail_test.dart +++ b/apps/app_seeds/test/data/variety_detail_test.dart @@ -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); + }); } diff --git a/apps/app_seeds/test/ui/variety_detail_screen_test.dart b/apps/app_seeds/test/ui/variety_detail_screen_test.dart index de394e6..8d60539 100644 --- a/apps/app_seeds/test/ui/variety_detail_screen_test.dart +++ b/apps/app_seeds/test/ui/variety_detail_screen_test.dart @@ -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); + }); }