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:
parent
7e92410728
commit
f8d4d9a778
10 changed files with 192 additions and 16 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue