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:
parent
040f15a898
commit
7ff4e38a15
15 changed files with 1093 additions and 18 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue