feat(block1): variety detail + edit screen with reactive lots

Tapping an inventory item now opens its detail (the /variety/:id route was a
placeholder). Read + edit one variety end-to-end:

- Detail view: photo, category, "also known as" names, notes and lots, driven
  by a reactive VarietyDetailCubit.
- Edit core fields (label/category/notes, LWW) via a bottom sheet.
- Add a lot (harvest year + quantity) — the list refreshes reactively.
- Soft-delete (tombstone) with a confirm dialog.

Repository:
- watchVariety(id): merges Drift table-change streams (variety, lots, names,
  attachments) via StreamGroup and reloads the full detail on any change, so a
  lot added to a *different* table still refreshes the view.
- updateVariety (LWW), addLot, softDeleteVariety.

i18n: detail/edit/addLot strings (ES/EN); switched slang to {param} braces
interpolation (translator- and Weblate-friendly).

Tests: repository (reactive watchVariety, update, soft-delete) and widget tests
(render, edit updates title, add-lot appears, delete shows not-found). Full
suite green: 23 passing, 1 skipped (SQLCipher-only encryption guard).
This commit is contained in:
vjrj 2026-07-07 15:56:03 +02:00
parent 040f15a898
commit 7ff4e38a15
15 changed files with 1093 additions and 18 deletions

View file

@ -1,3 +1,4 @@
import 'package:async/async.dart';
import 'package:commons_core/commons_core.dart';
import 'package:drift/drift.dart';
import 'package:equatable/equatable.dart';
@ -17,6 +18,56 @@ class VarietyListItem extends Equatable {
List<Object?> get props => [id, label, category];
}
/// One held batch of a variety, for the detail view.
class VarietyLot extends Equatable {
const VarietyLot({
required this.id,
this.harvestYear,
this.quantity,
this.storageLocation,
});
final String id;
final int? harvestYear;
final Quantity? quantity;
final String? storageLocation;
@override
List<Object?> get props => [id, harvestYear, quantity, storageLocation];
}
/// The full detail of one variety (identity + its lots, names and first photo).
class VarietyDetail extends Equatable {
const VarietyDetail({
required this.id,
required this.label,
this.category,
this.notes,
this.lots = const [],
this.vernacularNames = const [],
this.photo,
});
final String id;
final String label;
final String? category;
final String? notes;
final List<VarietyLot> lots;
final List<String> vernacularNames;
final Uint8List? photo;
@override
List<Object?> get props => [
id,
label,
category,
notes,
lots,
vernacularNames,
photo,
];
}
/// Reads and writes the inventory. The encrypted Drift DB is the single source
/// of truth; [watchInventory] exposes a reactive stream the UI subscribes to.
///
@ -120,6 +171,151 @@ class VarietyRepository {
return varietyId;
}
/// Reactively watches one variety with its lots, vernacular names and first
/// photo. Emits `null` if the variety does not exist or is soft-deleted.
///
/// Re-emits whenever the variety or any related row changes: a merged trigger
/// stream over the four tables drives a full reload, so adding a lot (a change
/// to a *different* table) still refreshes the view.
Stream<VarietyDetail?> watchVariety(String id) {
final triggers = StreamGroup.merge<void>([
(_db.select(
_db.varieties,
)..where((v) => v.id.equals(id))).watch().map((_) {}),
(_db.select(
_db.lots,
)..where((l) => l.varietyId.equals(id))).watch().map((_) {}),
(_db.select(
_db.varietyVernacularNames,
)..where((n) => n.varietyId.equals(id))).watch().map((_) {}),
(_db.select(
_db.attachments,
)..where((a) => a.parentId.equals(id))).watch().map((_) {}),
]);
return triggers.asyncMap((_) => _loadVariety(id));
}
Future<VarietyDetail?> _loadVariety(String id) async {
final v =
await (_db.select(_db.varieties)
..where((t) => t.id.equals(id) & t.isDeleted.equals(false)))
.getSingleOrNull();
if (v == null) return null;
final lots =
await (_db.select(_db.lots)
..where((l) => l.varietyId.equals(id) & l.isDeleted.equals(false))
..orderBy([(l) => OrderingTerm.desc(l.harvestYear)]))
.get();
final names = await (_db.select(
_db.varietyVernacularNames,
)..where((n) => n.varietyId.equals(id) & n.isDeleted.equals(false))).get();
final photos =
await (_db.select(_db.attachments)
..where(
(a) =>
a.parentId.equals(id) &
a.parentType.equalsValue(ParentType.variety) &
a.kind.equalsValue(AttachmentKind.photo) &
a.isDeleted.equals(false),
)
..limit(1))
.get();
return VarietyDetail(
id: v.id,
label: v.label,
category: v.category,
notes: v.notes,
lots: lots.map(_toLot).toList(),
vernacularNames: names.map((n) => n.name).toList(),
photo: photos.isEmpty ? null : photos.first.bytes,
);
}
/// Updates a variety's scalar fields (LWW). Passing null clears [category]
/// and [notes]; a null [label] leaves the label unchanged.
Future<void> updateVariety({
required String id,
String? label,
String? category,
String? notes,
}) async {
final (_, updated) = _stamp();
await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
VarietiesCompanion(
label: label == null ? const Value.absent() : Value(label),
category: Value(category),
notes: Value(notes),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
);
}
/// Adds a lot (a held batch) to a variety. Returns the new lot id.
Future<String> addLot({
required String varietyId,
int? harvestYear,
Quantity? quantity,
String? storageLocation,
}) async {
final (created, updated) = _stamp();
final id = idGen.newId();
await _db
.into(_db.lots)
.insert(
LotsCompanion.insert(
id: id,
varietyId: varietyId,
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
harvestYear: Value(harvestYear),
quantityKind: Value(quantity?.kind.name),
quantityPrecise: Value(quantity?.precise),
quantityLabel: Value(quantity?.label),
storageLocation: Value(storageLocation),
),
);
return id;
}
/// 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 {
final (_, updated) = _stamp();
await (_db.update(_db.varieties)..where((v) => v.id.equals(id))).write(
VarietiesCompanion(
isDeleted: const Value(true),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
);
}
VarietyLot _toLot(Lot l) {
final hasQuantity =
l.quantityKind != null ||
l.quantityPrecise != null ||
l.quantityLabel != null;
return VarietyLot(
id: l.id,
harvestYear: l.harvestYear,
storageLocation: l.storageLocation,
quantity: hasQuantity
? Quantity(
kind: _parseKind(l.quantityKind),
precise: l.quantityPrecise,
label: l.quantityLabel,
)
: null,
);
}
QuantityKind _parseKind(String? name) =>
QuantityKind.values.asNameMap()[name] ?? QuantityKind.aFew;
/// Advances the local clock and returns `(createdAtMillis, packedHlc)`.
(int, String) _stamp() {
final now = _now();