feat(db): lot presentation + attachment sort order (schema v4→v5)

Expand LotType from {seed, plant} to six append-only forms (seed,
seedling, plant, tree, bulb, cutting) and add an optional Presentation
attribute (pot/tray/plug/bareRoot/rootBall) kept separate from quantity
so 'how many' and 'in what packaging' never conflate.

Add attachments.sortOrder so the cover photo is the lowest-ordered one,
enabling reordering from the full-screen viewer. Expand QuantityKind
with the new plant-aware forms.

Migrations are additive (v1→v5 all covered by migration_test); enum
values are stored by name and appended only, keeping CRDT sync safe.
This commit is contained in:
vjrj 2026-07-09 11:51:21 +02:00
parent 06aed2a586
commit 3954c62aa6
16 changed files with 6174 additions and 32 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -67,6 +67,7 @@ class VarietyLot extends Equatable {
this.harvestYear,
this.harvestMonth,
this.quantity,
this.presentation,
this.storageLocation,
this.germinationTests = const [],
});
@ -78,6 +79,9 @@ class VarietyLot extends Equatable {
/// Optional harvest month (1..12). Only meaningful when [harvestYear] is set.
final int? harvestMonth;
final Quantity? quantity;
/// How living material is packaged (pot, tray, bare-root); null for seeds.
final Presentation? presentation;
final String? storageLocation;
final List<GerminationEntry> germinationTests;
@ -92,6 +96,7 @@ class VarietyLot extends Equatable {
harvestYear,
harvestMonth,
quantity,
presentation,
storageLocation,
germinationTests,
];
@ -252,13 +257,18 @@ class VarietyRepository {
) async {
if (varietyIds.isEmpty) return const {};
final rows =
await (_db.select(_db.attachments)..where(
await (_db.select(_db.attachments)
..where(
(a) =>
a.parentId.isIn(varietyIds) &
a.parentType.equalsValue(ParentType.variety) &
a.kind.equalsValue(AttachmentKind.photo) &
a.isDeleted.equals(false),
))
)
..orderBy([
(a) => OrderingTerm(expression: a.sortOrder),
(a) => OrderingTerm(expression: a.createdAt),
]))
.get();
final byVariety = <String, Uint8List>{};
for (final row in rows) {
@ -431,7 +441,10 @@ class VarietyRepository {
a.kind.equalsValue(AttachmentKind.photo) &
a.isDeleted.equals(false),
)
..orderBy([(a) => OrderingTerm(expression: a.createdAt)]))
..orderBy([
(a) => OrderingTerm(expression: a.sortOrder),
(a) => OrderingTerm(expression: a.createdAt),
]))
.get();
final links =
@ -527,6 +540,7 @@ class VarietyRepository {
int? harvestYear,
int? harvestMonth,
Quantity? quantity,
Presentation? presentation,
String? storageLocation,
}) async {
final (created, updated) = _stamp();
@ -546,6 +560,7 @@ class VarietyRepository {
quantityKind: Value(quantity?.kind.name),
quantityPrecise: Value(quantity?.count?.toDouble()),
quantityLabel: Value(quantity?.label),
presentation: Value(presentation),
storageLocation: Value(storageLocation),
),
);
@ -560,6 +575,7 @@ class VarietyRepository {
int? harvestYear,
int? harvestMonth,
Quantity? quantity,
Presentation? presentation,
String? storageLocation,
}) async {
final (_, updated) = _stamp();
@ -571,6 +587,7 @@ class VarietyRepository {
quantityKind: Value(quantity?.kind.name),
quantityPrecise: Value(quantity?.count?.toDouble()),
quantityLabel: Value(quantity?.label),
presentation: Value(presentation),
storageLocation: Value(storageLocation),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
@ -666,6 +683,34 @@ class VarietyRepository {
);
}
/// Makes [attachmentId] the cover (preferred) attachment of [varietyId] by
/// giving it a [sortOrder] below every current sibling. The cover is what the
/// inventory list shows as the avatar and the detail gallery shows first.
Future<void> setPreferredPhoto(String varietyId, String attachmentId) async {
final siblings =
await (_db.select(_db.attachments)..where(
(a) =>
a.parentId.equals(varietyId) &
a.parentType.equalsValue(ParentType.variety) &
a.isDeleted.equals(false),
))
.get();
var minOrder = 0;
for (final a in siblings) {
if (a.sortOrder < minOrder) minOrder = a.sortOrder;
}
final (_, updated) = _stamp();
await (_db.update(
_db.attachments,
)..where((a) => a.id.equals(attachmentId))).write(
AttachmentsCompanion(
sortOrder: Value(minOrder - 1),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
);
}
/// Adds an external link (URL, optional title) to a variety. Returns its id.
Future<String> addExternalLink(
String varietyId,
@ -756,6 +801,7 @@ class VarietyRepository {
type: l.type,
harvestYear: l.harvestYear,
harvestMonth: l.harvestMonth,
presentation: l.presentation,
storageLocation: l.storageLocation,
germinationTests: germinationTests,
quantity: hasQuantity

View file

@ -27,13 +27,13 @@ class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
@override
int get schemaVersion => 3;
int get schemaVersion => 5;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) async => m.createAll(),
onUpgrade: (m, from, to) async {
// v2: lots can hold seeds or plants/seedlings (plantel).
// v2: lots can hold seeds or living plant material.
if (from < 2) {
await m.addColumn(lots, lots.type);
}
@ -41,6 +41,14 @@ class AppDatabase extends _$AppDatabase {
if (from < 3) {
await m.addColumn(lots, lots.harvestMonth);
}
// v4: optional packaging/supply form for living lots.
if (from < 4) {
await m.addColumn(lots, lots.presentation);
}
// v5: display order for attachments (cover photo = lowest sortOrder).
if (from < 5) {
await m.addColumn(attachments, attachments.sortOrder);
}
},
);
}

View file

@ -2675,6 +2675,15 @@ class $LotsTable extends Lots with TableInfo<$LotsTable, Lot> {
type: DriftSqlType.string,
requiredDuringInsert: false,
);
@override
late final GeneratedColumnWithTypeConverter<Presentation?, String>
presentation = GeneratedColumn<String>(
'presentation',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
).withConverter<Presentation?>($LotsTable.$converterpresentationn);
static const VerificationMeta _storageLocationMeta = const VerificationMeta(
'storageLocation',
);
@ -2722,6 +2731,7 @@ class $LotsTable extends Lots with TableInfo<$LotsTable, Lot> {
quantityKind,
quantityPrecise,
quantityLabel,
presentation,
storageLocation,
offerStatus,
seedbankId,
@ -2913,6 +2923,12 @@ class $LotsTable extends Lots with TableInfo<$LotsTable, Lot> {
DriftSqlType.string,
data['${effectivePrefix}quantity_label'],
),
presentation: $LotsTable.$converterpresentationn.fromSql(
attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}presentation'],
),
),
storageLocation: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}storage_location'],
@ -2937,6 +2953,14 @@ class $LotsTable extends Lots with TableInfo<$LotsTable, Lot> {
static JsonTypeConverter2<LotType, String, String> $convertertype =
const EnumNameConverter<LotType>(LotType.values);
static JsonTypeConverter2<Presentation, String, String>
$converterpresentation = const EnumNameConverter<Presentation>(
Presentation.values,
);
static JsonTypeConverter2<Presentation?, String?, String?>
$converterpresentationn = JsonTypeConverter2.asNullable(
$converterpresentation,
);
static JsonTypeConverter2<OfferStatus, String, String> $converterofferStatus =
const EnumNameConverter<OfferStatus>(OfferStatus.values);
}
@ -2955,6 +2979,7 @@ class Lot extends DataClass implements Insertable<Lot> {
final String? quantityKind;
final double? quantityPrecise;
final String? quantityLabel;
final Presentation? presentation;
final String? storageLocation;
final OfferStatus offerStatus;
final String? seedbankId;
@ -2972,6 +2997,7 @@ class Lot extends DataClass implements Insertable<Lot> {
this.quantityKind,
this.quantityPrecise,
this.quantityLabel,
this.presentation,
this.storageLocation,
required this.offerStatus,
this.seedbankId,
@ -3004,6 +3030,11 @@ class Lot extends DataClass implements Insertable<Lot> {
if (!nullToAbsent || quantityLabel != null) {
map['quantity_label'] = Variable<String>(quantityLabel);
}
if (!nullToAbsent || presentation != null) {
map['presentation'] = Variable<String>(
$LotsTable.$converterpresentationn.toSql(presentation),
);
}
if (!nullToAbsent || storageLocation != null) {
map['storage_location'] = Variable<String>(storageLocation);
}
@ -3043,6 +3074,9 @@ class Lot extends DataClass implements Insertable<Lot> {
quantityLabel: quantityLabel == null && nullToAbsent
? const Value.absent()
: Value(quantityLabel),
presentation: presentation == null && nullToAbsent
? const Value.absent()
: Value(presentation),
storageLocation: storageLocation == null && nullToAbsent
? const Value.absent()
: Value(storageLocation),
@ -3074,6 +3108,9 @@ class Lot extends DataClass implements Insertable<Lot> {
quantityKind: serializer.fromJson<String?>(json['quantityKind']),
quantityPrecise: serializer.fromJson<double?>(json['quantityPrecise']),
quantityLabel: serializer.fromJson<String?>(json['quantityLabel']),
presentation: $LotsTable.$converterpresentationn.fromJson(
serializer.fromJson<String?>(json['presentation']),
),
storageLocation: serializer.fromJson<String?>(json['storageLocation']),
offerStatus: $LotsTable.$converterofferStatus.fromJson(
serializer.fromJson<String>(json['offerStatus']),
@ -3098,6 +3135,9 @@ class Lot extends DataClass implements Insertable<Lot> {
'quantityKind': serializer.toJson<String?>(quantityKind),
'quantityPrecise': serializer.toJson<double?>(quantityPrecise),
'quantityLabel': serializer.toJson<String?>(quantityLabel),
'presentation': serializer.toJson<String?>(
$LotsTable.$converterpresentationn.toJson(presentation),
),
'storageLocation': serializer.toJson<String?>(storageLocation),
'offerStatus': serializer.toJson<String>(
$LotsTable.$converterofferStatus.toJson(offerStatus),
@ -3120,6 +3160,7 @@ class Lot extends DataClass implements Insertable<Lot> {
Value<String?> quantityKind = const Value.absent(),
Value<double?> quantityPrecise = const Value.absent(),
Value<String?> quantityLabel = const Value.absent(),
Value<Presentation?> presentation = const Value.absent(),
Value<String?> storageLocation = const Value.absent(),
OfferStatus? offerStatus,
Value<String?> seedbankId = const Value.absent(),
@ -3141,6 +3182,7 @@ class Lot extends DataClass implements Insertable<Lot> {
quantityLabel: quantityLabel.present
? quantityLabel.value
: this.quantityLabel,
presentation: presentation.present ? presentation.value : this.presentation,
storageLocation: storageLocation.present
? storageLocation.value
: this.storageLocation,
@ -3176,6 +3218,9 @@ class Lot extends DataClass implements Insertable<Lot> {
quantityLabel: data.quantityLabel.present
? data.quantityLabel.value
: this.quantityLabel,
presentation: data.presentation.present
? data.presentation.value
: this.presentation,
storageLocation: data.storageLocation.present
? data.storageLocation.value
: this.storageLocation,
@ -3204,6 +3249,7 @@ class Lot extends DataClass implements Insertable<Lot> {
..write('quantityKind: $quantityKind, ')
..write('quantityPrecise: $quantityPrecise, ')
..write('quantityLabel: $quantityLabel, ')
..write('presentation: $presentation, ')
..write('storageLocation: $storageLocation, ')
..write('offerStatus: $offerStatus, ')
..write('seedbankId: $seedbankId')
@ -3226,6 +3272,7 @@ class Lot extends DataClass implements Insertable<Lot> {
quantityKind,
quantityPrecise,
quantityLabel,
presentation,
storageLocation,
offerStatus,
seedbankId,
@ -3247,6 +3294,7 @@ class Lot extends DataClass implements Insertable<Lot> {
other.quantityKind == this.quantityKind &&
other.quantityPrecise == this.quantityPrecise &&
other.quantityLabel == this.quantityLabel &&
other.presentation == this.presentation &&
other.storageLocation == this.storageLocation &&
other.offerStatus == this.offerStatus &&
other.seedbankId == this.seedbankId);
@ -3266,6 +3314,7 @@ class LotsCompanion extends UpdateCompanion<Lot> {
final Value<String?> quantityKind;
final Value<double?> quantityPrecise;
final Value<String?> quantityLabel;
final Value<Presentation?> presentation;
final Value<String?> storageLocation;
final Value<OfferStatus> offerStatus;
final Value<String?> seedbankId;
@ -3284,6 +3333,7 @@ class LotsCompanion extends UpdateCompanion<Lot> {
this.quantityKind = const Value.absent(),
this.quantityPrecise = const Value.absent(),
this.quantityLabel = const Value.absent(),
this.presentation = const Value.absent(),
this.storageLocation = const Value.absent(),
this.offerStatus = const Value.absent(),
this.seedbankId = const Value.absent(),
@ -3303,6 +3353,7 @@ class LotsCompanion extends UpdateCompanion<Lot> {
this.quantityKind = const Value.absent(),
this.quantityPrecise = const Value.absent(),
this.quantityLabel = const Value.absent(),
this.presentation = const Value.absent(),
this.storageLocation = const Value.absent(),
this.offerStatus = const Value.absent(),
this.seedbankId = const Value.absent(),
@ -3326,6 +3377,7 @@ class LotsCompanion extends UpdateCompanion<Lot> {
Expression<String>? quantityKind,
Expression<double>? quantityPrecise,
Expression<String>? quantityLabel,
Expression<String>? presentation,
Expression<String>? storageLocation,
Expression<String>? offerStatus,
Expression<String>? seedbankId,
@ -3345,6 +3397,7 @@ class LotsCompanion extends UpdateCompanion<Lot> {
if (quantityKind != null) 'quantity_kind': quantityKind,
if (quantityPrecise != null) 'quantity_precise': quantityPrecise,
if (quantityLabel != null) 'quantity_label': quantityLabel,
if (presentation != null) 'presentation': presentation,
if (storageLocation != null) 'storage_location': storageLocation,
if (offerStatus != null) 'offer_status': offerStatus,
if (seedbankId != null) 'seedbank_id': seedbankId,
@ -3366,6 +3419,7 @@ class LotsCompanion extends UpdateCompanion<Lot> {
Value<String?>? quantityKind,
Value<double?>? quantityPrecise,
Value<String?>? quantityLabel,
Value<Presentation?>? presentation,
Value<String?>? storageLocation,
Value<OfferStatus>? offerStatus,
Value<String?>? seedbankId,
@ -3385,6 +3439,7 @@ class LotsCompanion extends UpdateCompanion<Lot> {
quantityKind: quantityKind ?? this.quantityKind,
quantityPrecise: quantityPrecise ?? this.quantityPrecise,
quantityLabel: quantityLabel ?? this.quantityLabel,
presentation: presentation ?? this.presentation,
storageLocation: storageLocation ?? this.storageLocation,
offerStatus: offerStatus ?? this.offerStatus,
seedbankId: seedbankId ?? this.seedbankId,
@ -3436,6 +3491,11 @@ class LotsCompanion extends UpdateCompanion<Lot> {
if (quantityLabel.present) {
map['quantity_label'] = Variable<String>(quantityLabel.value);
}
if (presentation.present) {
map['presentation'] = Variable<String>(
$LotsTable.$converterpresentationn.toSql(presentation.value),
);
}
if (storageLocation.present) {
map['storage_location'] = Variable<String>(storageLocation.value);
}
@ -3469,6 +3529,7 @@ class LotsCompanion extends UpdateCompanion<Lot> {
..write('quantityKind: $quantityKind, ')
..write('quantityPrecise: $quantityPrecise, ')
..write('quantityLabel: $quantityLabel, ')
..write('presentation: $presentation, ')
..write('storageLocation: $storageLocation, ')
..write('offerStatus: $offerStatus, ')
..write('seedbankId: $seedbankId, ')
@ -5752,6 +5813,18 @@ class $AttachmentsTable extends Attachments
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _sortOrderMeta = const VerificationMeta(
'sortOrder',
);
@override
late final GeneratedColumn<int> sortOrder = GeneratedColumn<int>(
'sort_order',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultValue: const Constant(0),
);
@override
List<GeneratedColumn> get $columns => [
id,
@ -5766,6 +5839,7 @@ class $AttachmentsTable extends Attachments
uri,
bytes,
mimeType,
sortOrder,
];
@override
String get aliasedName => _alias ?? actualTableName;
@ -5849,6 +5923,12 @@ class $AttachmentsTable extends Attachments
mimeType.isAcceptableOrUnknown(data['mime_type']!, _mimeTypeMeta),
);
}
if (data.containsKey('sort_order')) {
context.handle(
_sortOrderMeta,
sortOrder.isAcceptableOrUnknown(data['sort_order']!, _sortOrderMeta),
);
}
return context;
}
@ -5910,6 +5990,10 @@ class $AttachmentsTable extends Attachments
DriftSqlType.string,
data['${effectivePrefix}mime_type'],
),
sortOrder: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}sort_order'],
)!,
);
}
@ -5937,6 +6021,12 @@ class Attachment extends DataClass implements Insertable<Attachment> {
final String? uri;
final Uint8List? bytes;
final String? mimeType;
/// Display order among sibling attachments (lower first). The lowest-ordered
/// photo is the "preferred"/cover used as the variety avatar and shown
/// first. New photos append at the end; "set as cover" moves one to the
/// front. A plain int (not a bool flag) so it generalizes to full reordering.
final int sortOrder;
const Attachment({
required this.id,
required this.createdAt,
@ -5950,6 +6040,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
this.uri,
this.bytes,
this.mimeType,
required this.sortOrder,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@ -5980,6 +6071,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
if (!nullToAbsent || mimeType != null) {
map['mime_type'] = Variable<String>(mimeType);
}
map['sort_order'] = Variable<int>(sortOrder);
return map;
}
@ -6001,6 +6093,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
mimeType: mimeType == null && nullToAbsent
? const Value.absent()
: Value(mimeType),
sortOrder: Value(sortOrder),
);
}
@ -6026,6 +6119,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
uri: serializer.fromJson<String?>(json['uri']),
bytes: serializer.fromJson<Uint8List?>(json['bytes']),
mimeType: serializer.fromJson<String?>(json['mimeType']),
sortOrder: serializer.fromJson<int>(json['sortOrder']),
);
}
@override
@ -6048,6 +6142,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
'uri': serializer.toJson<String?>(uri),
'bytes': serializer.toJson<Uint8List?>(bytes),
'mimeType': serializer.toJson<String?>(mimeType),
'sortOrder': serializer.toJson<int>(sortOrder),
};
}
@ -6064,6 +6159,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
Value<String?> uri = const Value.absent(),
Value<Uint8List?> bytes = const Value.absent(),
Value<String?> mimeType = const Value.absent(),
int? sortOrder,
}) => Attachment(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
@ -6077,6 +6173,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
uri: uri.present ? uri.value : this.uri,
bytes: bytes.present ? bytes.value : this.bytes,
mimeType: mimeType.present ? mimeType.value : this.mimeType,
sortOrder: sortOrder ?? this.sortOrder,
);
Attachment copyWithCompanion(AttachmentsCompanion data) {
return Attachment(
@ -6098,6 +6195,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
uri: data.uri.present ? data.uri.value : this.uri,
bytes: data.bytes.present ? data.bytes.value : this.bytes,
mimeType: data.mimeType.present ? data.mimeType.value : this.mimeType,
sortOrder: data.sortOrder.present ? data.sortOrder.value : this.sortOrder,
);
}
@ -6115,7 +6213,8 @@ class Attachment extends DataClass implements Insertable<Attachment> {
..write('kind: $kind, ')
..write('uri: $uri, ')
..write('bytes: $bytes, ')
..write('mimeType: $mimeType')
..write('mimeType: $mimeType, ')
..write('sortOrder: $sortOrder')
..write(')'))
.toString();
}
@ -6134,6 +6233,7 @@ class Attachment extends DataClass implements Insertable<Attachment> {
uri,
$driftBlobEquality.hash(bytes),
mimeType,
sortOrder,
);
@override
bool operator ==(Object other) =>
@ -6150,7 +6250,8 @@ class Attachment extends DataClass implements Insertable<Attachment> {
other.kind == this.kind &&
other.uri == this.uri &&
$driftBlobEquality.equals(other.bytes, this.bytes) &&
other.mimeType == this.mimeType);
other.mimeType == this.mimeType &&
other.sortOrder == this.sortOrder);
}
class AttachmentsCompanion extends UpdateCompanion<Attachment> {
@ -6166,6 +6267,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
final Value<String?> uri;
final Value<Uint8List?> bytes;
final Value<String?> mimeType;
final Value<int> sortOrder;
final Value<int> rowid;
const AttachmentsCompanion({
this.id = const Value.absent(),
@ -6180,6 +6282,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
this.uri = const Value.absent(),
this.bytes = const Value.absent(),
this.mimeType = const Value.absent(),
this.sortOrder = const Value.absent(),
this.rowid = const Value.absent(),
});
AttachmentsCompanion.insert({
@ -6195,6 +6298,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
this.uri = const Value.absent(),
this.bytes = const Value.absent(),
this.mimeType = const Value.absent(),
this.sortOrder = const Value.absent(),
this.rowid = const Value.absent(),
}) : id = Value(id),
createdAt = Value(createdAt),
@ -6216,6 +6320,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
Expression<String>? uri,
Expression<Uint8List>? bytes,
Expression<String>? mimeType,
Expression<int>? sortOrder,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
@ -6231,6 +6336,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
if (uri != null) 'uri': uri,
if (bytes != null) 'bytes': bytes,
if (mimeType != null) 'mime_type': mimeType,
if (sortOrder != null) 'sort_order': sortOrder,
if (rowid != null) 'rowid': rowid,
});
}
@ -6248,6 +6354,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
Value<String?>? uri,
Value<Uint8List?>? bytes,
Value<String?>? mimeType,
Value<int>? sortOrder,
Value<int>? rowid,
}) {
return AttachmentsCompanion(
@ -6263,6 +6370,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
uri: uri ?? this.uri,
bytes: bytes ?? this.bytes,
mimeType: mimeType ?? this.mimeType,
sortOrder: sortOrder ?? this.sortOrder,
rowid: rowid ?? this.rowid,
);
}
@ -6310,6 +6418,9 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
if (mimeType.present) {
map['mime_type'] = Variable<String>(mimeType.value);
}
if (sortOrder.present) {
map['sort_order'] = Variable<int>(sortOrder.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
@ -6331,6 +6442,7 @@ class AttachmentsCompanion extends UpdateCompanion<Attachment> {
..write('uri: $uri, ')
..write('bytes: $bytes, ')
..write('mimeType: $mimeType, ')
..write('sortOrder: $sortOrder, ')
..write('rowid: $rowid')
..write(')'))
.toString();
@ -8246,6 +8358,7 @@ typedef $$LotsTableCreateCompanionBuilder =
Value<String?> quantityKind,
Value<double?> quantityPrecise,
Value<String?> quantityLabel,
Value<Presentation?> presentation,
Value<String?> storageLocation,
Value<OfferStatus> offerStatus,
Value<String?> seedbankId,
@ -8266,6 +8379,7 @@ typedef $$LotsTableUpdateCompanionBuilder =
Value<String?> quantityKind,
Value<double?> quantityPrecise,
Value<String?> quantityLabel,
Value<Presentation?> presentation,
Value<String?> storageLocation,
Value<OfferStatus> offerStatus,
Value<String?> seedbankId,
@ -8346,6 +8460,12 @@ class $$LotsTableFilterComposer extends Composer<_$AppDatabase, $LotsTable> {
builder: (column) => ColumnFilters(column),
);
ColumnWithTypeConverterFilters<Presentation?, Presentation, String>
get presentation => $composableBuilder(
column: $table.presentation,
builder: (column) => ColumnWithTypeConverterFilters(column),
);
ColumnFilters<String> get storageLocation => $composableBuilder(
column: $table.storageLocation,
builder: (column) => ColumnFilters(column),
@ -8436,6 +8556,11 @@ class $$LotsTableOrderingComposer extends Composer<_$AppDatabase, $LotsTable> {
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get presentation => $composableBuilder(
column: $table.presentation,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get storageLocation => $composableBuilder(
column: $table.storageLocation,
builder: (column) => ColumnOrderings(column),
@ -8514,6 +8639,12 @@ class $$LotsTableAnnotationComposer
builder: (column) => column,
);
GeneratedColumnWithTypeConverter<Presentation?, String> get presentation =>
$composableBuilder(
column: $table.presentation,
builder: (column) => column,
);
GeneratedColumn<String> get storageLocation => $composableBuilder(
column: $table.storageLocation,
builder: (column) => column,
@ -8572,6 +8703,7 @@ class $$LotsTableTableManager
Value<String?> quantityKind = const Value.absent(),
Value<double?> quantityPrecise = const Value.absent(),
Value<String?> quantityLabel = const Value.absent(),
Value<Presentation?> presentation = const Value.absent(),
Value<String?> storageLocation = const Value.absent(),
Value<OfferStatus> offerStatus = const Value.absent(),
Value<String?> seedbankId = const Value.absent(),
@ -8590,6 +8722,7 @@ class $$LotsTableTableManager
quantityKind: quantityKind,
quantityPrecise: quantityPrecise,
quantityLabel: quantityLabel,
presentation: presentation,
storageLocation: storageLocation,
offerStatus: offerStatus,
seedbankId: seedbankId,
@ -8610,6 +8743,7 @@ class $$LotsTableTableManager
Value<String?> quantityKind = const Value.absent(),
Value<double?> quantityPrecise = const Value.absent(),
Value<String?> quantityLabel = const Value.absent(),
Value<Presentation?> presentation = const Value.absent(),
Value<String?> storageLocation = const Value.absent(),
Value<OfferStatus> offerStatus = const Value.absent(),
Value<String?> seedbankId = const Value.absent(),
@ -8628,6 +8762,7 @@ class $$LotsTableTableManager
quantityKind: quantityKind,
quantityPrecise: quantityPrecise,
quantityLabel: quantityLabel,
presentation: presentation,
storageLocation: storageLocation,
offerStatus: offerStatus,
seedbankId: seedbankId,
@ -9677,6 +9812,7 @@ typedef $$AttachmentsTableCreateCompanionBuilder =
Value<String?> uri,
Value<Uint8List?> bytes,
Value<String?> mimeType,
Value<int> sortOrder,
Value<int> rowid,
});
typedef $$AttachmentsTableUpdateCompanionBuilder =
@ -9693,6 +9829,7 @@ typedef $$AttachmentsTableUpdateCompanionBuilder =
Value<String?> uri,
Value<Uint8List?> bytes,
Value<String?> mimeType,
Value<int> sortOrder,
Value<int> rowid,
});
@ -9766,6 +9903,11 @@ class $$AttachmentsTableFilterComposer
column: $table.mimeType,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get sortOrder => $composableBuilder(
column: $table.sortOrder,
builder: (column) => ColumnFilters(column),
);
}
class $$AttachmentsTableOrderingComposer
@ -9836,6 +9978,11 @@ class $$AttachmentsTableOrderingComposer
column: $table.mimeType,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get sortOrder => $composableBuilder(
column: $table.sortOrder,
builder: (column) => ColumnOrderings(column),
);
}
class $$AttachmentsTableAnnotationComposer
@ -9889,6 +10036,9 @@ class $$AttachmentsTableAnnotationComposer
GeneratedColumn<String> get mimeType =>
$composableBuilder(column: $table.mimeType, builder: (column) => column);
GeneratedColumn<int> get sortOrder =>
$composableBuilder(column: $table.sortOrder, builder: (column) => column);
}
class $$AttachmentsTableTableManager
@ -9934,6 +10084,7 @@ class $$AttachmentsTableTableManager
Value<String?> uri = const Value.absent(),
Value<Uint8List?> bytes = const Value.absent(),
Value<String?> mimeType = const Value.absent(),
Value<int> sortOrder = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => AttachmentsCompanion(
id: id,
@ -9948,6 +10099,7 @@ class $$AttachmentsTableTableManager
uri: uri,
bytes: bytes,
mimeType: mimeType,
sortOrder: sortOrder,
rowid: rowid,
),
createCompanionCallback:
@ -9964,6 +10116,7 @@ class $$AttachmentsTableTableManager
Value<String?> uri = const Value.absent(),
Value<Uint8List?> bytes = const Value.absent(),
Value<String?> mimeType = const Value.absent(),
Value<int> sortOrder = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => AttachmentsCompanion.insert(
id: id,
@ -9978,6 +10131,7 @@ class $$AttachmentsTableTableManager
uri: uri,
bytes: bytes,
mimeType: mimeType,
sortOrder: sortOrder,
rowid: rowid,
),
withReferenceMapper: (p0) => p0

View file

@ -3,9 +3,26 @@
// may only be appended, never renumbered or reused; when sync arrives, readers
// must tolerate unknown values (map to a safe default). No sync yet in Block 1.
/// Whether a lot holds seeds or living plants/seedlings (plantel). Germination
/// tests apply only to seed lots.
enum LotType { seed, plant }
/// The form in which a lot is held. `seed` is dry seed (the only form
/// germination tests apply to); the rest are living/vegetative material. Values
/// are stored by name and append-only (data-model §5): never renamed/reordered.
///
/// - `seedling` a young plant grown from seed, to transplant (plantón).
/// - `plant` an established plant (potted or bare-root).
/// - `tree` a woody plant: tree or shrub.
/// - `bulb` a storage organ: bulb, corm, tuber or rhizome.
/// - `cutting` vegetative propagation material (cutting, scion).
enum LotType { seed, plant, seedling, tree, bulb, cutting }
/// How living plant material is packaged/supplied an optional attribute of a
/// non-seed [Lot], kept separate from the amount. Append-only by name.
///
/// - `pot` in a pot/container (maceta/contenedor).
/// - `tray` in a plug/cell tray (bandeja de alvéolos).
/// - `plug` a single plug/cell.
/// - `bareRoot` dormant, roots exposed, no soil (raíz desnuda).
/// - `rootBall` dug with an intact soil ball (cepellón).
enum Presentation { pot, tray, plug, bareRoot, rootBall }
/// Per-lot visibility (data-model §2.3). Used by the future sharing layer.
enum OfferStatus { private, shared, exchange, sell }

View file

@ -50,6 +50,8 @@ class Lots extends Table with SyncColumns {
TextColumn get quantityKind => text().nullable()(); // QuantityKind.name
RealColumn get quantityPrecise => real().nullable()();
TextColumn get quantityLabel => text().nullable()();
// How living (non-seed) material is packaged; null for seed lots or unset.
TextColumn get presentation => textEnum<Presentation>().nullable()();
TextColumn get storageLocation => text().nullable()();
TextColumn get offerStatus =>
textEnum<OfferStatus>().withDefault(const Constant('private'))();
@ -99,6 +101,12 @@ class Attachments extends Table with SyncColumns {
TextColumn get uri => text().nullable()();
BlobColumn get bytes => blob().nullable()();
TextColumn get mimeType => text().nullable()();
/// Display order among sibling attachments (lower first). The lowest-ordered
/// photo is the "preferred"/cover used as the variety avatar and shown
/// first. New photos append at the end; "set as cover" moves one to the
/// front. A plain int (not a bool flag) so it generalizes to full reordering.
IntColumn get sortOrder => integer().withDefault(const Constant(0))();
}
/// Any pasted URL (Wikipedia, forum). Polymorphic parent.

View file

@ -57,12 +57,14 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
int? year,
int? month,
Quantity? quantity,
Presentation? presentation,
}) => _repo.addLot(
varietyId: varietyId,
type: type,
harvestYear: year,
harvestMonth: month,
quantity: quantity,
presentation: presentation,
);
Future<void> updateLot({
@ -71,12 +73,14 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
int? year,
int? month,
Quantity? quantity,
Presentation? presentation,
}) => _repo.updateLot(
lotId: lotId,
type: type,
harvestYear: year,
harvestMonth: month,
quantity: quantity,
presentation: presentation,
);
Future<void> deleteLot(String lotId) => _repo.softDeleteLot(lotId);
@ -92,6 +96,9 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
Future<void> removePhoto(String attachmentId) =>
_repo.removePhoto(attachmentId);
Future<void> setPreferredPhoto(String attachmentId) =>
_repo.setPreferredPhoto(varietyId, attachmentId);
Future<void> addExternalLink(String url, {String? title}) =>
_repo.addExternalLink(varietyId, url, title: title);

View file

@ -201,6 +201,43 @@ void main() {
]);
});
test('setPreferredPhoto moves a photo to the front (cover)', () async {
final id = await repo.addQuickVariety(label: 'Maize');
await repo.addPhoto(id, Uint8List.fromList([1]));
await repo.addPhoto(id, Uint8List.fromList([2]));
final p3 = await repo.addPhoto(id, Uint8List.fromList([3]));
// Insertion order until a cover is chosen.
expect(
(await repo.watchVariety(id).first)!.photos.map((p) => p.bytes.toList()),
[
[1],
[2],
[3],
],
);
await repo.setPreferredPhoto(id, p3);
final photos = (await repo.watchVariety(id).first)!.photos;
expect(photos.map((p) => p.bytes.toList()), [
[3],
[1],
[2],
]);
});
test('setPreferredPhoto changes the inventory avatar to the cover', () async {
final id = await repo.addQuickVariety(label: 'Maize');
await repo.addPhoto(id, Uint8List.fromList([1]));
final p2 = await repo.addPhoto(id, Uint8List.fromList([2]));
expect((await repo.watchInventory().first).single.photo!.toList(), [1]);
await repo.setPreferredPhoto(id, p2);
expect((await repo.watchInventory().first).single.photo!.toList(), [2]);
});
test('watchInventory avatar refreshes when a photo is added later', () async {
final id = await repo.addQuickVariety(label: 'Maize');
final queue = StreamQueue(repo.watchInventory());

View file

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

View file

@ -7,6 +7,8 @@ import 'package:drift/internal/migrations.dart';
import 'schema_v1.dart' as v1;
import 'schema_v2.dart' as v2;
import 'schema_v3.dart' as v3;
import 'schema_v4.dart' as v4;
import 'schema_v5.dart' as v5;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@ -18,10 +20,14 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v2.DatabaseAtV2(db);
case 3:
return v3.DatabaseAtV3(db);
case 4:
return v4.DatabaseAtV4(db);
case 5:
return v5.DatabaseAtV5(db);
default:
throw MissingSchemaException(version, versions);
}
}
static const versions = const [1, 2, 3];
static const versions = const [1, 2, 3, 4, 5];
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -71,8 +71,10 @@ Zero or more per `Variety`, each with its **own year and its own unit** — and
| `id` | UUID | |
| `variety_id` | UUID → `Variety` | |
| `harvest_year` | INTEGER | Nullable. **This is where "year" lives** (germination decays with lot age). |
| `type` | enum `LotType` | The **form** held — see §2.3.2. |
| `quantity_label` | TEXT + `quantity_kind` | Qualitative by default. **Not only kitchen units** — see §2.3.1. |
| `quantity_precise` | REAL + `quantity_unit` | Optional precise amount (grams / seed count). |
| `presentation` | enum `Presentation` | Nullable. How living material is packaged — see §2.3.2. |
| `storage_location` | TEXT | Nullable. Free text with suggestions ("fridge", "village bank"). |
| `offer_status` | enum | `private` / `shared` / `exchange` / `sell` — the visibility from PLAN §3 Layer 2. Per lot. |
| `seedbank_id` | UUID → `SeedBank` | Nullable. If this lot belongs to a collective bank. |
@ -90,6 +92,19 @@ Quantity is deliberately *rough and human*, not precise. But the informal vocabu
Modelling: store `quantity_kind` (a stable enum key like `pod`, `cob`, `head`, `packet`, `handful`, `grams`, `count`) + an optional numeric `quantity_precise` (and a free-text `quantity_label` for a rough amount when no key fits). The **display label is localized** from the key, so "pod"→"vaina"/"beina"… Units are *labels for a rough amount*, generally **not convertible** between each other or to grams — and that's fine. Suggestion logic (e.g. Poaceae → ear/cob, Fabaceae → pod, Helianthus → flower head) lives in the catalog mapping, not hardcoded, so it stays extensible and translatable. **To think through further:** the full starter list per family, and whether users can add their own unit keys (probably yes, as free `quantity_label` text when no key fits).
#### 2.3.2 Lot form (`LotType`) and packaging (`Presentation`) — design note
A lot isn't always dry seed. Reviewing the vocabulary of real seed shops and nurseries (viveros) — where "plantel" is only the *seedbed/batch*, not a unit — the **form** a lot is held in is a small, append-only enum `LotType` (stored by name, data-model §5):
- `seed` — dry seed. **The only form germination tests apply to.**
- `seedling` — a young plant grown from seed, to transplant (*plantón* / plug).
- `plant` — an established plant (potted or bare-root).
- `tree` — a woody plant: tree or shrub.
- `bulb` — a storage organ: bulb, corm, tuber or rhizome.
- `cutting` — vegetative propagation material (cutting, scion).
**Form drives the offered units**, and only `seed` gets the rich plant-form scale of §2.3.1; living forms are counted as **whole individuals** (`seedling`, `plant`, `tree`, `cutting`; `bulb` also allows grams). The *packaging* is deliberately a **separate optional attribute** `Presentation` (`pot`, `tray`, `plug`, `bareRoot`, `rootBall`) so "how many" is never conflated with "in what format" — that conflation was the original flaw (the old `pot`/`tray` "amounts", now retained only for old rows). Presentation is offered only for `seedling`/`plant`/`tree`. **Left open:** grafts/divisions/forestry seedlings (nicho) fold into `cutting`/`plant` for now, or a free note; revisit if real exchanges demand their own key.
### 2.4 `Movement` — the append-only event log
Immutable events on a `Lot`. Entries/exits are just types. This *is* the history tab and the provenance chain.

View file

@ -10,6 +10,7 @@ Estas fijan `schemaVersion = 1` y el arranque técnico:
- **Cifrado en reposo (el "cómo" técnico):** SQLCipher + llave aleatoria en el almacén del sistema + QR de recuperación. Conceptualmente decidido; falta bajar el mecanismo. → [security-privacy.md](security-privacy.md)
- **Identidad → ✅ una sola clave** raíz (Duniter/Ğ1), de la que se deriva la secp256k1 para Nostr. La clave simétrica que cifra la BD en reposo es un **detalle interno aparte** (no es una identidad), así que no contradice el "una sola identidad". → [g1-integration.md](g1-integration.md), [security-privacy.md](security-privacy.md)
- **Detalles de modelo que fijan el esquema → ✅** `Quantity` como **tipo compartido** (Lot y Movement); `offer_status` **solo en Lot**; `category` **texto libre** prerrellenado desde `Species.family`. → [data-model.md](data-model.md) §6
- **Forma del lote (`LotType`) y envase (`Presentation`) → ✅** (schema v4). `LotType` pasa de `{seed, plant}` a **6 formas** append-only: `seed, seedling(plantón), plant, tree, bulb, cutting` — tras repasar el vocabulario de viveros/tiendas ("plantel" era mal nombre: es el semillero, no una unidad). El **envase** (`pot/tray/plug/bareRoot/rootBall`) es un **atributo opcional aparte**, no una cantidad, para no mezclar "cuántas" con "en qué formato". Germinación sigue siendo solo-`seed`. → [data-model.md](data-model.md) §2.3.2
- **Licencia** del código → ✅ **AGPL-3.0** (GPLv3 + cláusula de red; compatible con GPLv3 y con Duniter/Ğ1nkgo). Falta añadir el fichero `LICENSE` al abrir el repo.
- **Varilla v1 → ✅ viable** con datos abiertos: nombres/identificación (Wikidata+GBIF, fácil), **conservación desde Kew SID** (dominio público, ~52k taxones), cuidados enlazados de Permapeople. Falta: confirmar acceso/descarga de Kew SID y el subconjunto curado a empaquetar. → [data-notes.md](data-notes.md) §3-ter
- **Estructura del workspace** (`commons_core` + `app_seeds`, pub workspaces) — decidido; falta materializarlo.

View file

@ -33,10 +33,15 @@ enum QuantityKind {
tuber(QuantityGroup.seedForm, countable: true),
seedHead(QuantityGroup.seedForm, countable: true),
bunch(QuantityGroup.seedForm, countable: true),
// Seedlings / plants (for a plant lot).
// Living material counted as whole individuals (seedling / plant / tree /
// bulb / cutting lots). [pot] and [tray] are retained for old rows but are no
// longer offered as amounts packaging now lives in [Presentation] instead.
plant(QuantityGroup.plant, countable: true),
pot(QuantityGroup.plant, countable: true),
tray(QuantityGroup.plant, countable: true),
seedling(QuantityGroup.plant, countable: true),
tree(QuantityGroup.plant, countable: true),
cutting(QuantityGroup.plant, countable: true),
// Precise (optional).
grams(QuantityGroup.precise, countable: true),
count(QuantityGroup.precise, countable: true);