feat(inventory): month/year harvest date picker (schema v3)

Replace the free-text harvest year with a themed month/year picker:
a tappable field opens a green-styled dialog with month and year grids
(navigable, paginated years), month optional. Lots now read e.g.
"September 2021" or just "Year 2021".

- schema v3: add nullable Lot.harvest_month (1..12) + migration + tests
- repo/cubit propagate harvestMonth; localized month names (en/es) via slang
This commit is contained in:
vjrj 2026-07-08 12:09:38 +02:00
parent 9e5c82184b
commit 5d4053157f
17 changed files with 3799 additions and 57 deletions

View file

@ -65,6 +65,7 @@ class VarietyLot extends Equatable {
required this.id,
this.type = LotType.seed,
this.harvestYear,
this.harvestMonth,
this.quantity,
this.storageLocation,
this.germinationTests = const [],
@ -73,6 +74,9 @@ class VarietyLot extends Equatable {
final String id;
final LotType type;
final int? harvestYear;
/// Optional harvest month (1..12). Only meaningful when [harvestYear] is set.
final int? harvestMonth;
final Quantity? quantity;
final String? storageLocation;
final List<GerminationEntry> germinationTests;
@ -86,6 +90,7 @@ class VarietyLot extends Equatable {
id,
type,
harvestYear,
harvestMonth,
quantity,
storageLocation,
germinationTests,
@ -110,7 +115,18 @@ class VernacularName extends Equatable {
List<Object?> get props => [id, name, language, region];
}
/// The full detail of one variety (identity + its lots, names and first photo).
/// One stored photo of a variety (encrypted BLOB in the DB).
class VarietyPhoto extends Equatable {
const VarietyPhoto({required this.id, required this.bytes});
final String id;
final Uint8List bytes;
@override
List<Object?> get props => [id, bytes];
}
/// The full detail of one variety (identity + its lots, names and photos).
class VarietyDetail extends Equatable {
const VarietyDetail({
required this.id,
@ -121,7 +137,7 @@ class VarietyDetail extends Equatable {
this.scientificName,
this.lots = const [],
this.vernacularNames = const [],
this.photo,
this.photos = const [],
});
final String id;
@ -132,7 +148,7 @@ class VarietyDetail extends Equatable {
final String? scientificName;
final List<VarietyLot> lots;
final List<VernacularName> vernacularNames;
final Uint8List? photo;
final List<VarietyPhoto> photos;
@override
List<Object?> get props => [
@ -144,7 +160,7 @@ class VarietyDetail extends Equatable {
scientificName,
lots,
vernacularNames,
photo,
photos,
];
}
@ -380,7 +396,7 @@ class VarietyRepository {
a.kind.equalsValue(AttachmentKind.photo) &
a.isDeleted.equals(false),
)
..limit(1))
..orderBy([(a) => OrderingTerm(expression: a.createdAt)]))
.get();
return VarietyDetail(
@ -401,7 +417,10 @@ class VarietyRepository {
),
)
.toList(),
photo: photos.isEmpty ? null : photos.first.bytes,
photos: [
for (final p in photos)
if (p.bytes != null) VarietyPhoto(id: p.id, bytes: p.bytes!),
],
);
}
@ -457,6 +476,7 @@ class VarietyRepository {
required String varietyId,
LotType type = LotType.seed,
int? harvestYear,
int? harvestMonth,
Quantity? quantity,
String? storageLocation,
}) async {
@ -473,6 +493,7 @@ class VarietyRepository {
lastAuthor: nodeId,
type: Value(type),
harvestYear: Value(harvestYear),
harvestMonth: Value(harvestMonth),
quantityKind: Value(quantity?.kind.name),
quantityPrecise: Value(quantity?.count?.toDouble()),
quantityLabel: Value(quantity?.label),
@ -482,12 +503,13 @@ class VarietyRepository {
return id;
}
/// Updates an existing lot's fields (LWW). The [quantity] and [harvestYear]
/// are set as given (null clears them).
/// Updates an existing lot's fields (LWW). The [quantity], [harvestYear] and
/// [harvestMonth] are set as given (null clears them).
Future<void> updateLot({
required String lotId,
required LotType type,
int? harvestYear,
int? harvestMonth,
Quantity? quantity,
String? storageLocation,
}) async {
@ -496,6 +518,7 @@ class VarietyRepository {
LotsCompanion(
type: Value(type),
harvestYear: Value(harvestYear),
harvestMonth: Value(harvestMonth),
quantityKind: Value(quantity?.kind.name),
quantityPrecise: Value(quantity?.count?.toDouble()),
quantityLabel: Value(quantity?.label),
@ -558,6 +581,42 @@ class VarietyRepository {
);
}
/// Adds a photo (encrypted BLOB) to a variety. Returns the new attachment id.
Future<String> addPhoto(String varietyId, Uint8List bytes) async {
final (created, updated) = _stamp();
final id = idGen.newId();
await _db
.into(_db.attachments)
.insert(
AttachmentsCompanion.insert(
id: id,
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
parentType: ParentType.variety,
parentId: varietyId,
kind: AttachmentKind.photo,
bytes: Value(bytes),
mimeType: const Value('image/jpeg'),
),
);
return id;
}
/// Soft-deletes a photo (tombstone).
Future<void> removePhoto(String attachmentId) async {
final (_, updated) = _stamp();
await (_db.update(
_db.attachments,
)..where((a) => a.id.equals(attachmentId))).write(
AttachmentsCompanion(
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<void> softDeleteVariety(String id) async {
@ -608,6 +667,7 @@ class VarietyRepository {
id: l.id,
type: l.type,
harvestYear: l.harvestYear,
harvestMonth: l.harvestMonth,
storageLocation: l.storageLocation,
germinationTests: germinationTests,
quantity: hasQuantity