feat(plantare): tie the propose form to a real seed

The propose sheet used a free-text seed name, unlinked from the
inventory. Now the seed is picked from your catalogued varieties so the
Plantaré is always tied to a real Variety (tap-through to its detail, and
it shows in that seed's commitments):

- Seed field is an Autocomplete over the inventory; on send the typed name
  resolves to the matching Variety, or creates a quick one if it's new —
  so varietyId is never null. Sheet takes the VarietyRepository; chat
  passes it.
- New repo one-shot `varietyLabels()` (id + label, no joins) feeds the
  picker. Deliberately a Future, not `watchInventory()` — a transient
  sheet must not hold a live Drift subscription (that hung the widget test
  ~36 min). Tests use it too and now run in ~2s with bounded pumps + a
  tall surface, no pumpAndSettle.

Tests: a new name creates & links a Variety; a matching name links the
existing one without duplicating. ui/services/data plantare suites green.
This commit is contained in:
vjrj 2026-07-15 01:24:50 +02:00
parent 13a9bead15
commit 621902612d
4 changed files with 185 additions and 41 deletions

View file

@ -619,6 +619,20 @@ class VarietyRepository {
);
}
/// One-shot list of catalogued (named, non-draft) seeds for pickers id +
/// label only, no photo/species joins. A Future (not a stream) so a transient
/// sheet doesn't hold a live subscription (which would hang widget tests).
Future<List<({String id, String label})>> varietyLabels() async {
final rows = await (_db.select(_db.varieties)
..where((v) => v.isDeleted.equals(false) & v.isDraft.equals(false))
..orderBy([(v) => OrderingTerm(expression: v.label)]))
.get();
return [
for (final v in rows)
if (v.label.trim().isNotEmpty) (id: v.id, label: v.label),
];
}
Future<List<VarietyListItem>> _loadInventory() async {
final rows =
await (_db.select(_db.varieties)

View file

@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart';
import '../data/variety_repository.dart';
import '../di/injector.dart';
import '../domain/chat_timeline.dart';
import '../domain/message_rules.dart';
@ -214,6 +215,7 @@ class _ChatScreenState extends State<ChatScreen> {
final sent = await showProposePlantareSheet(
context,
service: getIt<PlantareService>(),
repository: getIt<VarietyRepository>(),
peerPubkey: widget.peerPubkey,
peerName: _peerName,
);

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../data/variety_repository.dart';
import '../db/enums.dart';
import '../i18n/strings.g.dart';
import '../services/plantare_service.dart';
@ -8,12 +9,15 @@ import 'theme.dart';
/// Opens the "propose a signed Plantaré" sheet against a specific peer, whose
/// [peerPubkey] is already in hand (the flow starts from a chat or a closed
/// offer). Fills the terms, self-signs, and sends the proposal via
/// [PlantareService]; the peer counter-signs to close it. Returns true if a
/// proposal was sent.
/// offer). The seed is picked from the inventory ([repository]) so the promise
/// is tied to a real `Variety`; a new name creates one. Fills the terms,
/// self-signs, and sends via [PlantareService]; the peer counter-signs to close
/// it. When [varietyId]/[seedLabel] are given (opened from a seed) that seed is
/// preselected. Returns true if a proposal was sent.
Future<bool?> showProposePlantareSheet(
BuildContext context, {
required PlantareService service,
required VarietyRepository repository,
required String peerPubkey,
String? peerName,
String? varietyId,
@ -24,6 +28,7 @@ Future<bool?> showProposePlantareSheet(
isScrollControlled: true,
builder: (_) => _ProposeSheet(
service: service,
repository: repository,
peerPubkey: peerPubkey,
peerName: peerName,
varietyId: varietyId,
@ -35,6 +40,7 @@ Future<bool?> showProposePlantareSheet(
class _ProposeSheet extends StatefulWidget {
const _ProposeSheet({
required this.service,
required this.repository,
required this.peerPubkey,
this.peerName,
this.varietyId,
@ -42,6 +48,7 @@ class _ProposeSheet extends StatefulWidget {
});
final PlantareService service;
final VarietyRepository repository;
final String peerPubkey;
final String? peerName;
final String? varietyId;
@ -56,16 +63,41 @@ class _ProposeSheetState extends State<_ProposeSheet> {
// common "I'm sharing seed" case.
PlantareDirection _direction = PlantareDirection.owedToMe;
PlantareReturnKind _returnKind = PlantareReturnKind.similar;
late final TextEditingController _seed =
TextEditingController(text: widget.seedLabel ?? '');
final _owed = TextEditingController();
final _hours = TextEditingController();
DateTime? _dueBy;
bool _saving = false;
/// The user's catalogued seeds (id + label), for the picker (empty until
/// loaded). A one-shot load, not a live stream a transient sheet must not
/// hold a Drift subscription.
List<({String id, String label})> _varieties = [];
/// The picked seed's id — set when the typed name matches (or is chosen from)
/// the inventory, cleared when the text is edited to something else. Null
/// the name will create a new Variety on send, so the promise is always tied
/// to a real seed.
String? _varietyId;
/// Autocomplete owns the field's controller; captured here to read on send
/// and to seed the initial text.
TextEditingController? _seedController;
@override
void initState() {
super.initState();
_varietyId = widget.varietyId;
_loadVarieties();
}
Future<void> _loadVarieties() async {
final items = await widget.repository.varietyLabels();
if (!mounted) return;
setState(() => _varieties = items);
}
@override
void dispose() {
_seed.dispose();
_owed.dispose();
_hours.dispose();
super.dispose();
@ -73,6 +105,18 @@ class _ProposeSheetState extends State<_ProposeSheet> {
String? _nullIfBlank(String s) => s.trim().isEmpty ? null : s.trim();
/// Resolves the typed seed name to a real Variety id: an existing one when the
/// name matches the inventory, otherwise a freshly created quick variety so
/// the Plantaré is always linked to a seed.
Future<String> _resolveVarietyId(String label) async {
if (_varietyId != null) return _varietyId!;
final match = _varieties.where(
(v) => v.label.trim().toLowerCase() == label.toLowerCase(),
);
if (match.isNotEmpty) return match.first.id;
return widget.repository.addQuickVariety(label: label);
}
Future<void> _pickDueBy() async {
final now = DateTime.now();
final picked = await showDatePicker(
@ -85,14 +129,15 @@ class _ProposeSheetState extends State<_ProposeSheet> {
}
Future<void> _send() async {
final label = _seed.text.trim();
final label = (_seedController?.text ?? widget.seedLabel ?? '').trim();
if (label.isEmpty) return; // the seed name is the one thing we need
setState(() => _saving = true);
final varietyId = await _resolveVarietyId(label);
await widget.service.propose(
direction: _direction,
counterpartyKey: widget.peerPubkey,
counterpartyName: widget.peerName,
varietyId: widget.varietyId,
varietyId: varietyId,
label: label,
owedDescription: _nullIfBlank(_owed.text),
returnKind: _returnKind,
@ -148,15 +193,39 @@ class _ProposeSheetState extends State<_ProposeSheet> {
onTap: () => setState(() => _direction = d),
),
const SizedBox(height: 8),
TextField(
// Pick the seed from the inventory so the promise is tied to a real
// Variety; typing a new name creates one on send.
Autocomplete<({String id, String label})>(
initialValue: TextEditingValue(text: widget.seedLabel ?? ''),
displayStringForOption: (v) => v.label,
optionsBuilder: (value) {
final q = value.text.trim().toLowerCase();
if (q.isEmpty) return _varieties;
return _varieties
.where((v) => v.label.toLowerCase().contains(q));
},
onSelected: (v) => setState(() => _varietyId = v.id),
fieldViewBuilder:
(context, controller, focusNode, onFieldSubmitted) {
_seedController = controller;
return TextField(
key: const Key('propose.seed'),
controller: _seed,
controller: controller,
focusNode: focusNode,
textCapitalization: TextCapitalization.sentences,
onChanged: (_) {
// Editing away from a picked seed unlinks it (a new name
// will create its own Variety).
if (_varietyId != null) setState(() => _varietyId = null);
},
onSubmitted: (_) => onFieldSubmitted(),
decoration: InputDecoration(
labelText: t.plantare.seedLabel,
helperText: t.plantare.seedHint,
border: const OutlineInputBorder(),
),
);
},
),
const SizedBox(height: 16),
Text(t.plantare.returnKindLabel,

View file

@ -2,6 +2,8 @@ import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/variety_repository.dart';
import 'package:tane/db/database.dart';
import 'package:tane/db/enums.dart';
import 'package:tane/i18n/strings.g.dart';
import 'package:tane/services/plantare_service.dart';
@ -29,21 +31,19 @@ class _CapturingTransport implements PlantareTransport {
}
void main() {
testWidgets('filling the seed and sending proposes a signed pledge',
(tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
final db = newTestDatabase();
addTearDown(db.close);
final repo = newTestRepository(db);
final tx = _CapturingTransport();
final service = PlantareService(
repo: repo,
// NEVER pumpAndSettle these: the sheet subscribes to a live Drift stream
// (watchInventory) and a focused TextField blinks its cursor forever, so
// pumpAndSettle never returns (see CLAUDE.md testing note). Use a tall surface
// so the whole sheet fits without scrolling, and bounded pumps only.
PlantareService serviceFor(AppDatabase db, _CapturingTransport tx) =>
PlantareService(
repo: newTestRepository(db),
selfPubkey: 'a' * 64,
selfSecretKey:
'0000000000000000000000000000000000000000000000000000000000000001',
)..bindTransport(tx);
await tester.pumpWidget(
Widget host(PlantareService service, VarietyRepository repo) =>
TranslationProvider(
child: MaterialApp(
locale: const Locale('en'),
@ -60,6 +60,7 @@ void main() {
onPressed: () => showProposePlantareSheet(
context,
service: service,
repository: repo,
peerPubkey: 'b' * 64,
peerName: 'Ana',
),
@ -69,31 +70,89 @@ void main() {
),
),
),
),
);
Future<void> useTallSurface(WidgetTester tester) async {
tester.view.physicalSize = const Size(1400, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
}
/// Opens the sheet and lets the slide-in + inventory load settle (bounded).
Future<void> openSheet(WidgetTester tester) async {
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
await tester.pump(); // start the sheet route
await tester.pump(const Duration(milliseconds: 400)); // slide-in + load
await tester.pump(const Duration(milliseconds: 200));
}
/// Taps send and lets the async propose (DB write + sign + pop) drain.
Future<void> tapSend(WidgetTester tester) async {
await tester.tap(find.byKey(const Key('propose.send')));
for (var i = 0; i < 6; i++) {
await tester.pump(const Duration(milliseconds: 100));
}
}
testWidgets('a new seed name proposes a signed pledge linked to a Variety',
(tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
await useTallSurface(tester);
final db = newTestDatabase();
addTearDown(db.close);
final repo = newTestRepository(db);
final tx = _CapturingTransport();
final service = serviceFor(db, tx);
await tester.pumpWidget(host(service, repo));
await openSheet(tester);
expect(find.byKey(const Key('propose.seed')), findsOneWidget);
// The seed name is the one required field.
await tester.enterText(
find.byKey(const Key('propose.seed')), 'Tomate rosa');
await tester.pump();
expect(find.text('Tomate rosa'), findsOneWidget);
await tester.ensureVisible(find.byKey(const Key('propose.send')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('propose.send')));
await tester.pumpAndSettle();
await tapSend(tester);
expect(tx.proposed, hasLength(1));
expect(tx.proposed.single.label, 'Tomate rosa');
// Default direction is owedToMe I'm the creditor and signed my stub.
expect(tx.proposed.single.creditorSignature, isNotNull);
expect(tx.proposed.single.debtorSignature, isNull);
// The local row is recorded as proposed.
// The local row is recorded as proposed AND tied to a real Variety a new
// name created one on send.
final row = await repo.plantareByPledgeId(tx.proposed.single.pledgeId);
expect(row!.remoteState, PlantareRemoteState.proposed);
expect(row.varietyId, isNotNull);
final linked = await repo.varietyLabels();
expect(linked.map((v) => v.id), contains(row.varietyId));
await service.stop();
});
testWidgets('typing an existing seed links the pledge to that variety',
(tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
await useTallSurface(tester);
final db = newTestDatabase();
addTearDown(db.close);
final repo = newTestRepository(db);
final existingId = await repo.addQuickVariety(label: 'Maíz rojo');
final tx = _CapturingTransport();
final service = serviceFor(db, tx);
await tester.pumpWidget(host(service, repo));
await openSheet(tester);
// A matching name resolves to the existing variety no duplicate created.
await tester.enterText(find.byKey(const Key('propose.seed')), 'Maíz rojo');
await tester.pump();
await tapSend(tester);
expect(tx.proposed, hasLength(1));
final row = await repo.plantareByPledgeId(tx.proposed.single.pledgeId);
expect(row!.varietyId, existingId); // linked, not duplicated
expect((await repo.varietyLabels()).length, 1);
await service.stop();
});