feat(app): ask 'how did it do?' when a harvest is noted (schema v13)

The note growers most wish they had two seasons later — did it do well,
was it worth keeping — never had a home. New append-only GardenOutcomes
table (lotId, season year, three-face rating, free note); the question
appears ONCE, inline, right after recording a harvest in the batch story,
and is skippable without a trace. The answer shows as one more story
line. Rides backups/sync like every mutable table (JSON codec, LWW
import, HLC clock absorb). Migration v12→v13 guarded + verified from
every historical version.
This commit is contained in:
vjrj 2026-07-18 12:18:25 +02:00
parent 92fd84590b
commit bb5b3d4a43
20 changed files with 6030 additions and 15 deletions

File diff suppressed because it is too large Load diff

View file

@ -153,6 +153,23 @@ class InventoryJsonCodec {
'notes': c.notes, 'notes': c.notes,
}, },
], ],
'gardenOutcomes': [
for (final o in snapshot.gardenOutcomes)
{
..._syncMeta(
id: o.id,
createdAt: o.createdAt,
updatedAt: o.updatedAt,
lastAuthor: o.lastAuthor,
schemaRowVersion: o.schemaRowVersion,
isDeleted: o.isDeleted,
),
'lotId': o.lotId,
'year': o.year,
'rating': o.rating?.name,
'notes': o.notes,
},
],
'movements': [ 'movements': [
for (final m in snapshot.movements) for (final m in snapshot.movements)
{ {
@ -417,6 +434,20 @@ class InventoryJsonCodec {
notes: m['notes'] as String?, notes: m['notes'] as String?,
); );
}), }),
gardenOutcomes: _rows(root, 'gardenOutcomes', (m) {
return GardenOutcome(
id: _string(m, 'id'),
createdAt: _int(m, 'createdAt'),
updatedAt: _string(m, 'updatedAt'),
lastAuthor: _string(m, 'lastAuthor'),
isDeleted: _bool(m, 'isDeleted'),
schemaRowVersion: _int(m, 'schemaRowVersion', fallback: 1),
lotId: _string(m, 'lotId'),
year: m['year'] as int?,
rating: _enumOrNull(GardenOutcomeRating.values, m['rating']),
notes: m['notes'] as String?,
);
}),
movements: _rows(root, 'movements', (m) { movements: _rows(root, 'movements', (m) {
final type = _enumOrNull(MovementType.values, m['type']); final type = _enumOrNull(MovementType.values, m['type']);
if (type == null) return null; // no safe default drop row if (type == null) return null; // no safe default drop row

View file

@ -27,6 +27,7 @@ class InventorySnapshot {
this.externalLinks = const [], this.externalLinks = const [],
this.germinationTests = const [], this.germinationTests = const [],
this.conditionChecks = const [], this.conditionChecks = const [],
this.gardenOutcomes = const [],
this.movements = const [], this.movements = const [],
this.parties = const [], this.parties = const [],
this.attachments = const [], this.attachments = const [],
@ -41,6 +42,7 @@ class InventorySnapshot {
final List<ExternalLink> externalLinks; final List<ExternalLink> externalLinks;
final List<GerminationTest> germinationTests; final List<GerminationTest> germinationTests;
final List<ConditionCheck> conditionChecks; final List<ConditionCheck> conditionChecks;
final List<GardenOutcome> gardenOutcomes;
final List<Movement> movements; final List<Movement> movements;
final List<Party> parties; final List<Party> parties;
final List<Attachment> attachments; final List<Attachment> attachments;

View file

@ -327,7 +327,13 @@ class ConditionEntry extends Equatable {
} }
/// What kind of event a [LotHistoryEntry] narrates. /// What kind of event a [LotHistoryEntry] narrates.
enum LotHistoryKind { created, movement, germinationTest, conditionCheck } enum LotHistoryKind {
created,
movement,
germinationTest,
conditionCheck,
gardenOutcome,
}
/// One line of a lot's composite history — the batch's own story, merged from /// One line of a lot's composite history — the batch's own story, merged from
/// what is already recorded (the lot's creation with its origin, movements, /// what is already recorded (the lot's creation with its origin, movements,
@ -346,6 +352,8 @@ class LotHistoryEntry extends Equatable {
this.germinationRate, this.germinationRate,
this.containerCount, this.containerCount,
this.desiccantState, this.desiccantState,
this.rating,
this.year,
this.hasParentLink = false, this.hasParentLink = false,
}); });
@ -374,6 +382,10 @@ class LotHistoryEntry extends Equatable {
final int? containerCount; final int? containerCount;
final DesiccantState? desiccantState; final DesiccantState? desiccantState;
/// Set for [LotHistoryKind.gardenOutcome] entries: how that season went.
final GardenOutcomeRating? rating;
final int? year;
/// True when the movement links back to a source batch (provenance DAG). /// True when the movement links back to a source batch (provenance DAG).
final bool hasParentLink; final bool hasParentLink;
@ -390,6 +402,8 @@ class LotHistoryEntry extends Equatable {
germinationRate, germinationRate,
containerCount, containerCount,
desiccantState, desiccantState,
rating,
year,
hasParentLink, hasParentLink,
]; ];
} }
@ -1973,6 +1987,33 @@ class VarietyRepository {
return id; return id;
} }
/// Records how a season went for a lot the optional, skippable answer to
/// "how did it do?" asked when a harvest is noted. Returns the new row id.
Future<String> addGardenOutcome({
required String lotId,
int? year,
GardenOutcomeRating? rating,
String? notes,
}) async {
final (created, updated) = await _stamp();
final id = idGen.newId();
await _db
.into(_db.gardenOutcomes)
.insert(
GardenOutcomesCompanion.insert(
id: id,
lotId: lotId,
createdAt: created,
updatedAt: updated,
lastAuthor: nodeId,
year: Value(year),
rating: Value(rating),
notes: Value(_hasText(notes) ? notes!.trim() : null),
),
);
return id;
}
/// The lot's composite history, most-recent first: every movement, /// The lot's composite history, most-recent first: every movement,
/// germination test and condition check already recorded, closed by the /// germination test and condition check already recorded, closed by the
/// lot's own creation entry (carrying its origin, so "received from…" shows /// lot's own creation entry (carrying its origin, so "received from…" shows
@ -1993,6 +2034,9 @@ class VarietyRepository {
final checks = await (_db.select( final checks = await (_db.select(
_db.conditionChecks, _db.conditionChecks,
)..where((c) => c.lotId.equals(lotId) & c.isDeleted.equals(false))).get(); )..where((c) => c.lotId.equals(lotId) & c.isDeleted.equals(false))).get();
final outcomes = await (_db.select(
_db.gardenOutcomes,
)..where((o) => o.lotId.equals(lotId) & o.isDeleted.equals(false))).get();
// Resolve counterparty display names in one go (usually zero or one). // Resolve counterparty display names in one go (usually zero or one).
final partyIds = movements final partyIds = movements
@ -2047,6 +2091,14 @@ class VarietyRepository {
containerCount: c.containerCount, containerCount: c.containerCount,
desiccantState: c.desiccantState, desiccantState: c.desiccantState,
), ),
for (final o in outcomes)
LotHistoryEntry(
kind: LotHistoryKind.gardenOutcome,
when: o.createdAt,
notes: o.notes,
rating: o.rating,
year: o.year,
),
]..sort((a, b) => (b.when ?? 0).compareTo(a.when ?? 0)); ]..sort((a, b) => (b.when ?? 0).compareTo(a.when ?? 0));
return [ return [
@ -2451,6 +2503,9 @@ class VarietyRepository {
conditionChecks: await (_db.select( conditionChecks: await (_db.select(
_db.conditionChecks, _db.conditionChecks,
)..where((c) => c.isDeleted.equals(false))).get(), )..where((c) => c.isDeleted.equals(false))).get(),
gardenOutcomes: await (_db.select(
_db.gardenOutcomes,
)..where((o) => o.isDeleted.equals(false))).get(),
movements: await _db.select(_db.movements).get(), movements: await _db.select(_db.movements).get(),
parties: await (_db.select( parties: await (_db.select(
_db.parties, _db.parties,
@ -2484,6 +2539,7 @@ class VarietyRepository {
externalLinks: await _db.select(_db.externalLinks).get(), externalLinks: await _db.select(_db.externalLinks).get(),
germinationTests: await _db.select(_db.germinationTests).get(), germinationTests: await _db.select(_db.germinationTests).get(),
conditionChecks: await _db.select(_db.conditionChecks).get(), conditionChecks: await _db.select(_db.conditionChecks).get(),
gardenOutcomes: await _db.select(_db.gardenOutcomes).get(),
movements: await _db.select(_db.movements).get(), movements: await _db.select(_db.movements).get(),
parties: await _db.select(_db.parties).get(), parties: await _db.select(_db.parties).get(),
plantares: await _db.select(_db.plantares).get(), plantares: await _db.select(_db.plantares).get(),
@ -2564,6 +2620,14 @@ class VarietyRepository {
updatedAtOf: (r) => r.updatedAt, updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler, reconciler: reconciler,
); );
summary += await _importMutableRows(
table: _db.gardenOutcomes,
idColumn: _db.gardenOutcomes.id,
rows: snapshot.gardenOutcomes,
idOf: (r) => r.id,
updatedAtOf: (r) => r.updatedAt,
reconciler: reconciler,
);
summary += await _importMutableRows( summary += await _importMutableRows(
table: _db.parties, table: _db.parties,
idColumn: _db.parties.id, idColumn: _db.parties.id,
@ -2606,6 +2670,7 @@ class VarietyRepository {
for (final e in snapshot.externalLinks) e.updatedAt, for (final e in snapshot.externalLinks) e.updatedAt,
for (final g in snapshot.germinationTests) g.updatedAt, for (final g in snapshot.germinationTests) g.updatedAt,
for (final c in snapshot.conditionChecks) c.updatedAt, for (final c in snapshot.conditionChecks) c.updatedAt,
for (final o in snapshot.gardenOutcomes) o.updatedAt,
for (final p in snapshot.parties) p.updatedAt, for (final p in snapshot.parties) p.updatedAt,
for (final a in snapshot.attachments) a.updatedAt, for (final a in snapshot.attachments) a.updatedAt,
for (final pl in snapshot.plantares) pl.updatedAt, for (final pl in snapshot.plantares) pl.updatedAt,

View file

@ -18,6 +18,7 @@ part 'database.g.dart';
Lots, Lots,
GerminationTests, GerminationTests,
ConditionChecks, ConditionChecks,
GardenOutcomes,
Movements, Movements,
Parties, Parties,
Attachments, Attachments,
@ -31,7 +32,7 @@ class AppDatabase extends _$AppDatabase {
/// Current schema version; also stamped into interchange exports so an /// Current schema version; also stamped into interchange exports so an
/// importer knows which app generation wrote the file (data-model §7). /// importer knows which app generation wrote the file (data-model §7).
static const int currentSchemaVersion = 12; static const int currentSchemaVersion = 13;
@override @override
int get schemaVersion => currentSchemaVersion; int get schemaVersion => currentSchemaVersion;
@ -187,6 +188,14 @@ class AppDatabase extends _$AppDatabase {
await addIfMissing('return_kind', plantares.returnKind); await addIfMissing('return_kind', plantares.returnKind);
await addIfMissing('work_hours', plantares.workHours); await addIfMissing('work_hours', plantares.workHours);
} }
// v13: GardenOutcomes the per-season "how did it do in my garden"
// answer (rating + note) captured when a harvest is recorded. Guarded
// (see the v7 note above).
if (from < 13) {
if (!await _hasTable('garden_outcomes')) {
await m.createTable(gardenOutcomes);
}
}
}, },
); );

View file

@ -5765,6 +5765,624 @@ class ConditionChecksCompanion extends UpdateCompanion<ConditionCheck> {
} }
} }
class $GardenOutcomesTable extends GardenOutcomes
with TableInfo<$GardenOutcomesTable, GardenOutcome> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$GardenOutcomesTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _idMeta = const VerificationMeta('id');
@override
late final GeneratedColumn<String> id = GeneratedColumn<String>(
'id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _createdAtMeta = const VerificationMeta(
'createdAt',
);
@override
late final GeneratedColumn<int> createdAt = GeneratedColumn<int>(
'created_at',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: true,
);
static const VerificationMeta _updatedAtMeta = const VerificationMeta(
'updatedAt',
);
@override
late final GeneratedColumn<String> updatedAt = GeneratedColumn<String>(
'updated_at',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _lastAuthorMeta = const VerificationMeta(
'lastAuthor',
);
@override
late final GeneratedColumn<String> lastAuthor = GeneratedColumn<String>(
'last_author',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _isDeletedMeta = const VerificationMeta(
'isDeleted',
);
@override
late final GeneratedColumn<bool> isDeleted = GeneratedColumn<bool>(
'is_deleted',
aliasedName,
false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'CHECK ("is_deleted" IN (0, 1))',
),
defaultValue: const Constant(false),
);
static const VerificationMeta _schemaRowVersionMeta = const VerificationMeta(
'schemaRowVersion',
);
@override
late final GeneratedColumn<int> schemaRowVersion = GeneratedColumn<int>(
'schema_row_version',
aliasedName,
false,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultValue: const Constant(1),
);
static const VerificationMeta _lotIdMeta = const VerificationMeta('lotId');
@override
late final GeneratedColumn<String> lotId = GeneratedColumn<String>(
'lot_id',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: true,
);
static const VerificationMeta _yearMeta = const VerificationMeta('year');
@override
late final GeneratedColumn<int> year = GeneratedColumn<int>(
'year',
aliasedName,
true,
type: DriftSqlType.int,
requiredDuringInsert: false,
);
@override
late final GeneratedColumnWithTypeConverter<GardenOutcomeRating?, String>
rating = GeneratedColumn<String>(
'rating',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
).withConverter<GardenOutcomeRating?>($GardenOutcomesTable.$converterratingn);
static const VerificationMeta _notesMeta = const VerificationMeta('notes');
@override
late final GeneratedColumn<String> notes = GeneratedColumn<String>(
'notes',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
@override
List<GeneratedColumn> get $columns => [
id,
createdAt,
updatedAt,
lastAuthor,
isDeleted,
schemaRowVersion,
lotId,
year,
rating,
notes,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'garden_outcomes';
@override
VerificationContext validateIntegrity(
Insertable<GardenOutcome> instance, {
bool isInserting = false,
}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
} else if (isInserting) {
context.missing(_idMeta);
}
if (data.containsKey('created_at')) {
context.handle(
_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta),
);
} else if (isInserting) {
context.missing(_createdAtMeta);
}
if (data.containsKey('updated_at')) {
context.handle(
_updatedAtMeta,
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta),
);
} else if (isInserting) {
context.missing(_updatedAtMeta);
}
if (data.containsKey('last_author')) {
context.handle(
_lastAuthorMeta,
lastAuthor.isAcceptableOrUnknown(data['last_author']!, _lastAuthorMeta),
);
} else if (isInserting) {
context.missing(_lastAuthorMeta);
}
if (data.containsKey('is_deleted')) {
context.handle(
_isDeletedMeta,
isDeleted.isAcceptableOrUnknown(data['is_deleted']!, _isDeletedMeta),
);
}
if (data.containsKey('schema_row_version')) {
context.handle(
_schemaRowVersionMeta,
schemaRowVersion.isAcceptableOrUnknown(
data['schema_row_version']!,
_schemaRowVersionMeta,
),
);
}
if (data.containsKey('lot_id')) {
context.handle(
_lotIdMeta,
lotId.isAcceptableOrUnknown(data['lot_id']!, _lotIdMeta),
);
} else if (isInserting) {
context.missing(_lotIdMeta);
}
if (data.containsKey('year')) {
context.handle(
_yearMeta,
year.isAcceptableOrUnknown(data['year']!, _yearMeta),
);
}
if (data.containsKey('notes')) {
context.handle(
_notesMeta,
notes.isAcceptableOrUnknown(data['notes']!, _notesMeta),
);
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
GardenOutcome map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return GardenOutcome(
id: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}id'],
)!,
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}created_at'],
)!,
updatedAt: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}updated_at'],
)!,
lastAuthor: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}last_author'],
)!,
isDeleted: attachedDatabase.typeMapping.read(
DriftSqlType.bool,
data['${effectivePrefix}is_deleted'],
)!,
schemaRowVersion: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}schema_row_version'],
)!,
lotId: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}lot_id'],
)!,
year: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}year'],
),
rating: $GardenOutcomesTable.$converterratingn.fromSql(
attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}rating'],
),
),
notes: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}notes'],
),
);
}
@override
$GardenOutcomesTable createAlias(String alias) {
return $GardenOutcomesTable(attachedDatabase, alias);
}
static JsonTypeConverter2<GardenOutcomeRating, String, String>
$converterrating = const EnumNameConverter<GardenOutcomeRating>(
GardenOutcomeRating.values,
);
static JsonTypeConverter2<GardenOutcomeRating?, String?, String?>
$converterratingn = JsonTypeConverter2.asNullable($converterrating);
}
class GardenOutcome extends DataClass implements Insertable<GardenOutcome> {
final String id;
final int createdAt;
final String updatedAt;
final String lastAuthor;
final bool isDeleted;
final int schemaRowVersion;
final String lotId;
final int? year;
final GardenOutcomeRating? rating;
final String? notes;
const GardenOutcome({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.lastAuthor,
required this.isDeleted,
required this.schemaRowVersion,
required this.lotId,
this.year,
this.rating,
this.notes,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<String>(id);
map['created_at'] = Variable<int>(createdAt);
map['updated_at'] = Variable<String>(updatedAt);
map['last_author'] = Variable<String>(lastAuthor);
map['is_deleted'] = Variable<bool>(isDeleted);
map['schema_row_version'] = Variable<int>(schemaRowVersion);
map['lot_id'] = Variable<String>(lotId);
if (!nullToAbsent || year != null) {
map['year'] = Variable<int>(year);
}
if (!nullToAbsent || rating != null) {
map['rating'] = Variable<String>(
$GardenOutcomesTable.$converterratingn.toSql(rating),
);
}
if (!nullToAbsent || notes != null) {
map['notes'] = Variable<String>(notes);
}
return map;
}
GardenOutcomesCompanion toCompanion(bool nullToAbsent) {
return GardenOutcomesCompanion(
id: Value(id),
createdAt: Value(createdAt),
updatedAt: Value(updatedAt),
lastAuthor: Value(lastAuthor),
isDeleted: Value(isDeleted),
schemaRowVersion: Value(schemaRowVersion),
lotId: Value(lotId),
year: year == null && nullToAbsent ? const Value.absent() : Value(year),
rating: rating == null && nullToAbsent
? const Value.absent()
: Value(rating),
notes: notes == null && nullToAbsent
? const Value.absent()
: Value(notes),
);
}
factory GardenOutcome.fromJson(
Map<String, dynamic> json, {
ValueSerializer? serializer,
}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return GardenOutcome(
id: serializer.fromJson<String>(json['id']),
createdAt: serializer.fromJson<int>(json['createdAt']),
updatedAt: serializer.fromJson<String>(json['updatedAt']),
lastAuthor: serializer.fromJson<String>(json['lastAuthor']),
isDeleted: serializer.fromJson<bool>(json['isDeleted']),
schemaRowVersion: serializer.fromJson<int>(json['schemaRowVersion']),
lotId: serializer.fromJson<String>(json['lotId']),
year: serializer.fromJson<int?>(json['year']),
rating: $GardenOutcomesTable.$converterratingn.fromJson(
serializer.fromJson<String?>(json['rating']),
),
notes: serializer.fromJson<String?>(json['notes']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'createdAt': serializer.toJson<int>(createdAt),
'updatedAt': serializer.toJson<String>(updatedAt),
'lastAuthor': serializer.toJson<String>(lastAuthor),
'isDeleted': serializer.toJson<bool>(isDeleted),
'schemaRowVersion': serializer.toJson<int>(schemaRowVersion),
'lotId': serializer.toJson<String>(lotId),
'year': serializer.toJson<int?>(year),
'rating': serializer.toJson<String?>(
$GardenOutcomesTable.$converterratingn.toJson(rating),
),
'notes': serializer.toJson<String?>(notes),
};
}
GardenOutcome copyWith({
String? id,
int? createdAt,
String? updatedAt,
String? lastAuthor,
bool? isDeleted,
int? schemaRowVersion,
String? lotId,
Value<int?> year = const Value.absent(),
Value<GardenOutcomeRating?> rating = const Value.absent(),
Value<String?> notes = const Value.absent(),
}) => GardenOutcome(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
lastAuthor: lastAuthor ?? this.lastAuthor,
isDeleted: isDeleted ?? this.isDeleted,
schemaRowVersion: schemaRowVersion ?? this.schemaRowVersion,
lotId: lotId ?? this.lotId,
year: year.present ? year.value : this.year,
rating: rating.present ? rating.value : this.rating,
notes: notes.present ? notes.value : this.notes,
);
GardenOutcome copyWithCompanion(GardenOutcomesCompanion data) {
return GardenOutcome(
id: data.id.present ? data.id.value : this.id,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
lastAuthor: data.lastAuthor.present
? data.lastAuthor.value
: this.lastAuthor,
isDeleted: data.isDeleted.present ? data.isDeleted.value : this.isDeleted,
schemaRowVersion: data.schemaRowVersion.present
? data.schemaRowVersion.value
: this.schemaRowVersion,
lotId: data.lotId.present ? data.lotId.value : this.lotId,
year: data.year.present ? data.year.value : this.year,
rating: data.rating.present ? data.rating.value : this.rating,
notes: data.notes.present ? data.notes.value : this.notes,
);
}
@override
String toString() {
return (StringBuffer('GardenOutcome(')
..write('id: $id, ')
..write('createdAt: $createdAt, ')
..write('updatedAt: $updatedAt, ')
..write('lastAuthor: $lastAuthor, ')
..write('isDeleted: $isDeleted, ')
..write('schemaRowVersion: $schemaRowVersion, ')
..write('lotId: $lotId, ')
..write('year: $year, ')
..write('rating: $rating, ')
..write('notes: $notes')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(
id,
createdAt,
updatedAt,
lastAuthor,
isDeleted,
schemaRowVersion,
lotId,
year,
rating,
notes,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is GardenOutcome &&
other.id == this.id &&
other.createdAt == this.createdAt &&
other.updatedAt == this.updatedAt &&
other.lastAuthor == this.lastAuthor &&
other.isDeleted == this.isDeleted &&
other.schemaRowVersion == this.schemaRowVersion &&
other.lotId == this.lotId &&
other.year == this.year &&
other.rating == this.rating &&
other.notes == this.notes);
}
class GardenOutcomesCompanion extends UpdateCompanion<GardenOutcome> {
final Value<String> id;
final Value<int> createdAt;
final Value<String> updatedAt;
final Value<String> lastAuthor;
final Value<bool> isDeleted;
final Value<int> schemaRowVersion;
final Value<String> lotId;
final Value<int?> year;
final Value<GardenOutcomeRating?> rating;
final Value<String?> notes;
final Value<int> rowid;
const GardenOutcomesCompanion({
this.id = const Value.absent(),
this.createdAt = const Value.absent(),
this.updatedAt = const Value.absent(),
this.lastAuthor = const Value.absent(),
this.isDeleted = const Value.absent(),
this.schemaRowVersion = const Value.absent(),
this.lotId = const Value.absent(),
this.year = const Value.absent(),
this.rating = const Value.absent(),
this.notes = const Value.absent(),
this.rowid = const Value.absent(),
});
GardenOutcomesCompanion.insert({
required String id,
required int createdAt,
required String updatedAt,
required String lastAuthor,
this.isDeleted = const Value.absent(),
this.schemaRowVersion = const Value.absent(),
required String lotId,
this.year = const Value.absent(),
this.rating = const Value.absent(),
this.notes = const Value.absent(),
this.rowid = const Value.absent(),
}) : id = Value(id),
createdAt = Value(createdAt),
updatedAt = Value(updatedAt),
lastAuthor = Value(lastAuthor),
lotId = Value(lotId);
static Insertable<GardenOutcome> custom({
Expression<String>? id,
Expression<int>? createdAt,
Expression<String>? updatedAt,
Expression<String>? lastAuthor,
Expression<bool>? isDeleted,
Expression<int>? schemaRowVersion,
Expression<String>? lotId,
Expression<int>? year,
Expression<String>? rating,
Expression<String>? notes,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (createdAt != null) 'created_at': createdAt,
if (updatedAt != null) 'updated_at': updatedAt,
if (lastAuthor != null) 'last_author': lastAuthor,
if (isDeleted != null) 'is_deleted': isDeleted,
if (schemaRowVersion != null) 'schema_row_version': schemaRowVersion,
if (lotId != null) 'lot_id': lotId,
if (year != null) 'year': year,
if (rating != null) 'rating': rating,
if (notes != null) 'notes': notes,
if (rowid != null) 'rowid': rowid,
});
}
GardenOutcomesCompanion copyWith({
Value<String>? id,
Value<int>? createdAt,
Value<String>? updatedAt,
Value<String>? lastAuthor,
Value<bool>? isDeleted,
Value<int>? schemaRowVersion,
Value<String>? lotId,
Value<int?>? year,
Value<GardenOutcomeRating?>? rating,
Value<String?>? notes,
Value<int>? rowid,
}) {
return GardenOutcomesCompanion(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
lastAuthor: lastAuthor ?? this.lastAuthor,
isDeleted: isDeleted ?? this.isDeleted,
schemaRowVersion: schemaRowVersion ?? this.schemaRowVersion,
lotId: lotId ?? this.lotId,
year: year ?? this.year,
rating: rating ?? this.rating,
notes: notes ?? this.notes,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<String>(id.value);
}
if (createdAt.present) {
map['created_at'] = Variable<int>(createdAt.value);
}
if (updatedAt.present) {
map['updated_at'] = Variable<String>(updatedAt.value);
}
if (lastAuthor.present) {
map['last_author'] = Variable<String>(lastAuthor.value);
}
if (isDeleted.present) {
map['is_deleted'] = Variable<bool>(isDeleted.value);
}
if (schemaRowVersion.present) {
map['schema_row_version'] = Variable<int>(schemaRowVersion.value);
}
if (lotId.present) {
map['lot_id'] = Variable<String>(lotId.value);
}
if (year.present) {
map['year'] = Variable<int>(year.value);
}
if (rating.present) {
map['rating'] = Variable<String>(
$GardenOutcomesTable.$converterratingn.toSql(rating.value),
);
}
if (notes.present) {
map['notes'] = Variable<String>(notes.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('GardenOutcomesCompanion(')
..write('id: $id, ')
..write('createdAt: $createdAt, ')
..write('updatedAt: $updatedAt, ')
..write('lastAuthor: $lastAuthor, ')
..write('isDeleted: $isDeleted, ')
..write('schemaRowVersion: $schemaRowVersion, ')
..write('lotId: $lotId, ')
..write('year: $year, ')
..write('rating: $rating, ')
..write('notes: $notes, ')
..write('rowid: $rowid')
..write(')'))
.toString();
}
}
class $MovementsTable extends Movements class $MovementsTable extends Movements
with TableInfo<$MovementsTable, Movement> { with TableInfo<$MovementsTable, Movement> {
@override @override
@ -10810,6 +11428,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
late final $ConditionChecksTable conditionChecks = $ConditionChecksTable( late final $ConditionChecksTable conditionChecks = $ConditionChecksTable(
this, this,
); );
late final $GardenOutcomesTable gardenOutcomes = $GardenOutcomesTable(this);
late final $MovementsTable movements = $MovementsTable(this); late final $MovementsTable movements = $MovementsTable(this);
late final $PartiesTable parties = $PartiesTable(this); late final $PartiesTable parties = $PartiesTable(this);
late final $AttachmentsTable attachments = $AttachmentsTable(this); late final $AttachmentsTable attachments = $AttachmentsTable(this);
@ -10828,6 +11447,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
lots, lots,
germinationTests, germinationTests,
conditionChecks, conditionChecks,
gardenOutcomes,
movements, movements,
parties, parties,
attachments, attachments,
@ -13499,6 +14119,312 @@ typedef $$ConditionChecksTableProcessedTableManager =
ConditionCheck, ConditionCheck,
PrefetchHooks Function() PrefetchHooks Function()
>; >;
typedef $$GardenOutcomesTableCreateCompanionBuilder =
GardenOutcomesCompanion Function({
required String id,
required int createdAt,
required String updatedAt,
required String lastAuthor,
Value<bool> isDeleted,
Value<int> schemaRowVersion,
required String lotId,
Value<int?> year,
Value<GardenOutcomeRating?> rating,
Value<String?> notes,
Value<int> rowid,
});
typedef $$GardenOutcomesTableUpdateCompanionBuilder =
GardenOutcomesCompanion Function({
Value<String> id,
Value<int> createdAt,
Value<String> updatedAt,
Value<String> lastAuthor,
Value<bool> isDeleted,
Value<int> schemaRowVersion,
Value<String> lotId,
Value<int?> year,
Value<GardenOutcomeRating?> rating,
Value<String?> notes,
Value<int> rowid,
});
class $$GardenOutcomesTableFilterComposer
extends Composer<_$AppDatabase, $GardenOutcomesTable> {
$$GardenOutcomesTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get lastAuthor => $composableBuilder(
column: $table.lastAuthor,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<bool> get isDeleted => $composableBuilder(
column: $table.isDeleted,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get schemaRowVersion => $composableBuilder(
column: $table.schemaRowVersion,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get lotId => $composableBuilder(
column: $table.lotId,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get year => $composableBuilder(
column: $table.year,
builder: (column) => ColumnFilters(column),
);
ColumnWithTypeConverterFilters<
GardenOutcomeRating?,
GardenOutcomeRating,
String
>
get rating => $composableBuilder(
column: $table.rating,
builder: (column) => ColumnWithTypeConverterFilters(column),
);
ColumnFilters<String> get notes => $composableBuilder(
column: $table.notes,
builder: (column) => ColumnFilters(column),
);
}
class $$GardenOutcomesTableOrderingComposer
extends Composer<_$AppDatabase, $GardenOutcomesTable> {
$$GardenOutcomesTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get lastAuthor => $composableBuilder(
column: $table.lastAuthor,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<bool> get isDeleted => $composableBuilder(
column: $table.isDeleted,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get schemaRowVersion => $composableBuilder(
column: $table.schemaRowVersion,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get lotId => $composableBuilder(
column: $table.lotId,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get year => $composableBuilder(
column: $table.year,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get rating => $composableBuilder(
column: $table.rating,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get notes => $composableBuilder(
column: $table.notes,
builder: (column) => ColumnOrderings(column),
);
}
class $$GardenOutcomesTableAnnotationComposer
extends Composer<_$AppDatabase, $GardenOutcomesTable> {
$$GardenOutcomesTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<String> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<int> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
GeneratedColumn<String> get updatedAt =>
$composableBuilder(column: $table.updatedAt, builder: (column) => column);
GeneratedColumn<String> get lastAuthor => $composableBuilder(
column: $table.lastAuthor,
builder: (column) => column,
);
GeneratedColumn<bool> get isDeleted =>
$composableBuilder(column: $table.isDeleted, builder: (column) => column);
GeneratedColumn<int> get schemaRowVersion => $composableBuilder(
column: $table.schemaRowVersion,
builder: (column) => column,
);
GeneratedColumn<String> get lotId =>
$composableBuilder(column: $table.lotId, builder: (column) => column);
GeneratedColumn<int> get year =>
$composableBuilder(column: $table.year, builder: (column) => column);
GeneratedColumnWithTypeConverter<GardenOutcomeRating?, String> get rating =>
$composableBuilder(column: $table.rating, builder: (column) => column);
GeneratedColumn<String> get notes =>
$composableBuilder(column: $table.notes, builder: (column) => column);
}
class $$GardenOutcomesTableTableManager
extends
RootTableManager<
_$AppDatabase,
$GardenOutcomesTable,
GardenOutcome,
$$GardenOutcomesTableFilterComposer,
$$GardenOutcomesTableOrderingComposer,
$$GardenOutcomesTableAnnotationComposer,
$$GardenOutcomesTableCreateCompanionBuilder,
$$GardenOutcomesTableUpdateCompanionBuilder,
(
GardenOutcome,
BaseReferences<_$AppDatabase, $GardenOutcomesTable, GardenOutcome>,
),
GardenOutcome,
PrefetchHooks Function()
> {
$$GardenOutcomesTableTableManager(
_$AppDatabase db,
$GardenOutcomesTable table,
) : super(
TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$GardenOutcomesTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$GardenOutcomesTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$GardenOutcomesTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
Value<String> id = const Value.absent(),
Value<int> createdAt = const Value.absent(),
Value<String> updatedAt = const Value.absent(),
Value<String> lastAuthor = const Value.absent(),
Value<bool> isDeleted = const Value.absent(),
Value<int> schemaRowVersion = const Value.absent(),
Value<String> lotId = const Value.absent(),
Value<int?> year = const Value.absent(),
Value<GardenOutcomeRating?> rating = const Value.absent(),
Value<String?> notes = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => GardenOutcomesCompanion(
id: id,
createdAt: createdAt,
updatedAt: updatedAt,
lastAuthor: lastAuthor,
isDeleted: isDeleted,
schemaRowVersion: schemaRowVersion,
lotId: lotId,
year: year,
rating: rating,
notes: notes,
rowid: rowid,
),
createCompanionCallback:
({
required String id,
required int createdAt,
required String updatedAt,
required String lastAuthor,
Value<bool> isDeleted = const Value.absent(),
Value<int> schemaRowVersion = const Value.absent(),
required String lotId,
Value<int?> year = const Value.absent(),
Value<GardenOutcomeRating?> rating = const Value.absent(),
Value<String?> notes = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => GardenOutcomesCompanion.insert(
id: id,
createdAt: createdAt,
updatedAt: updatedAt,
lastAuthor: lastAuthor,
isDeleted: isDeleted,
schemaRowVersion: schemaRowVersion,
lotId: lotId,
year: year,
rating: rating,
notes: notes,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$GardenOutcomesTableProcessedTableManager =
ProcessedTableManager<
_$AppDatabase,
$GardenOutcomesTable,
GardenOutcome,
$$GardenOutcomesTableFilterComposer,
$$GardenOutcomesTableOrderingComposer,
$$GardenOutcomesTableAnnotationComposer,
$$GardenOutcomesTableCreateCompanionBuilder,
$$GardenOutcomesTableUpdateCompanionBuilder,
(
GardenOutcome,
BaseReferences<_$AppDatabase, $GardenOutcomesTable, GardenOutcome>,
),
GardenOutcome,
PrefetchHooks Function()
>;
typedef $$MovementsTableCreateCompanionBuilder = typedef $$MovementsTableCreateCompanionBuilder =
MovementsCompanion Function({ MovementsCompanion Function({
required String id, required String id,
@ -15800,6 +16726,8 @@ class $AppDatabaseManager {
$$GerminationTestsTableTableManager(_db, _db.germinationTests); $$GerminationTestsTableTableManager(_db, _db.germinationTests);
$$ConditionChecksTableTableManager get conditionChecks => $$ConditionChecksTableTableManager get conditionChecks =>
$$ConditionChecksTableTableManager(_db, _db.conditionChecks); $$ConditionChecksTableTableManager(_db, _db.conditionChecks);
$$GardenOutcomesTableTableManager get gardenOutcomes =>
$$GardenOutcomesTableTableManager(_db, _db.gardenOutcomes);
$$MovementsTableTableManager get movements => $$MovementsTableTableManager get movements =>
$$MovementsTableTableManager(_db, _db.movements); $$MovementsTableTableManager(_db, _db.movements);
$$PartiesTableTableManager get parties => $$PartiesTableTableManager get parties =>

View file

@ -67,6 +67,12 @@ enum PreservationFormat {
/// - `fresh` just renewed (lila/violet). /// - `fresh` just renewed (lila/violet).
enum DesiccantState { none, add, replace, dry, fresh } enum DesiccantState { none, add, replace, dry, fresh }
/// How a sowing from this batch turned out in the grower's own garden — the
/// note people most wish they had two seasons later. Deliberately coarse
/// (three faces, not a breeder's scorecard): the free-text note carries any
/// nuance.
enum GardenOutcomeRating { good, mixed, poor }
/// Append-only event kinds on a Lot (data-model §2.4). /// Append-only event kinds on a Lot (data-model §2.4).
enum MovementType { enum MovementType {
received, received,

View file

@ -142,6 +142,18 @@ class ConditionChecks extends Table with SyncColumns {
TextColumn get notes => text().nullable()(); TextColumn get notes => text().nullable()();
} }
/// "How did it do in MY garden" one optional, skippable answer per season
/// (v13). Captured at the natural moment (recording a harvest), shown as one
/// more line of the lot's story. Deliberately minimal — a coarse rating plus a
/// free note; the note carries any nuance. Mirrors [GerminationTests]: a dated
/// log under a Lot.
class GardenOutcomes extends Table with SyncColumns {
TextColumn get lotId => text()();
IntColumn get year => integer().nullable()(); // season, e.g. 2026
TextColumn get rating => textEnum<GardenOutcomeRating>().nullable()();
TextColumn get notes => text().nullable()();
}
/// The append-only event log on a Lot history + provenance DAG. /// The append-only event log on a Lot history + provenance DAG.
class Movements extends Table with AppendOnlyColumns { class Movements extends Table with AppendOnlyColumns {
TextColumn get lotId => text()(); TextColumn get lotId => text()();

View file

@ -717,7 +717,18 @@
"created": "Added to your collection", "created": "Added to your collection",
"from": "From {origin}", "from": "From {origin}",
"germinationResult": "Germination test — {percent}%", "germinationResult": "Germination test — {percent}%",
"linkedEarlier": "Comes from an earlier batch" "linkedEarlier": "Comes from an earlier batch",
"outcomeQuestion": "How did it do?",
"outcomeGood": "Well",
"outcomeMixed": "So-so",
"outcomePoor": "Poorly",
"outcomeNoteHint": "A note for your future self (optional)",
"outcomeSaved": "Noted",
"ratedGood": "It did well",
"ratedMixed": "It did so-so",
"ratedPoor": "It did poorly",
"outcomeTitle": "Season note",
"outcomeYear": "Season {year}"
}, },
"sale": { "sale": {
"title": "Sales", "title": "Sales",

View file

@ -716,7 +716,18 @@
"created": "Entró en tu colección", "created": "Entró en tu colección",
"from": "De {origin}", "from": "De {origin}",
"germinationResult": "Prueba de germinación — {percent}%", "germinationResult": "Prueba de germinación — {percent}%",
"linkedEarlier": "Viene de un lote anterior" "linkedEarlier": "Viene de un lote anterior",
"outcomeQuestion": "¿Qué tal se dio?",
"outcomeGood": "Bien",
"outcomeMixed": "Regular",
"outcomePoor": "Mal",
"outcomeNoteHint": "Una nota para tu yo del futuro (opcional)",
"outcomeSaved": "Anotado",
"ratedGood": "Se dio bien",
"ratedMixed": "Se dio regular",
"ratedPoor": "Se dio mal",
"outcomeTitle": "Nota de temporada",
"outcomeYear": "Temporada {year}"
}, },
"sale": { "sale": {
"title": "Ventas", "title": "Ventas",

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 7 /// Locales: 7
/// Strings: 3528 (504 per locale) /// Strings: 3550 (507 per locale)
/// ///
/// Built on 2026-07-18 at 04:22 UTC /// Built on 2026-07-18 at 10:13 UTC
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import

View file

@ -2039,6 +2039,39 @@ class Translations$history$en {
/// en: 'Comes from an earlier batch' /// en: 'Comes from an earlier batch'
String get linkedEarlier => 'Comes from an earlier batch'; String get linkedEarlier => 'Comes from an earlier batch';
/// en: 'How did it do?'
String get outcomeQuestion => 'How did it do?';
/// en: 'Well'
String get outcomeGood => 'Well';
/// en: 'So-so'
String get outcomeMixed => 'So-so';
/// en: 'Poorly'
String get outcomePoor => 'Poorly';
/// en: 'A note for your future self (optional)'
String get outcomeNoteHint => 'A note for your future self (optional)';
/// en: 'Noted'
String get outcomeSaved => 'Noted';
/// en: 'It did well'
String get ratedGood => 'It did well';
/// en: 'It did so-so'
String get ratedMixed => 'It did so-so';
/// en: 'It did poorly'
String get ratedPoor => 'It did poorly';
/// en: 'Season note'
String get outcomeTitle => 'Season note';
/// en: 'Season {year}'
String outcomeYear({required Object year}) => 'Season ${year}';
} }
// Path: sale // Path: sale
@ -3277,6 +3310,17 @@ extension on Translations {
'history.from' => ({required Object origin}) => 'From ${origin}', 'history.from' => ({required Object origin}) => 'From ${origin}',
'history.germinationResult' => ({required Object percent}) => 'Germination test — ${percent}%', 'history.germinationResult' => ({required Object percent}) => 'Germination test — ${percent}%',
'history.linkedEarlier' => 'Comes from an earlier batch', 'history.linkedEarlier' => 'Comes from an earlier batch',
'history.outcomeQuestion' => 'How did it do?',
'history.outcomeGood' => 'Well',
'history.outcomeMixed' => 'So-so',
'history.outcomePoor' => 'Poorly',
'history.outcomeNoteHint' => 'A note for your future self (optional)',
'history.outcomeSaved' => 'Noted',
'history.ratedGood' => 'It did well',
'history.ratedMixed' => 'It did so-so',
'history.ratedPoor' => 'It did poorly',
'history.outcomeTitle' => 'Season note',
'history.outcomeYear' => ({required Object year}) => 'Season ${year}',
'sale.title' => 'Sales', 'sale.title' => 'Sales',
'sale.help' => 'Record seed you sold or bought — money, Ğ1, or any currency. A separate model from a gift or a Plantare. No commission is ever taken on seeds.', 'sale.help' => 'Record seed you sold or bought — money, Ğ1, or any currency. A separate model from a gift or a Plantare. No commission is ever taken on seeds.',
'sale.add' => 'Record a sale', 'sale.add' => 'Record a sale',

View file

@ -1046,6 +1046,17 @@ class _Translations$history$es extends Translations$history$en {
@override String from({required Object origin}) => 'De ${origin}'; @override String from({required Object origin}) => 'De ${origin}';
@override String germinationResult({required Object percent}) => 'Prueba de germinación — ${percent}%'; @override String germinationResult({required Object percent}) => 'Prueba de germinación — ${percent}%';
@override String get linkedEarlier => 'Viene de un lote anterior'; @override String get linkedEarlier => 'Viene de un lote anterior';
@override String get outcomeQuestion => '¿Qué tal se dio?';
@override String get outcomeGood => 'Bien';
@override String get outcomeMixed => 'Regular';
@override String get outcomePoor => 'Mal';
@override String get outcomeNoteHint => 'Una nota para tu yo del futuro (opcional)';
@override String get outcomeSaved => 'Anotado';
@override String get ratedGood => 'Se dio bien';
@override String get ratedMixed => 'Se dio regular';
@override String get ratedPoor => 'Se dio mal';
@override String get outcomeTitle => 'Nota de temporada';
@override String outcomeYear({required Object year}) => 'Temporada ${year}';
} }
// Path: sale // Path: sale
@ -2055,6 +2066,17 @@ extension on TranslationsEs {
'history.from' => ({required Object origin}) => 'De ${origin}', 'history.from' => ({required Object origin}) => 'De ${origin}',
'history.germinationResult' => ({required Object percent}) => 'Prueba de germinación — ${percent}%', 'history.germinationResult' => ({required Object percent}) => 'Prueba de germinación — ${percent}%',
'history.linkedEarlier' => 'Viene de un lote anterior', 'history.linkedEarlier' => 'Viene de un lote anterior',
'history.outcomeQuestion' => '¿Qué tal se dio?',
'history.outcomeGood' => 'Bien',
'history.outcomeMixed' => 'Regular',
'history.outcomePoor' => 'Mal',
'history.outcomeNoteHint' => 'Una nota para tu yo del futuro (opcional)',
'history.outcomeSaved' => 'Anotado',
'history.ratedGood' => 'Se dio bien',
'history.ratedMixed' => 'Se dio regular',
'history.ratedPoor' => 'Se dio mal',
'history.outcomeTitle' => 'Nota de temporada',
'history.outcomeYear' => ({required Object year}) => 'Temporada ${year}',
'sale.title' => 'Ventas', 'sale.title' => 'Ventas',
'sale.help' => 'Registra semilla vendida o comprada — dinero, Ğ1 o cualquier moneda. Un modelo aparte del regalo y del Plantare. Nunca se cobra comisión por las semillas.', 'sale.help' => 'Registra semilla vendida o comprada — dinero, Ğ1 o cualquier moneda. Un modelo aparte del regalo y del Plantare. Nunca se cobra comisión por las semillas.',
'sale.add' => 'Registrar venta', 'sale.add' => 'Registrar venta',

View file

@ -38,12 +38,23 @@ class _LotHistorySheetState extends State<_LotHistorySheet> {
late Future<List<LotHistoryEntry>> _history; late Future<List<LotHistoryEntry>> _history;
bool _saving = false; bool _saving = false;
// The optional, skippable "how did it do?" asked right after a harvest is
// noted the only moment it appears. Dismissed, it never nags again.
bool _askOutcome = false;
final _outcomeNote = TextEditingController();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_history = widget.repository.lotHistory(widget.lot.id); _history = widget.repository.lotHistory(widget.lot.id);
} }
@override
void dispose() {
_outcomeNote.dispose();
super.dispose();
}
Future<void> _record(MovementType type) async { Future<void> _record(MovementType type) async {
if (_saving) return; if (_saving) return;
setState(() => _saving = true); setState(() => _saving = true);
@ -65,6 +76,29 @@ class _LotHistorySheetState extends State<_LotHistorySheet> {
); );
setState(() { setState(() {
_saving = false; _saving = false;
_askOutcome = type == MovementType.harvested;
_history = widget.repository.lotHistory(widget.lot.id);
});
}
Future<void> _saveOutcome(GardenOutcomeRating rating) async {
if (_saving) return;
setState(() => _saving = true);
final t = context.t;
final messenger = ScaffoldMessenger.of(context);
final note = _outcomeNote.text.trim();
await widget.repository.addGardenOutcome(
lotId: widget.lot.id,
year: DateTime.now().year,
rating: rating,
notes: note.isEmpty ? null : note,
);
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(t.history.outcomeSaved)));
_outcomeNote.clear();
setState(() {
_saving = false;
_askOutcome = false;
_history = widget.repository.lotHistory(widget.lot.id); _history = widget.repository.lotHistory(widget.lot.id);
}); });
} }
@ -74,7 +108,13 @@ class _LotHistorySheetState extends State<_LotHistorySheet> {
final t = context.t; final t = context.t;
return SafeArea( return SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), // Bottom inset keeps the optional note field above the keyboard.
padding: EdgeInsets.fromLTRB(
16,
16,
16,
24 + MediaQuery.of(context).viewInsets.bottom,
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -106,6 +146,15 @@ class _LotHistorySheetState extends State<_LotHistorySheet> {
), ),
], ],
), ),
if (_askOutcome) ...[
const SizedBox(height: 8),
_OutcomeQuestion(
note: _outcomeNote,
saving: _saving,
onRate: _saveOutcome,
onDismiss: () => setState(() => _askOutcome = false),
),
],
const SizedBox(height: 8), const SizedBox(height: 8),
Flexible( Flexible(
child: FutureBuilder<List<LotHistoryEntry>>( child: FutureBuilder<List<LotHistoryEntry>>(
@ -134,6 +183,91 @@ class _LotHistorySheetState extends State<_LotHistorySheet> {
} }
} }
/// The one skippable question of the whole flow: three faces and an optional
/// note. Tapping a face saves; the X dismisses without a trace.
class _OutcomeQuestion extends StatelessWidget {
const _OutcomeQuestion({
required this.note,
required this.saving,
required this.onRate,
required this.onDismiss,
});
final TextEditingController note;
final bool saving;
final ValueChanged<GardenOutcomeRating> onRate;
final VoidCallback onDismiss;
@override
Widget build(BuildContext context) {
final t = context.t;
return Card(
key: const Key('history.outcomeQuestion'),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 4, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
t.history.outcomeQuestion,
style: Theme.of(context).textTheme.titleSmall,
),
),
IconButton(
key: const Key('history.outcomeDismiss'),
icon: const Icon(Icons.close, size: 18),
onPressed: onDismiss,
),
],
),
Wrap(
spacing: 8,
children: [
ActionChip(
key: const Key('history.outcomeGood'),
avatar: const Icon(Icons.sentiment_satisfied_alt, size: 18),
label: Text(t.history.outcomeGood),
onPressed: saving
? null
: () => onRate(GardenOutcomeRating.good),
),
ActionChip(
key: const Key('history.outcomeMixed'),
avatar: const Icon(Icons.sentiment_neutral, size: 18),
label: Text(t.history.outcomeMixed),
onPressed: saving
? null
: () => onRate(GardenOutcomeRating.mixed),
),
ActionChip(
key: const Key('history.outcomePoor'),
avatar: const Icon(Icons.sentiment_dissatisfied, size: 18),
label: Text(t.history.outcomePoor),
onPressed: saving
? null
: () => onRate(GardenOutcomeRating.poor),
),
],
),
TextField(
key: const Key('history.outcomeNote'),
controller: note,
decoration: InputDecoration(
hintText: t.history.outcomeNoteHint,
isDense: true,
border: InputBorder.none,
),
),
],
),
),
);
}
}
class _HistoryTile extends StatelessWidget { class _HistoryTile extends StatelessWidget {
const _HistoryTile({required this.entry}); const _HistoryTile({required this.entry});
@ -143,6 +277,12 @@ class _HistoryTile extends StatelessWidget {
LotHistoryKind.created => Icons.spa_outlined, LotHistoryKind.created => Icons.spa_outlined,
LotHistoryKind.germinationTest => Icons.science_outlined, LotHistoryKind.germinationTest => Icons.science_outlined,
LotHistoryKind.conditionCheck => Icons.inventory_2_outlined, LotHistoryKind.conditionCheck => Icons.inventory_2_outlined,
LotHistoryKind.gardenOutcome => switch (entry.rating) {
GardenOutcomeRating.good => Icons.sentiment_satisfied_alt,
GardenOutcomeRating.mixed => Icons.sentiment_neutral,
GardenOutcomeRating.poor => Icons.sentiment_dissatisfied,
null => Icons.edit_note,
},
LotHistoryKind.movement => switch (entry.movementType!) { LotHistoryKind.movement => switch (entry.movementType!) {
MovementType.received => Icons.call_received, MovementType.received => Icons.call_received,
MovementType.given => Icons.redeem_outlined, MovementType.given => Icons.redeem_outlined,
@ -157,6 +297,12 @@ class _HistoryTile extends StatelessWidget {
String _title(Translations t) => switch (entry.kind) { String _title(Translations t) => switch (entry.kind) {
LotHistoryKind.created => t.history.created, LotHistoryKind.created => t.history.created,
LotHistoryKind.conditionCheck => t.conditionCheck.title, LotHistoryKind.conditionCheck => t.conditionCheck.title,
LotHistoryKind.gardenOutcome => switch (entry.rating) {
GardenOutcomeRating.good => t.history.ratedGood,
GardenOutcomeRating.mixed => t.history.ratedMixed,
GardenOutcomeRating.poor => t.history.ratedPoor,
null => t.history.outcomeTitle,
},
LotHistoryKind.germinationTest => entry.germinationRate == null LotHistoryKind.germinationTest => entry.germinationRate == null
? t.germination.title ? t.germination.title
: t.history.germinationResult( : t.history.germinationResult(
@ -194,6 +340,7 @@ class _HistoryTile extends StatelessWidget {
final details = [ final details = [
if (entry.kind == LotHistoryKind.created && origin.isNotEmpty) if (entry.kind == LotHistoryKind.created && origin.isNotEmpty)
t.history.from(origin: origin), t.history.from(origin: origin),
if (entry.year case final year?) t.history.outcomeYear(year: year),
if (entry.quantity != null) quantityDisplay(t, entry.quantity!), if (entry.quantity != null) quantityDisplay(t, entry.quantity!),
?entry.counterpartyName, ?entry.counterpartyName,
if (entry.hasParentLink) t.history.linkedEarlier, if (entry.hasParentLink) t.history.linkedEarlier,

View file

@ -68,6 +68,12 @@ void main() {
containerCount: 2, containerCount: 2,
desiccantState: DesiccantState.dry, desiccantState: DesiccantState.dry,
); );
await source.addGardenOutcome(
lotId: lotId,
year: 2024,
rating: GardenOutcomeRating.good,
notes: 'kept it',
);
await source.addVernacularName( await source.addVernacularName(
varietyId, varietyId,
'tomate de la abuela', 'tomate de la abuela',
@ -126,6 +132,7 @@ void main() {
expect(targetRows.externalLinks, sourceRows.externalLinks); expect(targetRows.externalLinks, sourceRows.externalLinks);
expect(targetRows.germinationTests, sourceRows.germinationTests); expect(targetRows.germinationTests, sourceRows.germinationTests);
expect(targetRows.conditionChecks, sourceRows.conditionChecks); expect(targetRows.conditionChecks, sourceRows.conditionChecks);
expect(targetRows.gardenOutcomes, sourceRows.gardenOutcomes);
expect(targetRows.movements, sourceRows.movements); expect(targetRows.movements, sourceRows.movements);
expect(targetRows.attachments, sourceRows.attachments); expect(targetRows.attachments, sourceRows.attachments);

View file

@ -11,19 +11,19 @@ void main() {
verifier = SchemaVerifier(GeneratedHelper()); verifier = SchemaVerifier(GeneratedHelper());
}); });
test('freshly created database matches the exported schema v12', () async { test('freshly created database matches the exported schema v13', () async {
final schema = await verifier.schemaAt(12); final schema = await verifier.schemaAt(13);
final db = AppDatabase(schema.newConnection()); final db = AppDatabase(schema.newConnection());
await verifier.migrateAndValidate(db, 12); await verifier.migrateAndValidate(db, 13);
await db.close(); await db.close();
}); });
// Every historical version upgrades cleanly to the current schema (v12). // Every historical version upgrades cleanly to the current schema (v13).
for (var from = 1; from <= 11; from++) { for (var from = 1; from <= 12; from++) {
test('upgrades v$from → v12 and matches the fresh schema', () async { test('upgrades v$from → v13 and matches the fresh schema', () async {
final connection = await verifier.startAt(from); final connection = await verifier.startAt(from);
final db = AppDatabase(connection); final db = AppDatabase(connection);
await verifier.migrateAndValidate(db, 12); await verifier.migrateAndValidate(db, 13);
await db.close(); await db.close();
}); });
} }

View file

@ -16,6 +16,7 @@ import 'schema_v9.dart' as v9;
import 'schema_v10.dart' as v10; import 'schema_v10.dart' as v10;
import 'schema_v11.dart' as v11; import 'schema_v11.dart' as v11;
import 'schema_v12.dart' as v12; import 'schema_v12.dart' as v12;
import 'schema_v13.dart' as v13;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@ -45,10 +46,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v11.DatabaseAtV11(db); return v11.DatabaseAtV11(db);
case 12: case 12:
return v12.DatabaseAtV12(db); return v12.DatabaseAtV12(db);
case 13:
return v13.DatabaseAtV13(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
} }
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
} }

File diff suppressed because it is too large Load diff

View file

@ -139,6 +139,23 @@ void main() {
} }
}); });
test('includes garden outcomes as story lines', () async {
final lotId = await newLot();
await repo.addGardenOutcome(
lotId: lotId,
year: 2026,
rating: GardenOutcomeRating.good,
notes: 'true to type, keeping it',
);
final history = await repo.lotHistory(lotId);
final outcome = history.singleWhere(
(e) => e.kind == LotHistoryKind.gardenOutcome,
);
expect(outcome.rating, GardenOutcomeRating.good);
expect(outcome.year, 2026);
expect(outcome.notes, 'true to type, keeping it');
});
test('ignores other lots', () async { test('ignores other lots', () async {
final lotId = await newLot(); final lotId = await newLot();
final otherLot = await newLot(); final otherLot = await newLot();

View file

@ -79,6 +79,80 @@ void main() {
await disposeTree(tester); await disposeTree(tester);
}); });
testWidgets('harvesting asks "how did it do?" once, a face tap saves it', (
tester,
) async {
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Maize');
final lotId = await repo.addLot(varietyId: id);
await tester.pumpWidget(
wrapDetail(
repository: repo,
varietyId: id,
species: newTestSpeciesRepository(db),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(Key('lot.history.$lotId')));
await tester.pumpAndSettle();
// Sowing does NOT ask; harvesting does.
await tester.tap(find.byKey(const Key('history.sowToday')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('history.outcomeQuestion')), findsNothing);
await tester.tap(find.byKey(const Key('history.harvestToday')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('history.outcomeQuestion')), findsOneWidget);
await tester.enterText(
find.byKey(const Key('history.outcomeNote')),
'true to type',
);
await tester.tap(find.byKey(const Key('history.outcomeGood')));
await tester.pumpAndSettle();
// Question gone, answer saved, story shows it.
expect(find.byKey(const Key('history.outcomeQuestion')), findsNothing);
final outcomes = await db.select(db.gardenOutcomes).get();
expect(outcomes.single.rating, GardenOutcomeRating.good);
expect(outcomes.single.notes, 'true to type');
expect(outcomes.single.year, DateTime.now().year);
expect(find.text('It did well'), findsOneWidget);
await disposeTree(tester);
});
testWidgets('the harvest question can be dismissed without a trace', (
tester,
) async {
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Maize');
final lotId = await repo.addLot(varietyId: id);
await tester.pumpWidget(
wrapDetail(
repository: repo,
varietyId: id,
species: newTestSpeciesRepository(db),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(Key('lot.history.$lotId')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('history.harvestToday')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('history.outcomeDismiss')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('history.outcomeQuestion')), findsNothing);
expect(await db.select(db.gardenOutcomes).get(), isEmpty);
await disposeTree(tester);
});
testWidgets('a plant lot offers harvest but not sowing', (tester) async { testWidgets('a plant lot offers harvest but not sowing', (tester) async {
final repo = newTestRepository(db); final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Rosemary'); final id = await repo.addQuickVariety(label: 'Rosemary');