feat(inventory): edit and delete existing lots

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.
This commit is contained in:
vjrj 2026-07-08 11:10:43 +02:00
parent 7e92410728
commit f8d4d9a778
10 changed files with 192 additions and 16 deletions

View file

@ -455,6 +455,42 @@ class VarietyRepository {
return id; return id;
} }
/// Updates an existing lot's fields (LWW). The [quantity] and [harvestYear]
/// are set as given (null clears them).
Future<void> 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<void> 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 /// 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

@ -32,6 +32,7 @@
"names": "Also known as", "names": "Also known as",
"notes": "Notes", "notes": "Notes",
"addLot": "Add lot", "addLot": "Add lot",
"editLot": "Edit lot",
"deleteConfirm": "Delete this seed?", "deleteConfirm": "Delete this seed?",
"year": "Year {year}", "year": "Year {year}",
"noYear": "Year unknown" "noYear": "Year unknown"

View file

@ -32,6 +32,7 @@
"names": "También conocida como", "names": "También conocida como",
"notes": "Notas", "notes": "Notas",
"addLot": "Añadir lote", "addLot": "Añadir lote",
"editLot": "Editar lote",
"deleteConfirm": "¿Eliminar esta semilla?", "deleteConfirm": "¿Eliminar esta semilla?",
"year": "Año {year}", "year": "Año {year}",
"noYear": "Año desconocido" "noYear": "Año desconocido"

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 2 /// 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 // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import

View file

@ -168,6 +168,9 @@ class Translations$detail$en {
/// en: 'Add lot' /// en: 'Add lot'
String get addLot => 'Add lot'; String get addLot => 'Add lot';
/// en: 'Edit lot'
String get editLot => 'Edit lot';
/// en: 'Delete this seed?' /// en: 'Delete this seed?'
String get deleteConfirm => 'Delete this seed?'; String get deleteConfirm => 'Delete this seed?';
@ -658,6 +661,7 @@ extension on Translations {
'detail.names' => 'Also known as', 'detail.names' => 'Also known as',
'detail.notes' => 'Notes', 'detail.notes' => 'Notes',
'detail.addLot' => 'Add lot', 'detail.addLot' => 'Add lot',
'detail.editLot' => 'Edit lot',
'detail.deleteConfirm' => 'Delete this seed?', 'detail.deleteConfirm' => 'Delete this seed?',
'detail.year' => ({required Object year}) => 'Year ${year}', 'detail.year' => ({required Object year}) => 'Year ${year}',
'detail.noYear' => 'Year unknown', 'detail.noYear' => 'Year unknown',

View file

@ -118,6 +118,7 @@ class _Translations$detail$es extends Translations$detail$en {
@override String get names => 'También conocida como'; @override String get names => 'También conocida como';
@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 deleteConfirm => '¿Eliminar esta semilla?'; @override String get deleteConfirm => '¿Eliminar esta semilla?';
@override String year({required Object year}) => 'Año ${year}'; @override String year({required Object year}) => 'Año ${year}';
@override String get noYear => 'Año desconocido'; @override String get noYear => 'Año desconocido';
@ -474,6 +475,7 @@ extension on TranslationsEs {
'detail.names' => 'También conocida como', 'detail.names' => 'También conocida como',
'detail.notes' => 'Notas', 'detail.notes' => 'Notas',
'detail.addLot' => 'Añadir lote', 'detail.addLot' => 'Añadir lote',
'detail.editLot' => 'Editar lote',
'detail.deleteConfirm' => '¿Eliminar esta semilla?', 'detail.deleteConfirm' => '¿Eliminar esta semilla?',
'detail.year' => ({required Object year}) => 'Año ${year}', 'detail.year' => ({required Object year}) => 'Año ${year}',
'detail.noYear' => 'Año desconocido', 'detail.noYear' => 'Año desconocido',

View file

@ -62,6 +62,20 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
quantity: quantity, quantity: quantity,
); );
Future<void> updateLot({
required String lotId,
required LotType type,
int? year,
Quantity? quantity,
}) => _repo.updateLot(
lotId: lotId,
type: type,
harvestYear: year,
quantity: quantity,
);
Future<void> deleteLot(String lotId) => _repo.softDeleteLot(lotId);
Future<void> linkSpecies(String speciesId) => Future<void> linkSpecies(String speciesId) =>
_repo.linkSpecies(varietyId, speciesId); _repo.linkSpecies(varietyId, speciesId);

View file

@ -1,4 +1,3 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@ -100,7 +99,7 @@ class _DetailView extends StatelessWidget {
_SectionTitle(t.detail.lots), _SectionTitle(t.detail.lots),
TextButton.icon( TextButton.icon(
key: const Key('detail.addLot'), key: const Key('detail.addLot'),
onPressed: () => _showAddLotSheet(context, cubit), onPressed: () => _showLotSheet(context, cubit),
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: Text(t.detail.addLot), label: Text(t.detail.addLot),
), ),
@ -111,7 +110,9 @@ class _DetailView extends StatelessWidget {
else else
for (final lot in detail.lots) for (final lot in detail.lots)
ListTile( ListTile(
key: Key('lot.${lot.id}'),
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
onTap: () => _showLotSheet(context, cubit, existing: lot),
leading: lot.quantity == null leading: lot.quantity == null
? const Icon(Icons.inventory_2_outlined) ? const Icon(Icons.inventory_2_outlined)
: QuantityKindIcon(lot.quantity!.kind, size: 28), : QuantityKindIcon(lot.quantity!.kind, size: 28),
@ -475,15 +476,20 @@ class _EditVarietySheetState extends State<_EditVarietySheet> {
} }
} }
Future<void> _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) { Future<void> _showLotSheet(
BuildContext context,
VarietyDetailCubit cubit, {
VarietyLot? existing,
}) {
final t = context.t; final t = context.t;
final currentYear = DateTime.now().year; final currentYear = DateTime.now().year;
// Harvest years: current year down through the last four decades. Newest // Harvest years: current year down through the last four decades. Newest
// first because a freshly harvested lot is the common case. // first because a freshly harvested lot is the common case.
final years = [for (var y = currentYear; y >= currentYear - 40; y--) y]; final years = [for (var y = currentYear; y >= currentYear - 40; y--) y];
var selectedYear = currentYear; var selectedYear = existing?.harvestYear ?? currentYear;
var selectedType = LotType.seed; var selectedType = existing?.type ?? LotType.seed;
Quantity? selectedQuantity; var selectedQuantity = existing?.quantity;
final editing = existing != null;
return showModalBottomSheet<void>( return showModalBottomSheet<void>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
@ -499,9 +505,25 @@ Future<void> _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Row(
t.addLot.title, children: [
style: Theme.of(sheetContext).textTheme.titleLarge, 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), const SizedBox(height: 12),
LotTypeSelector( LotTypeSelector(
@ -549,11 +571,20 @@ Future<void> _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) {
FilledButton( FilledButton(
key: const Key('addLot.save'), key: const Key('addLot.save'),
onPressed: () { onPressed: () {
cubit.addLot( if (editing) {
type: selectedType, cubit.updateLot(
year: selectedYear, lotId: existing.id,
quantity: selectedQuantity, type: selectedType,
); year: selectedYear,
quantity: selectedQuantity,
);
} else {
cubit.addLot(
type: selectedType,
year: selectedYear,
quantity: selectedQuantity,
);
}
Navigator.of(sheetContext).pop(); Navigator.of(sheetContext).pop();
}, },
child: Text(t.common.save), child: Text(t.common.save),

View file

@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/species_repository.dart'; import 'package:tane/data/species_repository.dart';
import 'package:tane/data/variety_repository.dart'; import 'package:tane/data/variety_repository.dart';
import 'package:tane/db/database.dart'; import 'package:tane/db/database.dart';
import 'package:tane/db/enums.dart';
import '../support/test_support.dart'; import '../support/test_support.dart';
@ -139,4 +140,35 @@ void main() {
await queue.cancel(); 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);
});
} }

View file

@ -200,4 +200,59 @@ void main() {
expect(find.text('90%'), findsOneWidget); // germination badge on the lot expect(find.text('90%'), findsOneWidget); // germination badge on the lot
await disposeTree(tester); 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);
});
} }