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 { Future<List<VarietyListItem>> _loadInventory() async {
final rows = final rows =
await (_db.select(_db.varieties) 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:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../data/variety_repository.dart';
import '../di/injector.dart'; import '../di/injector.dart';
import '../domain/chat_timeline.dart'; import '../domain/chat_timeline.dart';
import '../domain/message_rules.dart'; import '../domain/message_rules.dart';
@ -214,6 +215,7 @@ class _ChatScreenState extends State<ChatScreen> {
final sent = await showProposePlantareSheet( final sent = await showProposePlantareSheet(
context, context,
service: getIt<PlantareService>(), service: getIt<PlantareService>(),
repository: getIt<VarietyRepository>(),
peerPubkey: widget.peerPubkey, peerPubkey: widget.peerPubkey,
peerName: _peerName, peerName: _peerName,
); );

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../data/variety_repository.dart';
import '../db/enums.dart'; import '../db/enums.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../services/plantare_service.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 /// 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 /// [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 /// offer). The seed is picked from the inventory ([repository]) so the promise
/// [PlantareService]; the peer counter-signs to close it. Returns true if a /// is tied to a real `Variety`; a new name creates one. Fills the terms,
/// proposal was sent. /// 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( Future<bool?> showProposePlantareSheet(
BuildContext context, { BuildContext context, {
required PlantareService service, required PlantareService service,
required VarietyRepository repository,
required String peerPubkey, required String peerPubkey,
String? peerName, String? peerName,
String? varietyId, String? varietyId,
@ -24,6 +28,7 @@ Future<bool?> showProposePlantareSheet(
isScrollControlled: true, isScrollControlled: true,
builder: (_) => _ProposeSheet( builder: (_) => _ProposeSheet(
service: service, service: service,
repository: repository,
peerPubkey: peerPubkey, peerPubkey: peerPubkey,
peerName: peerName, peerName: peerName,
varietyId: varietyId, varietyId: varietyId,
@ -35,6 +40,7 @@ Future<bool?> showProposePlantareSheet(
class _ProposeSheet extends StatefulWidget { class _ProposeSheet extends StatefulWidget {
const _ProposeSheet({ const _ProposeSheet({
required this.service, required this.service,
required this.repository,
required this.peerPubkey, required this.peerPubkey,
this.peerName, this.peerName,
this.varietyId, this.varietyId,
@ -42,6 +48,7 @@ class _ProposeSheet extends StatefulWidget {
}); });
final PlantareService service; final PlantareService service;
final VarietyRepository repository;
final String peerPubkey; final String peerPubkey;
final String? peerName; final String? peerName;
final String? varietyId; final String? varietyId;
@ -56,16 +63,41 @@ class _ProposeSheetState extends State<_ProposeSheet> {
// common "I'm sharing seed" case. // common "I'm sharing seed" case.
PlantareDirection _direction = PlantareDirection.owedToMe; PlantareDirection _direction = PlantareDirection.owedToMe;
PlantareReturnKind _returnKind = PlantareReturnKind.similar; PlantareReturnKind _returnKind = PlantareReturnKind.similar;
late final TextEditingController _seed =
TextEditingController(text: widget.seedLabel ?? '');
final _owed = TextEditingController(); final _owed = TextEditingController();
final _hours = TextEditingController(); final _hours = TextEditingController();
DateTime? _dueBy; DateTime? _dueBy;
bool _saving = false; 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 @override
void dispose() { void dispose() {
_seed.dispose();
_owed.dispose(); _owed.dispose();
_hours.dispose(); _hours.dispose();
super.dispose(); super.dispose();
@ -73,6 +105,18 @@ class _ProposeSheetState extends State<_ProposeSheet> {
String? _nullIfBlank(String s) => s.trim().isEmpty ? null : s.trim(); 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 { Future<void> _pickDueBy() async {
final now = DateTime.now(); final now = DateTime.now();
final picked = await showDatePicker( final picked = await showDatePicker(
@ -85,14 +129,15 @@ class _ProposeSheetState extends State<_ProposeSheet> {
} }
Future<void> _send() async { 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 if (label.isEmpty) return; // the seed name is the one thing we need
setState(() => _saving = true); setState(() => _saving = true);
final varietyId = await _resolveVarietyId(label);
await widget.service.propose( await widget.service.propose(
direction: _direction, direction: _direction,
counterpartyKey: widget.peerPubkey, counterpartyKey: widget.peerPubkey,
counterpartyName: widget.peerName, counterpartyName: widget.peerName,
varietyId: widget.varietyId, varietyId: varietyId,
label: label, label: label,
owedDescription: _nullIfBlank(_owed.text), owedDescription: _nullIfBlank(_owed.text),
returnKind: _returnKind, returnKind: _returnKind,
@ -148,15 +193,39 @@ class _ProposeSheetState extends State<_ProposeSheet> {
onTap: () => setState(() => _direction = d), onTap: () => setState(() => _direction = d),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
TextField( // Pick the seed from the inventory so the promise is tied to a real
key: const Key('propose.seed'), // Variety; typing a new name creates one on send.
controller: _seed, Autocomplete<({String id, String label})>(
textCapitalization: TextCapitalization.sentences, initialValue: TextEditingValue(text: widget.seedLabel ?? ''),
decoration: InputDecoration( displayStringForOption: (v) => v.label,
labelText: t.plantare.seedLabel, optionsBuilder: (value) {
helperText: t.plantare.seedHint, final q = value.text.trim().toLowerCase();
border: const OutlineInputBorder(), 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: 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), const SizedBox(height: 16),
Text(t.plantare.returnKindLabel, 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/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.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/db/enums.dart';
import 'package:tane/i18n/strings.g.dart'; import 'package:tane/i18n/strings.g.dart';
import 'package:tane/services/plantare_service.dart'; import 'package:tane/services/plantare_service.dart';
@ -29,21 +31,19 @@ class _CapturingTransport implements PlantareTransport {
} }
void main() { void main() {
testWidgets('filling the seed and sending proposes a signed pledge', // NEVER pumpAndSettle these: the sheet subscribes to a live Drift stream
(tester) async { // (watchInventory) and a focused TextField blinks its cursor forever, so
LocaleSettings.setLocaleSync(AppLocale.en); // pumpAndSettle never returns (see CLAUDE.md testing note). Use a tall surface
final db = newTestDatabase(); // so the whole sheet fits without scrolling, and bounded pumps only.
addTearDown(db.close); PlantareService serviceFor(AppDatabase db, _CapturingTransport tx) =>
final repo = newTestRepository(db); PlantareService(
final tx = _CapturingTransport(); repo: newTestRepository(db),
final service = PlantareService( selfPubkey: 'a' * 64,
repo: repo, selfSecretKey:
selfPubkey: 'a' * 64, '0000000000000000000000000000000000000000000000000000000000000001',
selfSecretKey: )..bindTransport(tx);
'0000000000000000000000000000000000000000000000000000000000000001',
)..bindTransport(tx);
await tester.pumpWidget( Widget host(PlantareService service, VarietyRepository repo) =>
TranslationProvider( TranslationProvider(
child: MaterialApp( child: MaterialApp(
locale: const Locale('en'), locale: const Locale('en'),
@ -60,6 +60,7 @@ void main() {
onPressed: () => showProposePlantareSheet( onPressed: () => showProposePlantareSheet(
context, context,
service: service, service: service,
repository: repo,
peerPubkey: 'b' * 64, peerPubkey: 'b' * 64,
peerName: 'Ana', 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.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); expect(find.byKey(const Key('propose.seed')), findsOneWidget);
// The seed name is the one required field.
await tester.enterText( await tester.enterText(
find.byKey(const Key('propose.seed')), 'Tomate rosa'); find.byKey(const Key('propose.seed')), 'Tomate rosa');
await tester.pump(); await tester.pump();
expect(find.text('Tomate rosa'), findsOneWidget); await tapSend(tester);
await tester.ensureVisible(find.byKey(const Key('propose.send')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('propose.send')));
await tester.pumpAndSettle();
expect(tx.proposed, hasLength(1)); expect(tx.proposed, hasLength(1));
expect(tx.proposed.single.label, 'Tomate rosa'); expect(tx.proposed.single.label, 'Tomate rosa');
// Default direction is owedToMe I'm the creditor and signed my stub. // Default direction is owedToMe I'm the creditor and signed my stub.
expect(tx.proposed.single.creditorSignature, isNotNull); expect(tx.proposed.single.creditorSignature, isNotNull);
expect(tx.proposed.single.debtorSignature, isNull); 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); final row = await repo.plantareByPledgeId(tx.proposed.single.pledgeId);
expect(row!.remoteState, PlantareRemoteState.proposed); 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(); await service.stop();
}); });