From f8d4d9a7783344e70215a8a93add810c804af741 Mon Sep 17 00:00:00 2001 From: vjrj Date: Wed, 8 Jul 2026 11:10:43 +0200 Subject: [PATCH] feat(inventory): edit and delete existing lots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lots could only be created; now they can be edited and removed: - Repository: updateLot (LWW) + softDeleteLot (tombstone). - Cubit: updateLot / deleteLot. - The add-lot sheet is now a shared add/edit sheet — tapping a lot opens it prefilled (type, year, quantity), with a trash action to delete. The reactive detail view refreshes on save/delete. Tests: repo updateLot/softDeleteLot + widget edit-lot and delete-lot. 42 green. --- .../lib/data/variety_repository.dart | 36 +++++++++++ apps/app_seeds/lib/i18n/en.i18n.json | 1 + apps/app_seeds/lib/i18n/es.i18n.json | 1 + apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_en.g.dart | 4 ++ apps/app_seeds/lib/i18n/strings_es.g.dart | 2 + .../lib/state/variety_detail_cubit.dart | 14 +++++ .../lib/ui/variety_detail_screen.dart | 59 ++++++++++++++----- .../test/data/variety_detail_test.dart | 32 ++++++++++ .../test/ui/variety_detail_screen_test.dart | 55 +++++++++++++++++ 10 files changed, 192 insertions(+), 16 deletions(-) diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index 187156c..24e9fd8 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -455,6 +455,42 @@ class VarietyRepository { return id; } + /// Updates an existing lot's fields (LWW). The [quantity] and [harvestYear] + /// are set as given (null clears them). + Future updateLot({ + required String lotId, + required LotType type, + int? harvestYear, + Quantity? quantity, + String? storageLocation, + }) async { + final (_, updated) = _stamp(); + await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write( + LotsCompanion( + type: Value(type), + harvestYear: Value(harvestYear), + quantityKind: Value(quantity?.kind.name), + quantityPrecise: Value(quantity?.count?.toDouble()), + quantityLabel: Value(quantity?.label), + storageLocation: Value(storageLocation), + updatedAt: Value(updated), + lastAuthor: Value(nodeId), + ), + ); + } + + /// Soft-deletes a lot (tombstone); it leaves the variety's lot list. + Future softDeleteLot(String lotId) async { + final (_, updated) = _stamp(); + await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write( + LotsCompanion( + 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 d0f99e3..86364a5 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -32,6 +32,7 @@ "names": "Also known as", "notes": "Notes", "addLot": "Add lot", + "editLot": "Edit lot", "deleteConfirm": "Delete this seed?", "year": "Year {year}", "noYear": "Year unknown" diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 6a95ae4..9af374c 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -32,6 +32,7 @@ "names": "También conocida como", "notes": "Notas", "addLot": "Añadir lote", + "editLot": "Editar lote", "deleteConfirm": "¿Eliminar esta semilla?", "year": "Año {year}", "noYear": "Año desconocido" diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 789532d..d201f1d 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: 182 (91 per locale) +/// Strings: 184 (92 per locale) /// -/// Built on 2026-07-08 at 08:39 UTC +/// Built on 2026-07-08 at 09:07 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 e206959..f7215d5 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -168,6 +168,9 @@ class Translations$detail$en { /// en: 'Add lot' String get addLot => 'Add lot'; + /// en: 'Edit lot' + String get editLot => 'Edit lot'; + /// en: 'Delete this seed?' String get deleteConfirm => 'Delete this seed?'; @@ -658,6 +661,7 @@ extension on Translations { 'detail.names' => 'Also known as', 'detail.notes' => 'Notes', 'detail.addLot' => 'Add lot', + 'detail.editLot' => 'Edit lot', 'detail.deleteConfirm' => 'Delete this seed?', 'detail.year' => ({required Object year}) => 'Year ${year}', 'detail.noYear' => 'Year unknown', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 79e9728..afe411b 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -118,6 +118,7 @@ class _Translations$detail$es extends Translations$detail$en { @override String get names => 'También conocida como'; @override String get notes => 'Notas'; @override String get addLot => 'Añadir lote'; + @override String get editLot => 'Editar lote'; @override String get deleteConfirm => '¿Eliminar esta semilla?'; @override String year({required Object year}) => 'Año ${year}'; @override String get noYear => 'Año desconocido'; @@ -474,6 +475,7 @@ extension on TranslationsEs { 'detail.names' => 'También conocida como', 'detail.notes' => 'Notas', 'detail.addLot' => 'Añadir lote', + 'detail.editLot' => 'Editar lote', 'detail.deleteConfirm' => '¿Eliminar esta semilla?', 'detail.year' => ({required Object year}) => 'Año ${year}', 'detail.noYear' => 'Año desconocido', diff --git a/apps/app_seeds/lib/state/variety_detail_cubit.dart b/apps/app_seeds/lib/state/variety_detail_cubit.dart index 2b0c93b..0ba3ef5 100644 --- a/apps/app_seeds/lib/state/variety_detail_cubit.dart +++ b/apps/app_seeds/lib/state/variety_detail_cubit.dart @@ -62,6 +62,20 @@ class VarietyDetailCubit extends Cubit { quantity: quantity, ); + Future updateLot({ + required String lotId, + required LotType type, + int? year, + Quantity? quantity, + }) => _repo.updateLot( + lotId: lotId, + type: type, + harvestYear: year, + quantity: quantity, + ); + + Future deleteLot(String lotId) => _repo.softDeleteLot(lotId); + 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 c8252d8..79f108d 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -1,4 +1,3 @@ -import 'package:commons_core/commons_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -100,7 +99,7 @@ class _DetailView extends StatelessWidget { _SectionTitle(t.detail.lots), TextButton.icon( key: const Key('detail.addLot'), - onPressed: () => _showAddLotSheet(context, cubit), + onPressed: () => _showLotSheet(context, cubit), icon: const Icon(Icons.add), label: Text(t.detail.addLot), ), @@ -111,7 +110,9 @@ class _DetailView extends StatelessWidget { else for (final lot in detail.lots) ListTile( + key: Key('lot.${lot.id}'), contentPadding: EdgeInsets.zero, + onTap: () => _showLotSheet(context, cubit, existing: lot), leading: lot.quantity == null ? const Icon(Icons.inventory_2_outlined) : QuantityKindIcon(lot.quantity!.kind, size: 28), @@ -475,15 +476,20 @@ class _EditVarietySheetState extends State<_EditVarietySheet> { } } -Future _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) { +Future _showLotSheet( + BuildContext context, + VarietyDetailCubit cubit, { + VarietyLot? existing, +}) { final t = context.t; final currentYear = DateTime.now().year; // Harvest years: current year down through the last four decades. Newest // first because a freshly harvested lot is the common case. final years = [for (var y = currentYear; y >= currentYear - 40; y--) y]; - var selectedYear = currentYear; - var selectedType = LotType.seed; - Quantity? selectedQuantity; + var selectedYear = existing?.harvestYear ?? currentYear; + var selectedType = existing?.type ?? LotType.seed; + var selectedQuantity = existing?.quantity; + final editing = existing != null; return showModalBottomSheet( context: context, isScrollControlled: true, @@ -499,9 +505,25 @@ Future _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - t.addLot.title, - style: Theme.of(sheetContext).textTheme.titleLarge, + Row( + children: [ + Expanded( + child: Text( + editing ? t.detail.editLot : t.addLot.title, + style: Theme.of(sheetContext).textTheme.titleLarge, + ), + ), + if (editing) + IconButton( + key: const Key('lot.delete'), + icon: const Icon(Icons.delete_outline), + tooltip: t.common.delete, + onPressed: () { + cubit.deleteLot(existing.id); + Navigator.of(sheetContext).pop(); + }, + ), + ], ), const SizedBox(height: 12), LotTypeSelector( @@ -549,11 +571,20 @@ Future _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) { FilledButton( key: const Key('addLot.save'), onPressed: () { - cubit.addLot( - type: selectedType, - year: selectedYear, - quantity: selectedQuantity, - ); + if (editing) { + cubit.updateLot( + lotId: existing.id, + type: selectedType, + year: selectedYear, + quantity: selectedQuantity, + ); + } else { + cubit.addLot( + type: selectedType, + year: selectedYear, + quantity: selectedQuantity, + ); + } Navigator.of(sheetContext).pop(); }, child: Text(t.common.save), diff --git a/apps/app_seeds/test/data/variety_detail_test.dart b/apps/app_seeds/test/data/variety_detail_test.dart index 435f222..eea8f55 100644 --- a/apps/app_seeds/test/data/variety_detail_test.dart +++ b/apps/app_seeds/test/data/variety_detail_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:tane/data/species_repository.dart'; import 'package:tane/data/variety_repository.dart'; import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; import '../support/test_support.dart'; @@ -139,4 +140,35 @@ void main() { await queue.cancel(); }); + + test('updateLot changes a lot\'s year, type and quantity', () async { + final id = await repo.addQuickVariety(label: 'Maize'); + final lotId = await repo.addLot( + varietyId: id, + harvestYear: 2020, + quantity: const Quantity(kind: QuantityKind.packet, count: 1), + ); + + await repo.updateLot( + lotId: lotId, + type: LotType.plant, + harvestYear: 2024, + quantity: const Quantity(kind: QuantityKind.plant, count: 3), + ); + + final lot = (await repo.watchVariety(id).first)!.lots.single; + expect(lot.type, LotType.plant); + expect(lot.harvestYear, 2024); + expect(lot.quantity?.kind, QuantityKind.plant); + expect(lot.quantity?.count, 3); + }); + + test('softDeleteLot removes the lot from the variety', () async { + final id = await repo.addQuickVariety(label: 'Maize'); + final lotId = await repo.addLot(varietyId: id, harvestYear: 2024); + + await repo.softDeleteLot(lotId); + + expect((await repo.watchVariety(id).first)!.lots, 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 38f3c06..de394e6 100644 --- a/apps/app_seeds/test/ui/variety_detail_screen_test.dart +++ b/apps/app_seeds/test/ui/variety_detail_screen_test.dart @@ -200,4 +200,59 @@ void main() { expect(find.text('90%'), findsOneWidget); // germination badge on the lot await disposeTree(tester); }); + + testWidgets('editing a lot updates its line', (tester) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Maize'); + await repo.addLot( + varietyId: id, + harvestYear: 2020, + quantity: const Quantity(kind: QuantityKind.packet, count: 1), + ); + + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Year 2020 · 1 packet')); // open the lot + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('addLot.year'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('2024').last); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('addLot.save'))); + await tester.pumpAndSettle(); + + expect(find.text('Year 2024 · 1 packet'), findsOneWidget); + await disposeTree(tester); + }); + + testWidgets('deleting a lot removes it', (tester) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Maize'); + await repo.addLot(varietyId: id, harvestYear: 2020); + + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.textContaining('Year 2020')); // open the lot + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('lot.delete'))); + await tester.pumpAndSettle(); + + expect(find.text('No lots yet.'), findsOneWidget); + await disposeTree(tester); + }); }