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 4ab146b157
commit 7a82505a4e
4 changed files with 185 additions and 41 deletions

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(
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,