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:
parent
4ab146b157
commit
7a82505a4e
4 changed files with 185 additions and 41 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue