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

File diff suppressed because it is too large Load diff

View file

@ -65,6 +65,7 @@ class VarietyLot extends Equatable {
required this.id, required this.id,
this.type = LotType.seed, this.type = LotType.seed,
this.harvestYear, this.harvestYear,
this.harvestMonth,
this.quantity, this.quantity,
this.storageLocation, this.storageLocation,
this.germinationTests = const [], this.germinationTests = const [],
@ -73,6 +74,9 @@ class VarietyLot extends Equatable {
final String id; final String id;
final LotType type; final LotType type;
final int? harvestYear; final int? harvestYear;
/// Optional harvest month (1..12). Only meaningful when [harvestYear] is set.
final int? harvestMonth;
final Quantity? quantity; final Quantity? quantity;
final String? storageLocation; final String? storageLocation;
final List<GerminationEntry> germinationTests; final List<GerminationEntry> germinationTests;
@ -86,6 +90,7 @@ class VarietyLot extends Equatable {
id, id,
type, type,
harvestYear, harvestYear,
harvestMonth,
quantity, quantity,
storageLocation, storageLocation,
germinationTests, germinationTests,
@ -110,7 +115,18 @@ class VernacularName extends Equatable {
List<Object?> get props => [id, name, language, region]; 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 { class VarietyDetail extends Equatable {
const VarietyDetail({ const VarietyDetail({
required this.id, required this.id,
@ -121,7 +137,7 @@ class VarietyDetail extends Equatable {
this.scientificName, this.scientificName,
this.lots = const [], this.lots = const [],
this.vernacularNames = const [], this.vernacularNames = const [],
this.photo, this.photos = const [],
}); });
final String id; final String id;
@ -132,7 +148,7 @@ class VarietyDetail extends Equatable {
final String? scientificName; final String? scientificName;
final List<VarietyLot> lots; final List<VarietyLot> lots;
final List<VernacularName> vernacularNames; final List<VernacularName> vernacularNames;
final Uint8List? photo; final List<VarietyPhoto> photos;
@override @override
List<Object?> get props => [ List<Object?> get props => [
@ -144,7 +160,7 @@ class VarietyDetail extends Equatable {
scientificName, scientificName,
lots, lots,
vernacularNames, vernacularNames,
photo, photos,
]; ];
} }
@ -380,7 +396,7 @@ class VarietyRepository {
a.kind.equalsValue(AttachmentKind.photo) & a.kind.equalsValue(AttachmentKind.photo) &
a.isDeleted.equals(false), a.isDeleted.equals(false),
) )
..limit(1)) ..orderBy([(a) => OrderingTerm(expression: a.createdAt)]))
.get(); .get();
return VarietyDetail( return VarietyDetail(
@ -401,7 +417,10 @@ class VarietyRepository {
), ),
) )
.toList(), .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, required String varietyId,
LotType type = LotType.seed, LotType type = LotType.seed,
int? harvestYear, int? harvestYear,
int? harvestMonth,
Quantity? quantity, Quantity? quantity,
String? storageLocation, String? storageLocation,
}) async { }) async {
@ -473,6 +493,7 @@ class VarietyRepository {
lastAuthor: nodeId, lastAuthor: nodeId,
type: Value(type), type: Value(type),
harvestYear: Value(harvestYear), harvestYear: Value(harvestYear),
harvestMonth: Value(harvestMonth),
quantityKind: Value(quantity?.kind.name), quantityKind: Value(quantity?.kind.name),
quantityPrecise: Value(quantity?.count?.toDouble()), quantityPrecise: Value(quantity?.count?.toDouble()),
quantityLabel: Value(quantity?.label), quantityLabel: Value(quantity?.label),
@ -482,12 +503,13 @@ class VarietyRepository {
return id; return id;
} }
/// Updates an existing lot's fields (LWW). The [quantity] and [harvestYear] /// Updates an existing lot's fields (LWW). The [quantity], [harvestYear] and
/// are set as given (null clears them). /// [harvestMonth] are set as given (null clears them).
Future<void> updateLot({ Future<void> updateLot({
required String lotId, required String lotId,
required LotType type, required LotType type,
int? harvestYear, int? harvestYear,
int? harvestMonth,
Quantity? quantity, Quantity? quantity,
String? storageLocation, String? storageLocation,
}) async { }) async {
@ -496,6 +518,7 @@ class VarietyRepository {
LotsCompanion( LotsCompanion(
type: Value(type), type: Value(type),
harvestYear: Value(harvestYear), harvestYear: Value(harvestYear),
harvestMonth: Value(harvestMonth),
quantityKind: Value(quantity?.kind.name), quantityKind: Value(quantity?.kind.name),
quantityPrecise: Value(quantity?.count?.toDouble()), quantityPrecise: Value(quantity?.count?.toDouble()),
quantityLabel: Value(quantity?.label), 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 /// 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 {
@ -608,6 +667,7 @@ class VarietyRepository {
id: l.id, id: l.id,
type: l.type, type: l.type,
harvestYear: l.harvestYear, harvestYear: l.harvestYear,
harvestMonth: l.harvestMonth,
storageLocation: l.storageLocation, storageLocation: l.storageLocation,
germinationTests: germinationTests, germinationTests: germinationTests,
quantity: hasQuantity quantity: hasQuantity

View file

@ -27,7 +27,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase(super.e); AppDatabase(super.e);
@override @override
int get schemaVersion => 2; int get schemaVersion => 3;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@ -37,6 +37,10 @@ class AppDatabase extends _$AppDatabase {
if (from < 2) { if (from < 2) {
await m.addColumn(lots, lots.type); await m.addColumn(lots, lots.type);
} }
// v3: optional harvest month alongside the harvest year.
if (from < 3) {
await m.addColumn(lots, lots.harvestMonth);
}
}, },
); );
} }

View file

@ -2631,6 +2631,17 @@ class $LotsTable extends Lots with TableInfo<$LotsTable, Lot> {
type: DriftSqlType.int, type: DriftSqlType.int,
requiredDuringInsert: false, requiredDuringInsert: false,
); );
static const VerificationMeta _harvestMonthMeta = const VerificationMeta(
'harvestMonth',
);
@override
late final GeneratedColumn<int> harvestMonth = GeneratedColumn<int>(
'harvest_month',
aliasedName,
true,
type: DriftSqlType.int,
requiredDuringInsert: false,
);
static const VerificationMeta _quantityKindMeta = const VerificationMeta( static const VerificationMeta _quantityKindMeta = const VerificationMeta(
'quantityKind', 'quantityKind',
); );
@ -2707,6 +2718,7 @@ class $LotsTable extends Lots with TableInfo<$LotsTable, Lot> {
varietyId, varietyId,
type, type,
harvestYear, harvestYear,
harvestMonth,
quantityKind, quantityKind,
quantityPrecise, quantityPrecise,
quantityLabel, quantityLabel,
@ -2787,6 +2799,15 @@ class $LotsTable extends Lots with TableInfo<$LotsTable, Lot> {
), ),
); );
} }
if (data.containsKey('harvest_month')) {
context.handle(
_harvestMonthMeta,
harvestMonth.isAcceptableOrUnknown(
data['harvest_month']!,
_harvestMonthMeta,
),
);
}
if (data.containsKey('quantity_kind')) { if (data.containsKey('quantity_kind')) {
context.handle( context.handle(
_quantityKindMeta, _quantityKindMeta,
@ -2876,6 +2897,10 @@ class $LotsTable extends Lots with TableInfo<$LotsTable, Lot> {
DriftSqlType.int, DriftSqlType.int,
data['${effectivePrefix}harvest_year'], data['${effectivePrefix}harvest_year'],
), ),
harvestMonth: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}harvest_month'],
),
quantityKind: attachedDatabase.typeMapping.read( quantityKind: attachedDatabase.typeMapping.read(
DriftSqlType.string, DriftSqlType.string,
data['${effectivePrefix}quantity_kind'], data['${effectivePrefix}quantity_kind'],
@ -2926,6 +2951,7 @@ class Lot extends DataClass implements Insertable<Lot> {
final String varietyId; final String varietyId;
final LotType type; final LotType type;
final int? harvestYear; final int? harvestYear;
final int? harvestMonth;
final String? quantityKind; final String? quantityKind;
final double? quantityPrecise; final double? quantityPrecise;
final String? quantityLabel; final String? quantityLabel;
@ -2942,6 +2968,7 @@ class Lot extends DataClass implements Insertable<Lot> {
required this.varietyId, required this.varietyId,
required this.type, required this.type,
this.harvestYear, this.harvestYear,
this.harvestMonth,
this.quantityKind, this.quantityKind,
this.quantityPrecise, this.quantityPrecise,
this.quantityLabel, this.quantityLabel,
@ -2965,6 +2992,9 @@ class Lot extends DataClass implements Insertable<Lot> {
if (!nullToAbsent || harvestYear != null) { if (!nullToAbsent || harvestYear != null) {
map['harvest_year'] = Variable<int>(harvestYear); map['harvest_year'] = Variable<int>(harvestYear);
} }
if (!nullToAbsent || harvestMonth != null) {
map['harvest_month'] = Variable<int>(harvestMonth);
}
if (!nullToAbsent || quantityKind != null) { if (!nullToAbsent || quantityKind != null) {
map['quantity_kind'] = Variable<String>(quantityKind); map['quantity_kind'] = Variable<String>(quantityKind);
} }
@ -3001,6 +3031,9 @@ class Lot extends DataClass implements Insertable<Lot> {
harvestYear: harvestYear == null && nullToAbsent harvestYear: harvestYear == null && nullToAbsent
? const Value.absent() ? const Value.absent()
: Value(harvestYear), : Value(harvestYear),
harvestMonth: harvestMonth == null && nullToAbsent
? const Value.absent()
: Value(harvestMonth),
quantityKind: quantityKind == null && nullToAbsent quantityKind: quantityKind == null && nullToAbsent
? const Value.absent() ? const Value.absent()
: Value(quantityKind), : Value(quantityKind),
@ -3037,6 +3070,7 @@ class Lot extends DataClass implements Insertable<Lot> {
serializer.fromJson<String>(json['type']), serializer.fromJson<String>(json['type']),
), ),
harvestYear: serializer.fromJson<int?>(json['harvestYear']), harvestYear: serializer.fromJson<int?>(json['harvestYear']),
harvestMonth: serializer.fromJson<int?>(json['harvestMonth']),
quantityKind: serializer.fromJson<String?>(json['quantityKind']), quantityKind: serializer.fromJson<String?>(json['quantityKind']),
quantityPrecise: serializer.fromJson<double?>(json['quantityPrecise']), quantityPrecise: serializer.fromJson<double?>(json['quantityPrecise']),
quantityLabel: serializer.fromJson<String?>(json['quantityLabel']), quantityLabel: serializer.fromJson<String?>(json['quantityLabel']),
@ -3060,6 +3094,7 @@ class Lot extends DataClass implements Insertable<Lot> {
'varietyId': serializer.toJson<String>(varietyId), 'varietyId': serializer.toJson<String>(varietyId),
'type': serializer.toJson<String>($LotsTable.$convertertype.toJson(type)), 'type': serializer.toJson<String>($LotsTable.$convertertype.toJson(type)),
'harvestYear': serializer.toJson<int?>(harvestYear), 'harvestYear': serializer.toJson<int?>(harvestYear),
'harvestMonth': serializer.toJson<int?>(harvestMonth),
'quantityKind': serializer.toJson<String?>(quantityKind), 'quantityKind': serializer.toJson<String?>(quantityKind),
'quantityPrecise': serializer.toJson<double?>(quantityPrecise), 'quantityPrecise': serializer.toJson<double?>(quantityPrecise),
'quantityLabel': serializer.toJson<String?>(quantityLabel), 'quantityLabel': serializer.toJson<String?>(quantityLabel),
@ -3081,6 +3116,7 @@ class Lot extends DataClass implements Insertable<Lot> {
String? varietyId, String? varietyId,
LotType? type, LotType? type,
Value<int?> harvestYear = const Value.absent(), Value<int?> harvestYear = const Value.absent(),
Value<int?> harvestMonth = const Value.absent(),
Value<String?> quantityKind = const Value.absent(), Value<String?> quantityKind = const Value.absent(),
Value<double?> quantityPrecise = const Value.absent(), Value<double?> quantityPrecise = const Value.absent(),
Value<String?> quantityLabel = const Value.absent(), Value<String?> quantityLabel = const Value.absent(),
@ -3097,6 +3133,7 @@ class Lot extends DataClass implements Insertable<Lot> {
varietyId: varietyId ?? this.varietyId, varietyId: varietyId ?? this.varietyId,
type: type ?? this.type, type: type ?? this.type,
harvestYear: harvestYear.present ? harvestYear.value : this.harvestYear, harvestYear: harvestYear.present ? harvestYear.value : this.harvestYear,
harvestMonth: harvestMonth.present ? harvestMonth.value : this.harvestMonth,
quantityKind: quantityKind.present ? quantityKind.value : this.quantityKind, quantityKind: quantityKind.present ? quantityKind.value : this.quantityKind,
quantityPrecise: quantityPrecise.present quantityPrecise: quantityPrecise.present
? quantityPrecise.value ? quantityPrecise.value
@ -3127,6 +3164,9 @@ class Lot extends DataClass implements Insertable<Lot> {
harvestYear: data.harvestYear.present harvestYear: data.harvestYear.present
? data.harvestYear.value ? data.harvestYear.value
: this.harvestYear, : this.harvestYear,
harvestMonth: data.harvestMonth.present
? data.harvestMonth.value
: this.harvestMonth,
quantityKind: data.quantityKind.present quantityKind: data.quantityKind.present
? data.quantityKind.value ? data.quantityKind.value
: this.quantityKind, : this.quantityKind,
@ -3160,6 +3200,7 @@ class Lot extends DataClass implements Insertable<Lot> {
..write('varietyId: $varietyId, ') ..write('varietyId: $varietyId, ')
..write('type: $type, ') ..write('type: $type, ')
..write('harvestYear: $harvestYear, ') ..write('harvestYear: $harvestYear, ')
..write('harvestMonth: $harvestMonth, ')
..write('quantityKind: $quantityKind, ') ..write('quantityKind: $quantityKind, ')
..write('quantityPrecise: $quantityPrecise, ') ..write('quantityPrecise: $quantityPrecise, ')
..write('quantityLabel: $quantityLabel, ') ..write('quantityLabel: $quantityLabel, ')
@ -3181,6 +3222,7 @@ class Lot extends DataClass implements Insertable<Lot> {
varietyId, varietyId,
type, type,
harvestYear, harvestYear,
harvestMonth,
quantityKind, quantityKind,
quantityPrecise, quantityPrecise,
quantityLabel, quantityLabel,
@ -3201,6 +3243,7 @@ class Lot extends DataClass implements Insertable<Lot> {
other.varietyId == this.varietyId && other.varietyId == this.varietyId &&
other.type == this.type && other.type == this.type &&
other.harvestYear == this.harvestYear && other.harvestYear == this.harvestYear &&
other.harvestMonth == this.harvestMonth &&
other.quantityKind == this.quantityKind && other.quantityKind == this.quantityKind &&
other.quantityPrecise == this.quantityPrecise && other.quantityPrecise == this.quantityPrecise &&
other.quantityLabel == this.quantityLabel && other.quantityLabel == this.quantityLabel &&
@ -3219,6 +3262,7 @@ class LotsCompanion extends UpdateCompanion<Lot> {
final Value<String> varietyId; final Value<String> varietyId;
final Value<LotType> type; final Value<LotType> type;
final Value<int?> harvestYear; final Value<int?> harvestYear;
final Value<int?> harvestMonth;
final Value<String?> quantityKind; final Value<String?> quantityKind;
final Value<double?> quantityPrecise; final Value<double?> quantityPrecise;
final Value<String?> quantityLabel; final Value<String?> quantityLabel;
@ -3236,6 +3280,7 @@ class LotsCompanion extends UpdateCompanion<Lot> {
this.varietyId = const Value.absent(), this.varietyId = const Value.absent(),
this.type = const Value.absent(), this.type = const Value.absent(),
this.harvestYear = const Value.absent(), this.harvestYear = const Value.absent(),
this.harvestMonth = const Value.absent(),
this.quantityKind = const Value.absent(), this.quantityKind = const Value.absent(),
this.quantityPrecise = const Value.absent(), this.quantityPrecise = const Value.absent(),
this.quantityLabel = const Value.absent(), this.quantityLabel = const Value.absent(),
@ -3254,6 +3299,7 @@ class LotsCompanion extends UpdateCompanion<Lot> {
required String varietyId, required String varietyId,
this.type = const Value.absent(), this.type = const Value.absent(),
this.harvestYear = const Value.absent(), this.harvestYear = const Value.absent(),
this.harvestMonth = const Value.absent(),
this.quantityKind = const Value.absent(), this.quantityKind = const Value.absent(),
this.quantityPrecise = const Value.absent(), this.quantityPrecise = const Value.absent(),
this.quantityLabel = const Value.absent(), this.quantityLabel = const Value.absent(),
@ -3276,6 +3322,7 @@ class LotsCompanion extends UpdateCompanion<Lot> {
Expression<String>? varietyId, Expression<String>? varietyId,
Expression<String>? type, Expression<String>? type,
Expression<int>? harvestYear, Expression<int>? harvestYear,
Expression<int>? harvestMonth,
Expression<String>? quantityKind, Expression<String>? quantityKind,
Expression<double>? quantityPrecise, Expression<double>? quantityPrecise,
Expression<String>? quantityLabel, Expression<String>? quantityLabel,
@ -3294,6 +3341,7 @@ class LotsCompanion extends UpdateCompanion<Lot> {
if (varietyId != null) 'variety_id': varietyId, if (varietyId != null) 'variety_id': varietyId,
if (type != null) 'type': type, if (type != null) 'type': type,
if (harvestYear != null) 'harvest_year': harvestYear, if (harvestYear != null) 'harvest_year': harvestYear,
if (harvestMonth != null) 'harvest_month': harvestMonth,
if (quantityKind != null) 'quantity_kind': quantityKind, if (quantityKind != null) 'quantity_kind': quantityKind,
if (quantityPrecise != null) 'quantity_precise': quantityPrecise, if (quantityPrecise != null) 'quantity_precise': quantityPrecise,
if (quantityLabel != null) 'quantity_label': quantityLabel, if (quantityLabel != null) 'quantity_label': quantityLabel,
@ -3314,6 +3362,7 @@ class LotsCompanion extends UpdateCompanion<Lot> {
Value<String>? varietyId, Value<String>? varietyId,
Value<LotType>? type, Value<LotType>? type,
Value<int?>? harvestYear, Value<int?>? harvestYear,
Value<int?>? harvestMonth,
Value<String?>? quantityKind, Value<String?>? quantityKind,
Value<double?>? quantityPrecise, Value<double?>? quantityPrecise,
Value<String?>? quantityLabel, Value<String?>? quantityLabel,
@ -3332,6 +3381,7 @@ class LotsCompanion extends UpdateCompanion<Lot> {
varietyId: varietyId ?? this.varietyId, varietyId: varietyId ?? this.varietyId,
type: type ?? this.type, type: type ?? this.type,
harvestYear: harvestYear ?? this.harvestYear, harvestYear: harvestYear ?? this.harvestYear,
harvestMonth: harvestMonth ?? this.harvestMonth,
quantityKind: quantityKind ?? this.quantityKind, quantityKind: quantityKind ?? this.quantityKind,
quantityPrecise: quantityPrecise ?? this.quantityPrecise, quantityPrecise: quantityPrecise ?? this.quantityPrecise,
quantityLabel: quantityLabel ?? this.quantityLabel, quantityLabel: quantityLabel ?? this.quantityLabel,
@ -3374,6 +3424,9 @@ class LotsCompanion extends UpdateCompanion<Lot> {
if (harvestYear.present) { if (harvestYear.present) {
map['harvest_year'] = Variable<int>(harvestYear.value); map['harvest_year'] = Variable<int>(harvestYear.value);
} }
if (harvestMonth.present) {
map['harvest_month'] = Variable<int>(harvestMonth.value);
}
if (quantityKind.present) { if (quantityKind.present) {
map['quantity_kind'] = Variable<String>(quantityKind.value); map['quantity_kind'] = Variable<String>(quantityKind.value);
} }
@ -3412,6 +3465,7 @@ class LotsCompanion extends UpdateCompanion<Lot> {
..write('varietyId: $varietyId, ') ..write('varietyId: $varietyId, ')
..write('type: $type, ') ..write('type: $type, ')
..write('harvestYear: $harvestYear, ') ..write('harvestYear: $harvestYear, ')
..write('harvestMonth: $harvestMonth, ')
..write('quantityKind: $quantityKind, ') ..write('quantityKind: $quantityKind, ')
..write('quantityPrecise: $quantityPrecise, ') ..write('quantityPrecise: $quantityPrecise, ')
..write('quantityLabel: $quantityLabel, ') ..write('quantityLabel: $quantityLabel, ')
@ -8188,6 +8242,7 @@ typedef $$LotsTableCreateCompanionBuilder =
required String varietyId, required String varietyId,
Value<LotType> type, Value<LotType> type,
Value<int?> harvestYear, Value<int?> harvestYear,
Value<int?> harvestMonth,
Value<String?> quantityKind, Value<String?> quantityKind,
Value<double?> quantityPrecise, Value<double?> quantityPrecise,
Value<String?> quantityLabel, Value<String?> quantityLabel,
@ -8207,6 +8262,7 @@ typedef $$LotsTableUpdateCompanionBuilder =
Value<String> varietyId, Value<String> varietyId,
Value<LotType> type, Value<LotType> type,
Value<int?> harvestYear, Value<int?> harvestYear,
Value<int?> harvestMonth,
Value<String?> quantityKind, Value<String?> quantityKind,
Value<double?> quantityPrecise, Value<double?> quantityPrecise,
Value<String?> quantityLabel, Value<String?> quantityLabel,
@ -8270,6 +8326,11 @@ class $$LotsTableFilterComposer extends Composer<_$AppDatabase, $LotsTable> {
builder: (column) => ColumnFilters(column), builder: (column) => ColumnFilters(column),
); );
ColumnFilters<int> get harvestMonth => $composableBuilder(
column: $table.harvestMonth,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get quantityKind => $composableBuilder( ColumnFilters<String> get quantityKind => $composableBuilder(
column: $table.quantityKind, column: $table.quantityKind,
builder: (column) => ColumnFilters(column), builder: (column) => ColumnFilters(column),
@ -8355,6 +8416,11 @@ class $$LotsTableOrderingComposer extends Composer<_$AppDatabase, $LotsTable> {
builder: (column) => ColumnOrderings(column), builder: (column) => ColumnOrderings(column),
); );
ColumnOrderings<int> get harvestMonth => $composableBuilder(
column: $table.harvestMonth,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get quantityKind => $composableBuilder( ColumnOrderings<String> get quantityKind => $composableBuilder(
column: $table.quantityKind, column: $table.quantityKind,
builder: (column) => ColumnOrderings(column), builder: (column) => ColumnOrderings(column),
@ -8428,6 +8494,11 @@ class $$LotsTableAnnotationComposer
builder: (column) => column, builder: (column) => column,
); );
GeneratedColumn<int> get harvestMonth => $composableBuilder(
column: $table.harvestMonth,
builder: (column) => column,
);
GeneratedColumn<String> get quantityKind => $composableBuilder( GeneratedColumn<String> get quantityKind => $composableBuilder(
column: $table.quantityKind, column: $table.quantityKind,
builder: (column) => column, builder: (column) => column,
@ -8497,6 +8568,7 @@ class $$LotsTableTableManager
Value<String> varietyId = const Value.absent(), Value<String> varietyId = const Value.absent(),
Value<LotType> type = const Value.absent(), Value<LotType> type = const Value.absent(),
Value<int?> harvestYear = const Value.absent(), Value<int?> harvestYear = const Value.absent(),
Value<int?> harvestMonth = const Value.absent(),
Value<String?> quantityKind = const Value.absent(), Value<String?> quantityKind = const Value.absent(),
Value<double?> quantityPrecise = const Value.absent(), Value<double?> quantityPrecise = const Value.absent(),
Value<String?> quantityLabel = const Value.absent(), Value<String?> quantityLabel = const Value.absent(),
@ -8514,6 +8586,7 @@ class $$LotsTableTableManager
varietyId: varietyId, varietyId: varietyId,
type: type, type: type,
harvestYear: harvestYear, harvestYear: harvestYear,
harvestMonth: harvestMonth,
quantityKind: quantityKind, quantityKind: quantityKind,
quantityPrecise: quantityPrecise, quantityPrecise: quantityPrecise,
quantityLabel: quantityLabel, quantityLabel: quantityLabel,
@ -8533,6 +8606,7 @@ class $$LotsTableTableManager
required String varietyId, required String varietyId,
Value<LotType> type = const Value.absent(), Value<LotType> type = const Value.absent(),
Value<int?> harvestYear = const Value.absent(), Value<int?> harvestYear = const Value.absent(),
Value<int?> harvestMonth = const Value.absent(),
Value<String?> quantityKind = const Value.absent(), Value<String?> quantityKind = const Value.absent(),
Value<double?> quantityPrecise = const Value.absent(), Value<double?> quantityPrecise = const Value.absent(),
Value<String?> quantityLabel = const Value.absent(), Value<String?> quantityLabel = const Value.absent(),
@ -8550,6 +8624,7 @@ class $$LotsTableTableManager
varietyId: varietyId, varietyId: varietyId,
type: type, type: type,
harvestYear: harvestYear, harvestYear: harvestYear,
harvestMonth: harvestMonth,
quantityKind: quantityKind, quantityKind: quantityKind,
quantityPrecise: quantityPrecise, quantityPrecise: quantityPrecise,
quantityLabel: quantityLabel, quantityLabel: quantityLabel,

View file

@ -46,6 +46,7 @@ class Lots extends Table with SyncColumns {
TextColumn get type => TextColumn get type =>
textEnum<LotType>().withDefault(const Constant('seed'))(); textEnum<LotType>().withDefault(const Constant('seed'))();
IntColumn get harvestYear => integer().nullable()(); IntColumn get harvestYear => integer().nullable()();
IntColumn get harvestMonth => integer().nullable()(); // 1..12, optional
TextColumn get quantityKind => text().nullable()(); // QuantityKind.name TextColumn get quantityKind => text().nullable()(); // QuantityKind.name
RealColumn get quantityPrecise => real().nullable()(); RealColumn get quantityPrecise => real().nullable()();
TextColumn get quantityLabel => text().nullable()(); TextColumn get quantityLabel => text().nullable()();

View file

@ -56,10 +56,19 @@
}, },
"addLot": { "addLot": {
"title": "Add lot", "title": "Add lot",
"year": "Harvest year", "year": "Harvest date",
"quantity": "How much?", "quantity": "How much?",
"amount": "Amount" "amount": "Amount"
}, },
"harvest": {
"pickTitle": "Select month / year",
"anyMonth": "Any month",
"noDate": "Set harvest date",
"monthNames": [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
]
},
"lotType": { "lotType": {
"seed": "Seeds", "seed": "Seeds",
"plant": "Plants" "plant": "Plants"

View file

@ -56,10 +56,19 @@
}, },
"addLot": { "addLot": {
"title": "Añadir lote", "title": "Añadir lote",
"year": "Año de cosecha", "year": "Fecha de cosecha",
"quantity": "¿Cuánta?", "quantity": "¿Cuánta?",
"amount": "Cantidad" "amount": "Cantidad"
}, },
"harvest": {
"pickTitle": "Seleccionar mes / año",
"anyMonth": "Cualquier mes",
"noDate": "Indicar fecha de cosecha",
"monthNames": [
"enero", "febrero", "marzo", "abril", "mayo", "junio",
"julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"
]
},
"lotType": { "lotType": {
"seed": "Semillas", "seed": "Semillas",
"plant": "Plantel" "plant": "Plantel"

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 2 /// Locales: 2
/// Strings: 186 (93 per locale) /// Strings: 216 (108 per locale)
/// ///
/// Built on 2026-07-08 at 09:16 UTC /// Built on 2026-07-08 at 10:02 UTC
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import

View file

@ -48,6 +48,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
late final Translations$germination$en germination = Translations$germination$en.internal(_root); late final Translations$germination$en germination = Translations$germination$en.internal(_root);
late final Translations$editVariety$en editVariety = Translations$editVariety$en.internal(_root); late final Translations$editVariety$en editVariety = Translations$editVariety$en.internal(_root);
late final Translations$addLot$en addLot = Translations$addLot$en.internal(_root); late final Translations$addLot$en addLot = Translations$addLot$en.internal(_root);
late final Translations$harvest$en harvest = Translations$harvest$en.internal(_root);
late final Translations$lotType$en lotType = Translations$lotType$en.internal(_root); late final Translations$lotType$en lotType = Translations$lotType$en.internal(_root);
late final Translations$unit$en unit = Translations$unit$en.internal(_root); late final Translations$unit$en unit = Translations$unit$en.internal(_root);
} }
@ -249,8 +250,8 @@ class Translations$addLot$en {
/// en: 'Add lot' /// en: 'Add lot'
String get title => 'Add lot'; String get title => 'Add lot';
/// en: 'Harvest year' /// en: 'Harvest date'
String get year => 'Harvest year'; String get year => 'Harvest date';
/// en: 'How much?' /// en: 'How much?'
String get quantity => 'How much?'; String get quantity => 'How much?';
@ -259,6 +260,39 @@ class Translations$addLot$en {
String get amount => 'Amount'; String get amount => 'Amount';
} }
// Path: harvest
class Translations$harvest$en {
Translations$harvest$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Select month / year'
String get pickTitle => 'Select month / year';
/// en: 'Any month'
String get anyMonth => 'Any month';
/// en: 'Set harvest date'
String get noDate => 'Set harvest date';
List<String> get monthNames => [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
}
// Path: lotType // Path: lotType
class Translations$lotType$en { class Translations$lotType$en {
Translations$lotType$en.internal(this._root); Translations$lotType$en.internal(this._root);
@ -682,9 +716,24 @@ extension on Translations {
'editVariety.species' => 'Species (from catalog)', 'editVariety.species' => 'Species (from catalog)',
'editVariety.speciesHint' => 'Search a species…', 'editVariety.speciesHint' => 'Search a species…',
'addLot.title' => 'Add lot', 'addLot.title' => 'Add lot',
'addLot.year' => 'Harvest year', 'addLot.year' => 'Harvest date',
'addLot.quantity' => 'How much?', 'addLot.quantity' => 'How much?',
'addLot.amount' => 'Amount', 'addLot.amount' => 'Amount',
'harvest.pickTitle' => 'Select month / year',
'harvest.anyMonth' => 'Any month',
'harvest.noDate' => 'Set harvest date',
'harvest.monthNames.0' => 'January',
'harvest.monthNames.1' => 'February',
'harvest.monthNames.2' => 'March',
'harvest.monthNames.3' => 'April',
'harvest.monthNames.4' => 'May',
'harvest.monthNames.5' => 'June',
'harvest.monthNames.6' => 'July',
'harvest.monthNames.7' => 'August',
'harvest.monthNames.8' => 'September',
'harvest.monthNames.9' => 'October',
'harvest.monthNames.10' => 'November',
'harvest.monthNames.11' => 'December',
'lotType.seed' => 'Seeds', 'lotType.seed' => 'Seeds',
'lotType.plant' => 'Plants', 'lotType.plant' => 'Plants',
'unit.aFew' => 'a few', 'unit.aFew' => 'a few',

View file

@ -47,6 +47,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$germination$es germination = _Translations$germination$es._(_root); @override late final _Translations$germination$es germination = _Translations$germination$es._(_root);
@override late final _Translations$editVariety$es editVariety = _Translations$editVariety$es._(_root); @override late final _Translations$editVariety$es editVariety = _Translations$editVariety$es._(_root);
@override late final _Translations$addLot$es addLot = _Translations$addLot$es._(_root); @override late final _Translations$addLot$es addLot = _Translations$addLot$es._(_root);
@override late final _Translations$harvest$es harvest = _Translations$harvest$es._(_root);
@override late final _Translations$lotType$es lotType = _Translations$lotType$es._(_root); @override late final _Translations$lotType$es lotType = _Translations$lotType$es._(_root);
@override late final _Translations$unit$es unit = _Translations$unit$es._(_root); @override late final _Translations$unit$es unit = _Translations$unit$es._(_root);
} }
@ -163,11 +164,37 @@ class _Translations$addLot$es extends Translations$addLot$en {
// Translations // Translations
@override String get title => 'Añadir lote'; @override String get title => 'Añadir lote';
@override String get year => 'Año de cosecha'; @override String get year => 'Fecha de cosecha';
@override String get quantity => '¿Cuánta?'; @override String get quantity => '¿Cuánta?';
@override String get amount => 'Cantidad'; @override String get amount => 'Cantidad';
} }
// Path: harvest
class _Translations$harvest$es extends Translations$harvest$en {
_Translations$harvest$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get pickTitle => 'Seleccionar mes / año';
@override String get anyMonth => 'Cualquier mes';
@override String get noDate => 'Indicar fecha de cosecha';
@override List<String> get monthNames => [
'enero',
'febrero',
'marzo',
'abril',
'mayo',
'junio',
'julio',
'agosto',
'septiembre',
'octubre',
'noviembre',
'diciembre',
];
}
// Path: lotType // Path: lotType
class _Translations$lotType$es extends Translations$lotType$en { class _Translations$lotType$es extends Translations$lotType$en {
_Translations$lotType$es._(TranslationsEs root) : this._root = root, super.internal(root); _Translations$lotType$es._(TranslationsEs root) : this._root = root, super.internal(root);
@ -494,9 +521,24 @@ extension on TranslationsEs {
'editVariety.species' => 'Especie (del catálogo)', 'editVariety.species' => 'Especie (del catálogo)',
'editVariety.speciesHint' => 'Buscar una especie…', 'editVariety.speciesHint' => 'Buscar una especie…',
'addLot.title' => 'Añadir lote', 'addLot.title' => 'Añadir lote',
'addLot.year' => 'Año de cosecha', 'addLot.year' => 'Fecha de cosecha',
'addLot.quantity' => '¿Cuánta?', 'addLot.quantity' => '¿Cuánta?',
'addLot.amount' => 'Cantidad', 'addLot.amount' => 'Cantidad',
'harvest.pickTitle' => 'Seleccionar mes / año',
'harvest.anyMonth' => 'Cualquier mes',
'harvest.noDate' => 'Indicar fecha de cosecha',
'harvest.monthNames.0' => 'enero',
'harvest.monthNames.1' => 'febrero',
'harvest.monthNames.2' => 'marzo',
'harvest.monthNames.3' => 'abril',
'harvest.monthNames.4' => 'mayo',
'harvest.monthNames.5' => 'junio',
'harvest.monthNames.6' => 'julio',
'harvest.monthNames.7' => 'agosto',
'harvest.monthNames.8' => 'septiembre',
'harvest.monthNames.9' => 'octubre',
'harvest.monthNames.10' => 'noviembre',
'harvest.monthNames.11' => 'diciembre',
'lotType.seed' => 'Semillas', 'lotType.seed' => 'Semillas',
'lotType.plant' => 'Plantel', 'lotType.plant' => 'Plantel',
'unit.aFew' => 'unas pocas', 'unit.aFew' => 'unas pocas',

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data';
import 'package:commons_core/commons_core.dart'; import 'package:commons_core/commons_core.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
@ -54,11 +55,13 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
Future<void> addLot({ Future<void> addLot({
LotType type = LotType.seed, LotType type = LotType.seed,
int? year, int? year,
int? month,
Quantity? quantity, Quantity? quantity,
}) => _repo.addLot( }) => _repo.addLot(
varietyId: varietyId, varietyId: varietyId,
type: type, type: type,
harvestYear: year, harvestYear: year,
harvestMonth: month,
quantity: quantity, quantity: quantity,
); );
@ -66,11 +69,13 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
required String lotId, required String lotId,
required LotType type, required LotType type,
int? year, int? year,
int? month,
Quantity? quantity, Quantity? quantity,
}) => _repo.updateLot( }) => _repo.updateLot(
lotId: lotId, lotId: lotId,
type: type, type: type,
harvestYear: year, harvestYear: year,
harvestMonth: month,
quantity: quantity, quantity: quantity,
); );
@ -82,6 +87,11 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
Future<void> removeVernacularName(String nameId) => Future<void> removeVernacularName(String nameId) =>
_repo.removeVernacularName(nameId); _repo.removeVernacularName(nameId);
Future<void> addPhoto(Uint8List bytes) => _repo.addPhoto(varietyId, bytes);
Future<void> removePhoto(String attachmentId) =>
_repo.removePhoto(attachmentId);
Future<void> linkSpecies(String speciesId) => Future<void> linkSpecies(String speciesId) =>
_repo.linkSpecies(varietyId, speciesId); _repo.linkSpecies(varietyId, speciesId);

View file

@ -0,0 +1,396 @@
import 'package:flutter/material.dart';
import '../i18n/strings.g.dart';
import 'theme.dart';
/// How many years back the picker offers. A freshly harvested lot is the common
/// case, but old seed still needs a home.
const _yearsBack = 40;
/// A harvest date: a required [year] plus an optional [month] (1..12).
class HarvestDate {
const HarvestDate(this.year, [this.month]);
final int year;
final int? month;
}
/// Formats a harvest date for display: "September 2021" when a month is set,
/// otherwise just the localized "Year 2021".
String harvestDateLabel(Translations t, {required int year, int? month}) {
if (month == null) return t.detail.year(year: year);
return '${t.harvest.monthNames[month - 1]} $year';
}
/// An inline, tappable field showing the current harvest date. Tapping opens a
/// themed month/year dialog (see [showHarvestDatePicker]). Month is optional, so
/// the field can read "September 2021" or just "2021".
class HarvestDateField extends StatelessWidget {
const HarvestDateField({
required this.year,
required this.onChanged,
this.month,
super.key,
});
final int year;
final int? month;
final void Function(int year, int? month) onChanged;
@override
Widget build(BuildContext context) {
final t = context.t;
return OutlinedButton.icon(
key: const Key('addLot.year'),
onPressed: () async {
final picked = await showHarvestDatePicker(
context,
initial: HarvestDate(year, month),
);
if (picked != null) onChanged(picked.year, picked.month);
},
icon: const Icon(Icons.event_outlined, size: 20),
label: Align(
alignment: Alignment.centerLeft,
child: Text(harvestDateLabel(t, year: year, month: month)),
),
style: OutlinedButton.styleFrom(
foregroundColor: seedGreenDark,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
minimumSize: const Size.fromHeight(0),
),
);
}
}
/// Opens the themed month/year dialog. Returns the chosen [HarvestDate], or null
/// if the user cancels.
Future<HarvestDate?> showHarvestDatePicker(
BuildContext context, {
required HarvestDate initial,
}) {
return showDialog<HarvestDate>(
context: context,
builder: (_) => _HarvestPickerDialog(initial: initial),
);
}
enum _Mode { months, years }
class _HarvestPickerDialog extends StatefulWidget {
const _HarvestPickerDialog({required this.initial});
final HarvestDate initial;
@override
State<_HarvestPickerDialog> createState() => _HarvestPickerDialogState();
}
class _HarvestPickerDialogState extends State<_HarvestPickerDialog> {
late int _year = widget.initial.year;
late int? _month = widget.initial.month;
var _mode = _Mode.months;
int get _maxYear => DateTime.now().year;
int get _minYear => _maxYear - _yearsBack;
void _setYear(int year) => setState(() {
_year = year.clamp(_minYear, _maxYear);
_mode = _Mode.months;
});
void _toggleMonth(int month) =>
setState(() => _month = _month == month ? null : month);
@override
Widget build(BuildContext context) {
final t = context.t;
return Dialog(
clipBehavior: Clip.antiAlias,
child: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Header(
title: t.harvest.pickTitle,
selection: harvestDateLabel(t, year: _year, month: _month),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: _NavRow(
year: _year,
mode: _mode,
onToggleMode: () => setState(
() =>
_mode = _mode == _Mode.years ? _Mode.months : _Mode.years,
),
onStep: (delta) {
if (_mode == _Mode.months) {
_setYear(_year + delta);
} else {
// Page the year grid by a screenful (12 years).
_setYearGridPage(delta);
}
},
),
),
AnimatedSize(
duration: const Duration(milliseconds: 150),
child: _mode == _Mode.months
? _MonthGrid(
selected: _month,
onSelect: _toggleMonth,
onClear: () => setState(() => _month = null),
)
: _YearGrid(
years: _yearPage,
selected: _year,
onSelect: _setYear,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
key: const Key('harvestPicker.cancel'),
onPressed: () => Navigator.of(context).pop(),
child: Text(t.common.cancel),
),
TextButton(
key: const Key('harvestPicker.ok'),
onPressed: () =>
Navigator.of(context).pop(HarvestDate(_year, _month)),
child: Text(t.common.save),
),
],
),
),
],
),
),
);
}
// --- Year grid paging -----------------------------------------------------
// Top-left year of the visible page in years mode. Anchored to the current
// year so recent harvests show first; older years are one page away.
late int _yearPageAnchor = _maxYear;
List<int> get _yearPage {
// A page of up to 12 years, newest at the top-left, clamped to the range.
final start = _yearPageAnchor;
return [for (var y = start; y > start - 12 && y >= _minYear; y--) y];
}
void _setYearGridPage(int delta) => setState(() {
// delta -1 () older page, +1 () newer page.
_yearPageAnchor = (_yearPageAnchor + delta * 12).clamp(
_minYear + 11,
_maxYear,
);
});
}
class _Header extends StatelessWidget {
const _Header({required this.title, required this.selection});
final String title;
final String selection;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
color: seedGreen,
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title.toUpperCase(),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
letterSpacing: 1.2,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
selection,
style: const TextStyle(
color: Colors.white,
fontSize: 30,
fontWeight: FontWeight.w400,
),
),
],
),
);
}
}
class _NavRow extends StatelessWidget {
const _NavRow({
required this.year,
required this.mode,
required this.onToggleMode,
required this.onStep,
});
final int year;
final _Mode mode;
final VoidCallback onToggleMode;
final ValueChanged<int> onStep;
@override
Widget build(BuildContext context) {
return Row(
children: [
TextButton.icon(
key: const Key('harvestPicker.yearToggle'),
onPressed: onToggleMode,
label: Text('$year'),
icon: Icon(
mode == _Mode.years ? Icons.arrow_drop_up : Icons.arrow_drop_down,
),
iconAlignment: IconAlignment.end,
style: TextButton.styleFrom(foregroundColor: seedGreenDark),
),
const Spacer(),
IconButton(
key: const Key('harvestPicker.prev'),
onPressed: () => onStep(-1),
icon: const Icon(Icons.chevron_left),
),
IconButton(
key: const Key('harvestPicker.next'),
onPressed: () => onStep(1),
icon: const Icon(Icons.chevron_right),
),
],
);
}
}
class _MonthGrid extends StatelessWidget {
const _MonthGrid({
required this.selected,
required this.onSelect,
required this.onClear,
});
final int? selected;
final ValueChanged<int> onSelect;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
final t = context.t;
final names = t.harvest.monthNames;
return Column(
children: [
GridView.count(
crossAxisCount: 3,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 12),
childAspectRatio: 2.4,
children: [
for (var m = 1; m <= 12; m++)
_PickCell(
key: Key('harvestPicker.month.$m'),
label: names[m - 1],
selected: selected == m,
onTap: () => onSelect(m),
),
],
),
TextButton(
key: const Key('harvestPicker.anyMonth'),
onPressed: selected == null ? null : onClear,
child: Text(t.harvest.anyMonth),
),
],
);
}
}
class _YearGrid extends StatelessWidget {
const _YearGrid({
required this.years,
required this.selected,
required this.onSelect,
});
final List<int> years;
final int selected;
final ValueChanged<int> onSelect;
@override
Widget build(BuildContext context) {
return GridView.count(
crossAxisCount: 4,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
childAspectRatio: 1.8,
children: [
for (final y in years)
_PickCell(
key: Key('harvestPicker.year.$y'),
label: '$y',
selected: y == selected,
onTap: () => onSelect(y),
),
],
);
}
}
/// A round-highlight cell used for both months and years, matching the seed
/// theme (green pill when selected).
class _PickCell extends StatelessWidget {
const _PickCell({
required this.label,
required this.selected,
required this.onTap,
super.key,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(4),
child: Material(
color: selected ? seedGreen : Colors.transparent,
shape: const StadiumBorder(),
child: InkWell(
customBorder: const StadiumBorder(),
onTap: onTap,
child: Center(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: selected ? Colors.white : null,
fontWeight: selected ? FontWeight.w600 : null,
),
),
),
),
),
);
}
}

View file

@ -1,11 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
import '../data/species_repository.dart'; import '../data/species_repository.dart';
import '../data/variety_repository.dart'; import '../data/variety_repository.dart';
import '../db/enums.dart'; import '../db/enums.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../state/variety_detail_cubit.dart'; import '../state/variety_detail_cubit.dart';
import 'harvest_date_picker.dart';
import 'quantity_kind_l10n.dart'; import 'quantity_kind_l10n.dart';
import 'quantity_picker.dart'; import 'quantity_picker.dart';
import 'seed_glyph.dart'; import 'seed_glyph.dart';
@ -155,7 +157,7 @@ class _DetailView extends StatelessWidget {
String _lotSubtitle(Translations t, VarietyLot lot) { String _lotSubtitle(Translations t, VarietyLot lot) {
final parts = <String>[ final parts = <String>[
if (lot.harvestYear != null) if (lot.harvestYear != null)
t.detail.year(year: lot.harvestYear!) harvestDateLabel(t, year: lot.harvestYear!, month: lot.harvestMonth)
else else
t.detail.noYear, t.detail.noYear,
if (lot.quantity != null) quantityDisplay(t, lot.quantity!), if (lot.quantity != null) quantityDisplay(t, lot.quantity!),
@ -495,11 +497,8 @@ Future<void> _showLotSheet(
VarietyLot? existing, VarietyLot? existing,
}) { }) {
final t = context.t; final t = context.t;
final currentYear = DateTime.now().year; var selectedYear = existing?.harvestYear ?? DateTime.now().year;
// Harvest years: current year down through the last four decades. Newest var selectedMonth = existing?.harvestMonth;
// first because a freshly harvested lot is the common case.
final years = [for (var y = currentYear; y >= currentYear - 40; y--) y];
var selectedYear = existing?.harvestYear ?? currentYear;
var selectedType = existing?.type ?? LotType.seed; var selectedType = existing?.type ?? LotType.seed;
var selectedQuantity = existing?.quantity; var selectedQuantity = existing?.quantity;
final editing = existing != null; final editing = existing != null;
@ -547,20 +546,18 @@ Future<void> _showLotSheet(
}), }),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
DropdownButtonFormField<int>( Text(
key: const Key('addLot.year'), t.addLot.year,
initialValue: selectedYear, style: Theme.of(sheetContext).textTheme.labelLarge,
decoration: InputDecoration( ),
labelText: t.addLot.year, const SizedBox(height: 8),
border: const OutlineInputBorder(), HarvestDateField(
), year: selectedYear,
items: [ month: selectedMonth,
for (final y in years) onChanged: (year, month) => setState(() {
DropdownMenuItem(value: y, child: Text('$y')), selectedYear = year;
], selectedMonth = month;
onChanged: (value) { }),
if (value != null) setState(() => selectedYear = value);
},
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
@ -589,12 +586,14 @@ Future<void> _showLotSheet(
lotId: existing.id, lotId: existing.id,
type: selectedType, type: selectedType,
year: selectedYear, year: selectedYear,
month: selectedMonth,
quantity: selectedQuantity, quantity: selectedQuantity,
); );
} else { } else {
cubit.addLot( cubit.addLot(
type: selectedType, type: selectedType,
year: selectedYear, year: selectedYear,
month: selectedMonth,
quantity: selectedQuantity, quantity: selectedQuantity,
); );
} }
@ -692,18 +691,166 @@ class _DetailHeader extends StatelessWidget {
], ],
), ),
), ),
if (detail.photo != null) ...[ if (hasText) const SizedBox(width: 12),
if (hasText) const SizedBox(width: 12), _PhotoGallery(detail: detail),
ClipRRect( ],
borderRadius: BorderRadius.circular(12), );
child: Image.memory( }
detail.photo!, }
width: 140,
height: 140, /// A 140×140 photo carousel (mockup 07): swipe between photos, page dots, add a
fit: BoxFit.cover, /// photo, and delete the current one. Shows just an "add" button when empty.
class _PhotoGallery extends StatefulWidget {
const _PhotoGallery({required this.detail});
final VarietyDetail detail;
@override
State<_PhotoGallery> createState() => _PhotoGalleryState();
}
class _PhotoGalleryState extends State<_PhotoGallery> {
final _controller = PageController();
int _page = 0;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _addPhoto(VarietyDetailCubit cubit) async {
final file = await ImagePicker().pickImage(
source: ImageSource.gallery,
maxWidth: 1280,
imageQuality: 80,
);
final bytes = await file?.readAsBytes();
if (bytes != null) await cubit.addPhoto(bytes);
}
@override
Widget build(BuildContext context) {
final cubit = context.read<VarietyDetailCubit>();
final photos = widget.detail.photos;
if (photos.isEmpty) {
return SizedBox(
width: 140,
height: 140,
child: OutlinedButton(
key: const Key('photo.add'),
onPressed: () => _addPhoto(cubit),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.add_a_photo_outlined),
const SizedBox(height: 8),
Text(context.t.quickAdd.addPhoto, textAlign: TextAlign.center),
],
),
),
);
}
final page = _page.clamp(0, photos.length - 1);
return SizedBox(
width: 140,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 140,
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: PageView.builder(
controller: _controller,
itemCount: photos.length,
onPageChanged: (i) => setState(() => _page = i),
itemBuilder: (_, i) => Image.memory(
photos[i].bytes,
width: 140,
height: 140,
fit: BoxFit.cover,
),
),
),
Positioned(
top: 2,
right: 2,
child: _CircleIconButton(
icon: Icons.close,
tooltip: context.t.common.delete,
onPressed: () => cubit.removePhoto(photos[page].id),
),
),
],
), ),
), ),
const SizedBox(height: 6),
if (photos.length > 1) _Dots(count: photos.length, active: page),
TextButton.icon(
key: const Key('photo.add'),
onPressed: () => _addPhoto(cubit),
icon: const Icon(Icons.add_a_photo_outlined, size: 18),
label: Text(context.t.quickAdd.addPhoto),
),
], ],
),
);
}
}
class _CircleIconButton extends StatelessWidget {
const _CircleIconButton({
required this.icon,
required this.onPressed,
this.tooltip,
});
final IconData icon;
final VoidCallback onPressed;
final String? tooltip;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.black45,
shape: const CircleBorder(),
child: IconButton(
key: const Key('photo.delete'),
icon: Icon(icon, size: 18, color: Colors.white),
tooltip: tooltip,
visualDensity: VisualDensity.compact,
onPressed: onPressed,
),
);
}
}
class _Dots extends StatelessWidget {
const _Dots({required this.count, required this.active});
final int count;
final int active;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < count; i++)
Container(
width: 6,
height: 6,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == active ? seedGreen : Colors.black26,
),
),
], ],
); );
} }

View file

@ -11,19 +11,30 @@ void main() {
verifier = SchemaVerifier(GeneratedHelper()); verifier = SchemaVerifier(GeneratedHelper());
}); });
test('freshly created database matches the exported schema v2', () async { test('freshly created database matches the exported schema v3', () async {
final schema = await verifier.schemaAt(2); final schema = await verifier.schemaAt(3);
final db = AppDatabase(schema.newConnection()); final db = AppDatabase(schema.newConnection());
await verifier.migrateAndValidate(db, 2); await verifier.migrateAndValidate(db, 3);
await db.close(); await db.close();
}); });
test( test(
'upgrades v1 → v2 (adds Lot.type) and matches the fresh schema', 'upgrades v1 → v3 (adds Lot.type, then harvestMonth) and matches the '
'fresh schema',
() async { () async {
final connection = await verifier.startAt(1); final connection = await verifier.startAt(1);
final db = AppDatabase(connection); final db = AppDatabase(connection);
await verifier.migrateAndValidate(db, 2); await verifier.migrateAndValidate(db, 3);
await db.close();
},
);
test(
'upgrades v2 → v3 (adds Lot.harvestMonth) and matches the fresh schema',
() async {
final connection = await verifier.startAt(2);
final db = AppDatabase(connection);
await verifier.migrateAndValidate(db, 3);
await db.close(); await db.close();
}, },
); );

View file

@ -6,6 +6,7 @@ import 'package:drift/drift.dart';
import 'package:drift/internal/migrations.dart'; import 'package:drift/internal/migrations.dart';
import 'schema_v1.dart' as v1; import 'schema_v1.dart' as v1;
import 'schema_v2.dart' as v2; import 'schema_v2.dart' as v2;
import 'schema_v3.dart' as v3;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@ -15,10 +16,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v1.DatabaseAtV1(db); return v1.DatabaseAtV1(db);
case 2: case 2:
return v2.DatabaseAtV2(db); return v2.DatabaseAtV2(db);
case 3:
return v3.DatabaseAtV3(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
} }
static const versions = const [1, 2]; static const versions = const [1, 2, 3];
} }

File diff suppressed because it is too large Load diff

View file

@ -83,9 +83,13 @@ void main() {
await tester.tap(find.byKey(const Key('detail.addLot'))); await tester.tap(find.byKey(const Key('detail.addLot')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('addLot.year'))); await tester.tap(find.byKey(const Key('addLot.year'))); // open the picker
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('2023').last); // pick the year from the dropdown await tester.tap(find.byKey(const Key('harvestPicker.yearToggle')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('harvestPicker.year.2023')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('harvestPicker.ok')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('spoon')); // pick the unit from the scale await tester.tap(find.text('spoon')); // pick the unit from the scale
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -222,9 +226,13 @@ void main() {
await tester.tap(find.text('Year 2020 · 1 packet')); // open the lot await tester.tap(find.text('Year 2020 · 1 packet')); // open the lot
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('addLot.year'))); await tester.tap(find.byKey(const Key('addLot.year'))); // open the picker
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('2024').last); await tester.tap(find.byKey(const Key('harvestPicker.yearToggle')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('harvestPicker.year.2024')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('harvestPicker.ok')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('addLot.save'))); await tester.tap(find.byKey(const Key('addLot.save')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -233,6 +241,44 @@ void main() {
await disposeTree(tester); await disposeTree(tester);
}); });
testWidgets('picking a harvest month shows month + year on the lot', (
tester,
) async {
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Maize');
await tester.pumpWidget(
wrapDetail(
repository: repo,
varietyId: id,
species: newTestSpeciesRepository(db),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('detail.addLot')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('addLot.year'))); // open the picker
await tester.pumpAndSettle();
// Pick year 2022
await tester.tap(find.byKey(const Key('harvestPicker.yearToggle')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('harvestPicker.year.2022')));
await tester.pumpAndSettle();
// then September (month 9).
await tester.tap(find.byKey(const Key('harvestPicker.month.9')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('harvestPicker.ok')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('addLot.save')));
await tester.pumpAndSettle();
expect(find.text('September 2022'), findsOneWidget);
await disposeTree(tester);
});
testWidgets('deleting a lot removes it', (tester) async { testWidgets('deleting a lot removes it', (tester) async {
final repo = newTestRepository(db); final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Maize'); final id = await repo.addQuickVariety(label: 'Maize');