diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index 60b1be3..dfb9a4f 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -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> 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> _loadInventory() async { final rows = await (_db.select(_db.varieties) diff --git a/apps/app_seeds/lib/ui/chat_screen.dart b/apps/app_seeds/lib/ui/chat_screen.dart index d9fa5f7..6598f36 100644 --- a/apps/app_seeds/lib/ui/chat_screen.dart +++ b/apps/app_seeds/lib/ui/chat_screen.dart @@ -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 { final sent = await showProposePlantareSheet( context, service: getIt(), + repository: getIt(), peerPubkey: widget.peerPubkey, peerName: _peerName, ); diff --git a/apps/app_seeds/lib/ui/plantare_propose_sheet.dart b/apps/app_seeds/lib/ui/plantare_propose_sheet.dart index 1915276..d317e8e 100644 --- a/apps/app_seeds/lib/ui/plantare_propose_sheet.dart +++ b/apps/app_seeds/lib/ui/plantare_propose_sheet.dart @@ -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 showProposePlantareSheet( BuildContext context, { required PlantareService service, + required VarietyRepository repository, required String peerPubkey, String? peerName, String? varietyId, @@ -24,6 +28,7 @@ Future showProposePlantareSheet( isScrollControlled: true, builder: (_) => _ProposeSheet( service: service, + repository: repository, peerPubkey: peerPubkey, peerName: peerName, varietyId: varietyId, @@ -35,6 +40,7 @@ Future 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 _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 _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 _pickDueBy() async { final now = DateTime.now(); final picked = await showDatePicker( @@ -85,14 +129,15 @@ class _ProposeSheetState extends State<_ProposeSheet> { } Future _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( - key: const Key('propose.seed'), - controller: _seed, - textCapitalization: TextCapitalization.sentences, - decoration: InputDecoration( - labelText: t.plantare.seedLabel, - helperText: t.plantare.seedHint, - border: const OutlineInputBorder(), - ), + // 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: 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, diff --git a/apps/app_seeds/test/ui/plantare_propose_sheet_test.dart b/apps/app_seeds/test/ui/plantare_propose_sheet_test.dart index 129d4f0..f06bcdf 100644 --- a/apps/app_seeds/test/ui/plantare_propose_sheet_test.dart +++ b/apps/app_seeds/test/ui/plantare_propose_sheet_test.dart @@ -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, - selfPubkey: 'a' * 64, - selfSecretKey: - '0000000000000000000000000000000000000000000000000000000000000001', - )..bindTransport(tx); + // 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 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 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 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(); });