feat(plantare): UI — propose from chat, review & sign on the ledger

The viral loop, made usable end-to-end:

- Propose sheet (plantare_propose_sheet.dart): from a chat with a peer
  (their key already in hand), pick your side, name the seed, choose what
  comes back (similar · non-GMO organic / work hours / other) and an
  optional return-by, then sign and send. Reached from the chat overflow
  menu; confirms "waiting for them to sign".
- Plantares screen: incoming proposals show Accept & sign / Decline right
  on the tile; every bilateral row wears a handshake badge — awaiting
  signature, signed by both, or declined. Accept falls back to a gentle
  "offline" nudge if the proposal isn't in hand yet.
- i18n: full en + es for the bilateral flow; other locales fall back to
  base per slang config.

Wire-up only touches human copy (no jargon) and stays behind the
optional PlantareService, so inventory-only builds are unaffected.

Tests: propose sheet drives a real sign+send; the ledger renders the
signed/awaiting badges. Fake SocialSession in your_people test gains the
new transport getter. Widget tests targeted + timed. All green.
This commit is contained in:
vjrj 2026-07-14 11:19:04 +02:00
parent 2e25e34d11
commit f9c3dc018d
11 changed files with 2223 additions and 1459 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 7 /// Locales: 7
/// Strings: 3396 (485 per locale) /// Strings: 3442 (491 per locale)
/// ///
/// Built on 2026-07-14 at 09:00 UTC /// Built on 2026-07-14 at 09:22 UTC
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import

View file

@ -1809,6 +1809,75 @@ class Translations$plantare$en {
/// en: 'Commitments' /// en: 'Commitments'
String get sectionTitle => 'Commitments'; String get sectionTitle => 'Commitments';
/// en: 'Propose a signed Plantaré'
String get propose => 'Propose a signed Plantaré';
/// en: 'Both of you keep the same promise, signed by both — proof that this seed changed hands and will be grown out and returned.'
String get proposeHelp => 'Both of you keep the same promise, signed by both — proof that this seed changed hands and will be grown out and returned.';
/// en: 'With {name}'
String proposeTo({required Object name}) => 'With ${name}';
/// en: 'Proposal sent — waiting for them to sign'
String get sent => 'Proposal sent — waiting for them to sign';
/// en: 'Which seed?'
String get seedLabel => 'Which seed?';
/// en: 'The variety this promise is about'
String get seedHint => 'The variety this promise is about';
/// en: 'What comes back?'
String get returnKindLabel => 'What comes back?';
/// en: 'A similar amount of seed'
String get returnSimilar => 'A similar amount of seed';
/// en: 'open-pollinated · non-GMO · organically grown'
String get returnSimilarNote => 'open-pollinated · non-GMO · organically grown';
/// en: 'Some hours of work'
String get returnWork => 'Some hours of work';
/// en: 'Something else'
String get returnOther => 'Something else';
/// en: 'How many hours?'
String get workHoursLabel => 'How many hours?';
/// en: 'Waiting for your reply'
String get proposalsSection => 'Waiting for your reply';
/// en: '{name} proposes a Plantaré'
String incomingFrom({required Object name}) => '${name} proposes a Plantaré';
/// en: 'Accept & sign'
String get accept => 'Accept & sign';
/// en: 'Decline'
String get declineAction => 'Decline';
/// en: 'Reason (optional)'
String get declineReasonHint => 'Reason (optional)';
/// en: 'Signed — you both hold it now'
String get acceptedToast => 'Signed — you both hold it now';
/// en: 'Declined'
String get declinedToast => 'Declined';
/// en: 'Awaiting signature'
String get badgeAwaiting => 'Awaiting signature';
/// en: 'Signed by both'
String get badgeSigned => 'Signed by both';
/// en: 'Declined'
String get badgeDeclined => 'Declined';
/// en: 'You're offline it'll be sent when you reconnect'
String get offline => 'You\'re offline — it\'ll be sent when you reconnect';
} }
// Path: handover // Path: handover
@ -3020,6 +3089,31 @@ extension on Translations {
'plantare.pickDate' => 'Pick a date', 'plantare.pickDate' => 'Pick a date',
'plantare.clearDate' => 'Clear date', 'plantare.clearDate' => 'Clear date',
'plantare.sectionTitle' => 'Commitments', 'plantare.sectionTitle' => 'Commitments',
'plantare.propose' => 'Propose a signed Plantaré',
'plantare.proposeHelp' => 'Both of you keep the same promise, signed by both — proof that this seed changed hands and will be grown out and returned.',
'plantare.proposeTo' => ({required Object name}) => 'With ${name}',
'plantare.sent' => 'Proposal sent — waiting for them to sign',
'plantare.seedLabel' => 'Which seed?',
'plantare.seedHint' => 'The variety this promise is about',
'plantare.returnKindLabel' => 'What comes back?',
'plantare.returnSimilar' => 'A similar amount of seed',
'plantare.returnSimilarNote' => 'open-pollinated · non-GMO · organically grown',
'plantare.returnWork' => 'Some hours of work',
'plantare.returnOther' => 'Something else',
'plantare.workHoursLabel' => 'How many hours?',
'plantare.proposalsSection' => 'Waiting for your reply',
'plantare.incomingFrom' => ({required Object name}) => '${name} proposes a Plantaré',
'plantare.accept' => 'Accept & sign',
_ => null,
} ?? switch (path) {
'plantare.declineAction' => 'Decline',
'plantare.declineReasonHint' => 'Reason (optional)',
'plantare.acceptedToast' => 'Signed — you both hold it now',
'plantare.declinedToast' => 'Declined',
'plantare.badgeAwaiting' => 'Awaiting signature',
'plantare.badgeSigned' => 'Signed by both',
'plantare.badgeDeclined' => 'Declined',
'plantare.offline' => 'You\'re offline — it\'ll be sent when you reconnect',
'handover.title' => 'Seeds changed hands', 'handover.title' => 'Seeds changed hands',
'handover.help' => 'A gift, a swap or a sale — note it down, with a return promise or without.', 'handover.help' => 'A gift, a swap or a sale — note it down, with a return promise or without.',
'handover.iGave' => 'I gave seeds', 'handover.iGave' => 'I gave seeds',
@ -3035,8 +3129,6 @@ extension on Translations {
'sale.help' => 'Record seed you sold or bought — money, Ğ1, or any currency. A separate model from a gift or a Plantare. No commission is ever taken on seeds.', 'sale.help' => 'Record seed you sold or bought — money, Ğ1, or any currency. A separate model from a gift or a Plantare. No commission is ever taken on seeds.',
'sale.add' => 'Record a sale', 'sale.add' => 'Record a sale',
'sale.empty' => 'No sales yet. Note here what you sell or buy.', 'sale.empty' => 'No sales yet. Note here what you sell or buy.',
_ => null,
} ?? switch (path) {
'sale.iSold' => 'I sold', 'sale.iSold' => 'I sold',
'sale.iBought' => 'I bought', 'sale.iBought' => 'I bought',
'sale.direction' => 'Sold or bought?', 'sale.direction' => 'Sold or bought?',

View file

@ -950,6 +950,29 @@ class _Translations$plantare$es extends Translations$plantare$en {
@override String get pickDate => 'Elegir fecha'; @override String get pickDate => 'Elegir fecha';
@override String get clearDate => 'Quitar fecha'; @override String get clearDate => 'Quitar fecha';
@override String get sectionTitle => 'Compromisos'; @override String get sectionTitle => 'Compromisos';
@override String get propose => 'Proponer un Plantaré firmado';
@override String get proposeHelp => 'Los dos guardáis la misma promesa, firmada por ambos: prueba de que esta semilla cambió de manos y se cultivará y devolverá.';
@override String proposeTo({required Object name}) => 'Con ${name}';
@override String get sent => 'Propuesta enviada — esperando su firma';
@override String get seedLabel => '¿Qué semilla?';
@override String get seedHint => 'La variedad a la que se refiere la promesa';
@override String get returnKindLabel => '¿Qué se devuelve?';
@override String get returnSimilar => 'Una cantidad similar de semilla';
@override String get returnSimilarNote => 'polinización abierta · no transgénica · cultivada en ecológico';
@override String get returnWork => 'Unas horas de trabajo';
@override String get returnOther => 'Otra cosa';
@override String get workHoursLabel => '¿Cuántas horas?';
@override String get proposalsSection => 'Esperando tu respuesta';
@override String incomingFrom({required Object name}) => '${name} te propone un Plantaré';
@override String get accept => 'Aceptar y firmar';
@override String get declineAction => 'Rechazar';
@override String get declineReasonHint => 'Motivo (opcional)';
@override String get acceptedToast => 'Firmado — ahora lo guardáis los dos';
@override String get declinedToast => 'Rechazado';
@override String get badgeAwaiting => 'Falta firma';
@override String get badgeSigned => 'Firmado por ambos';
@override String get badgeDeclined => 'Rechazado';
@override String get offline => 'Estás sin conexión — se enviará al reconectar';
} }
// Path: handover // Path: handover
@ -1910,6 +1933,31 @@ extension on TranslationsEs {
'plantare.pickDate' => 'Elegir fecha', 'plantare.pickDate' => 'Elegir fecha',
'plantare.clearDate' => 'Quitar fecha', 'plantare.clearDate' => 'Quitar fecha',
'plantare.sectionTitle' => 'Compromisos', 'plantare.sectionTitle' => 'Compromisos',
'plantare.propose' => 'Proponer un Plantaré firmado',
'plantare.proposeHelp' => 'Los dos guardáis la misma promesa, firmada por ambos: prueba de que esta semilla cambió de manos y se cultivará y devolverá.',
'plantare.proposeTo' => ({required Object name}) => 'Con ${name}',
'plantare.sent' => 'Propuesta enviada — esperando su firma',
'plantare.seedLabel' => '¿Qué semilla?',
'plantare.seedHint' => 'La variedad a la que se refiere la promesa',
'plantare.returnKindLabel' => '¿Qué se devuelve?',
'plantare.returnSimilar' => 'Una cantidad similar de semilla',
'plantare.returnSimilarNote' => 'polinización abierta · no transgénica · cultivada en ecológico',
'plantare.returnWork' => 'Unas horas de trabajo',
'plantare.returnOther' => 'Otra cosa',
'plantare.workHoursLabel' => '¿Cuántas horas?',
'plantare.proposalsSection' => 'Esperando tu respuesta',
'plantare.incomingFrom' => ({required Object name}) => '${name} te propone un Plantaré',
'plantare.accept' => 'Aceptar y firmar',
'plantare.declineAction' => 'Rechazar',
_ => null,
} ?? switch (path) {
'plantare.declineReasonHint' => 'Motivo (opcional)',
'plantare.acceptedToast' => 'Firmado — ahora lo guardáis los dos',
'plantare.declinedToast' => 'Rechazado',
'plantare.badgeAwaiting' => 'Falta firma',
'plantare.badgeSigned' => 'Firmado por ambos',
'plantare.badgeDeclined' => 'Rechazado',
'plantare.offline' => 'Estás sin conexión — se enviará al reconectar',
'handover.title' => 'Di o recibí semillas', 'handover.title' => 'Di o recibí semillas',
'handover.help' => 'Un regalo, un trueque o una venta: apúntalo, con promesa de devolver semilla o sin ella.', 'handover.help' => 'Un regalo, un trueque o una venta: apúntalo, con promesa de devolver semilla o sin ella.',
'handover.iGave' => 'Di semillas', 'handover.iGave' => 'Di semillas',
@ -1926,8 +1974,6 @@ extension on TranslationsEs {
'sale.add' => 'Registrar venta', 'sale.add' => 'Registrar venta',
'sale.empty' => 'Aún no hay ventas. Anota aquí lo que vendes o compras.', 'sale.empty' => 'Aún no hay ventas. Anota aquí lo que vendes o compras.',
'sale.iSold' => 'Vendí', 'sale.iSold' => 'Vendí',
_ => null,
} ?? switch (path) {
'sale.iBought' => 'Compré', 'sale.iBought' => 'Compré',
'sale.direction' => '¿Vendes o compras?', 'sale.direction' => '¿Vendes o compras?',
'sale.counterparty' => '¿Con quién?', 'sale.counterparty' => '¿Con quién?',

View file

@ -12,6 +12,7 @@ import '../domain/message_rules.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../services/message_store.dart'; import '../services/message_store.dart';
import '../services/onboarding_store.dart'; import '../services/onboarding_store.dart';
import '../services/plantare_service.dart';
import '../services/profile_cache.dart'; import '../services/profile_cache.dart';
import '../services/profile_store.dart'; import '../services/profile_store.dart';
import '../services/social_connection.dart'; import '../services/social_connection.dart';
@ -23,6 +24,7 @@ import '../state/peer_rating_cubit.dart';
import '../state/trust_cubit.dart'; import '../state/trust_cubit.dart';
import 'market_gate.dart'; import 'market_gate.dart';
import 'peer_avatar.dart'; import 'peer_avatar.dart';
import 'plantare_propose_sheet.dart';
import 'rating_sheet.dart'; import 'rating_sheet.dart';
import 'report_sheet.dart'; import 'report_sheet.dart';
import 'theme.dart'; import 'theme.dart';
@ -205,6 +207,23 @@ class _ChatScreenState extends State<ChatScreen> {
/// Reports this peer (a standard report event to the community servers); /// Reports this peer (a standard report event to the community servers);
/// when they were also blocked from the sheet, leaves the chat too. /// when they were also blocked from the sheet, leaves the chat too.
/// Opens the "propose a signed Plantaré" sheet against this peer (their key is
/// in hand from the conversation), and confirms when a proposal is sent.
Future<void> _proposePlantare() async {
if (!getIt.isRegistered<PlantareService>()) return;
final sent = await showProposePlantareSheet(
context,
service: getIt<PlantareService>(),
peerPubkey: widget.peerPubkey,
peerName: _peerName,
);
if (sent == true && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.t.plantare.sent)),
);
}
}
Future<void> _reportPeer() async { Future<void> _reportPeer() async {
final outcome = await showReportSheet( final outcome = await showReportSheet(
context, context,
@ -305,10 +324,16 @@ class _ChatScreenState extends State<ChatScreen> {
PopupMenuButton<String>( PopupMenuButton<String>(
key: const Key('chat.menu'), key: const Key('chat.menu'),
onSelected: (value) { onSelected: (value) {
if (value == 'plantare') _proposePlantare();
if (value == 'report') _reportPeer(); if (value == 'report') _reportPeer();
if (value == 'block') _blockPeer(); if (value == 'block') _blockPeer();
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
if (getIt.isRegistered<PlantareService>())
PopupMenuItem(
value: 'plantare',
child: Text(t.plantare.propose),
),
PopupMenuItem( PopupMenuItem(
value: 'report', value: 'report',
child: Text(t.report.person), child: Text(t.report.person),

View file

@ -0,0 +1,289 @@
import 'package:flutter/material.dart';
import '../db/enums.dart';
import '../i18n/strings.g.dart';
import '../services/plantare_service.dart';
import '../services/profile_cache.dart' show shortPubkey;
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.
Future<bool?> showProposePlantareSheet(
BuildContext context, {
required PlantareService service,
required String peerPubkey,
String? peerName,
String? varietyId,
String? seedLabel,
}) {
return showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (_) => _ProposeSheet(
service: service,
peerPubkey: peerPubkey,
peerName: peerName,
varietyId: varietyId,
seedLabel: seedLabel,
),
);
}
class _ProposeSheet extends StatefulWidget {
const _ProposeSheet({
required this.service,
required this.peerPubkey,
this.peerName,
this.varietyId,
this.seedLabel,
});
final PlantareService service;
final String peerPubkey;
final String? peerName;
final String? varietyId;
final String? seedLabel;
@override
State<_ProposeSheet> createState() => _ProposeSheetState();
}
class _ProposeSheetState extends State<_ProposeSheet> {
// Default: I gave the seed and they'll return some (I'm the creditor) the
// 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;
@override
void dispose() {
_seed.dispose();
_owed.dispose();
_hours.dispose();
super.dispose();
}
String? _nullIfBlank(String s) => s.trim().isEmpty ? null : s.trim();
Future<void> _pickDueBy() async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: _dueBy ?? DateTime(now.year + 1, now.month, now.day),
firstDate: DateTime(now.year - 1),
lastDate: DateTime(now.year + 10),
);
if (picked != null) setState(() => _dueBy = picked);
}
Future<void> _send() async {
final label = _seed.text.trim();
if (label.isEmpty) return; // the seed name is the one thing we need
setState(() => _saving = true);
await widget.service.propose(
direction: _direction,
counterpartyKey: widget.peerPubkey,
counterpartyName: widget.peerName,
varietyId: widget.varietyId,
label: label,
owedDescription: _nullIfBlank(_owed.text),
returnKind: _returnKind,
workHours: _returnKind == PlantareReturnKind.workHours
? double.tryParse(_hours.text.trim().replaceAll(',', '.'))
: null,
dueBy: _dueBy,
);
if (mounted) Navigator.of(context).pop(true);
}
@override
Widget build(BuildContext context) {
final t = context.t;
final peer = widget.peerName ?? shortPubkey(widget.peerPubkey);
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(t.plantare.propose,
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 4),
Text(t.plantare.proposeTo(name: peer),
style: const TextStyle(color: seedGreen, fontSize: 13)),
const SizedBox(height: 8),
Text(t.plantare.proposeHelp,
style: const TextStyle(color: seedMuted, fontSize: 13)),
const SizedBox(height: 16),
Text(t.plantare.direction,
style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 6),
for (final d in PlantareDirection.values)
ListTile(
key: Key('propose.direction.${d.name}'),
contentPadding: EdgeInsets.zero,
leading: Icon(
_direction == d
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: _direction == d ? seedGreen : seedMuted,
),
title: Text(d == PlantareDirection.iReturn
? t.plantare.iReturn
: t.plantare.owedToMe),
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(),
),
),
const SizedBox(height: 16),
Text(t.plantare.returnKindLabel,
style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 6),
_ReturnChoice(
value: PlantareReturnKind.similar,
group: _returnKind,
title: t.plantare.returnSimilar,
note: t.plantare.returnSimilarNote,
onTap: (v) => setState(() => _returnKind = v),
),
_ReturnChoice(
value: PlantareReturnKind.workHours,
group: _returnKind,
title: t.plantare.returnWork,
onTap: (v) => setState(() => _returnKind = v),
),
_ReturnChoice(
value: PlantareReturnKind.other,
group: _returnKind,
title: t.plantare.returnOther,
onTap: (v) => setState(() => _returnKind = v),
),
if (_returnKind == PlantareReturnKind.workHours) ...[
const SizedBox(height: 8),
TextField(
key: const Key('propose.hours'),
controller: _hours,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: t.plantare.workHoursLabel,
border: const OutlineInputBorder(),
),
),
],
const SizedBox(height: 12),
TextField(
key: const Key('propose.owed'),
controller: _owed,
decoration: InputDecoration(
labelText: t.plantare.owed,
helperText: t.plantare.owedHint,
helperMaxLines: 2,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
InputDecorator(
decoration: InputDecoration(
labelText: t.plantare.dueByLabel,
helperText: t.plantare.dueByHint,
border: const OutlineInputBorder(),
),
child: Row(
children: [
Expanded(
child: Text(
_dueBy == null
? t.plantare.pickDate
: MaterialLocalizations.of(context)
.formatShortDate(_dueBy!),
style: TextStyle(
color: _dueBy == null ? seedMuted : seedOnSurface,
),
),
),
if (_dueBy != null)
IconButton(
icon: const Icon(Icons.clear, size: 20),
tooltip: t.plantare.clearDate,
onPressed: () => setState(() => _dueBy = null),
),
IconButton(
key: const Key('propose.dueBy.pick'),
icon: const Icon(Icons.event_outlined),
tooltip: t.plantare.pickDate,
onPressed: _pickDueBy,
),
],
),
),
const SizedBox(height: 20),
FilledButton.icon(
key: const Key('propose.send'),
onPressed: _saving ? null : _send,
icon: const Icon(Icons.draw_outlined),
label: Text(t.plantare.propose),
),
],
),
),
);
}
}
class _ReturnChoice extends StatelessWidget {
const _ReturnChoice({
required this.value,
required this.group,
required this.title,
required this.onTap,
this.note,
});
final PlantareReturnKind value;
final PlantareReturnKind group;
final String title;
final String? note;
final ValueChanged<PlantareReturnKind> onTap;
@override
Widget build(BuildContext context) {
final selected = value == group;
return ListTile(
key: Key('propose.return.${value.name}'),
contentPadding: EdgeInsets.zero,
leading: Icon(
selected ? Icons.radio_button_checked : Icons.radio_button_unchecked,
color: selected ? seedGreen : seedMuted,
),
title: Text(title),
subtitle: note == null
? null
: Text(note!, style: const TextStyle(color: seedMuted, fontSize: 12)),
onTap: () => onTap(value),
);
}
}

View file

@ -4,7 +4,9 @@ import 'package:go_router/go_router.dart';
import '../data/variety_repository.dart'; import '../data/variety_repository.dart';
import '../db/enums.dart'; import '../db/enums.dart';
import '../di/injector.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../services/plantare_service.dart';
import 'plantare_sheet.dart'; import 'plantare_sheet.dart';
import 'theme.dart'; import 'theme.dart';
@ -18,6 +20,9 @@ class PlantaresScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = context.t; final t = context.t;
final repo = context.read<VarietyRepository>(); final repo = context.read<VarietyRepository>();
final service = getIt.isRegistered<PlantareService>()
? getIt<PlantareService>()
: null;
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(t.plantare.title)), appBar: AppBar(title: Text(t.plantare.title)),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
@ -53,9 +58,9 @@ class PlantaresScreen extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 88), padding: const EdgeInsets.only(bottom: 88),
children: [ children: [
if (open.isNotEmpty) _SectionHeader(t.plantare.openSection), if (open.isNotEmpty) _SectionHeader(t.plantare.openSection),
for (final v in open) _PlantareTile(v, repo), for (final v in open) _PlantareTile(v, repo, service),
if (settled.isNotEmpty) _SectionHeader(t.plantare.settledSection), if (settled.isNotEmpty) _SectionHeader(t.plantare.settledSection),
for (final v in settled) _PlantareTile(v, repo), for (final v in settled) _PlantareTile(v, repo, service),
], ],
); );
}, },
@ -64,6 +69,50 @@ class PlantaresScreen extends StatelessWidget {
} }
} }
/// A small chip for the bilateral handshake state "awaiting signature",
/// "signed by both", or "declined". Absent for a plain v1 local note.
class _RemoteStateBadge extends StatelessWidget {
const _RemoteStateBadge(this.state);
final PlantareRemoteState state;
@override
Widget build(BuildContext context) {
final t = context.t;
final (label, color, icon) = switch (state) {
PlantareRemoteState.proposed => (
t.plantare.badgeAwaiting,
seedMuted,
Icons.hourglass_empty,
),
PlantareRemoteState.accepted => (
t.plantare.badgeSigned,
seedGreen,
Icons.verified_outlined,
),
PlantareRemoteState.declined => (
t.plantare.badgeDeclined,
seedWarning,
Icons.do_not_disturb_on_outlined,
),
};
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
color: color, fontSize: 12, fontWeight: FontWeight.w600),
),
],
),
);
}
}
class _SectionHeader extends StatelessWidget { class _SectionHeader extends StatelessWidget {
const _SectionHeader(this.label); const _SectionHeader(this.label);
final String label; final String label;
@ -89,10 +138,49 @@ class _SectionHeader extends StatelessWidget {
/// it's still ahead or overdue. The overflow menu carries the rest (let go / /// it's still ahead or overdue. The overflow menu carries the rest (let go /
/// reopen / remove). /// reopen / remove).
class _PlantareTile extends StatelessWidget { class _PlantareTile extends StatelessWidget {
const _PlantareTile(this.view, this.repo); const _PlantareTile(this.view, this.repo, this.service);
final PlantareView view; final PlantareView view;
final VarietyRepository repo; final VarietyRepository repo;
final PlantareService? service;
/// A proposal is "awaiting me" (I still have to accept/decline) when the
/// handshake is `proposed` and MY stub is missing my slot is the debtor one
/// if I'm the one who returns, else the creditor one.
bool get _awaitingMe {
final p = view.plantare;
if (p.remoteState != PlantareRemoteState.proposed) return false;
final mySig = p.direction == PlantareDirection.iReturn
? p.debtorSignature
: p.creditorSignature;
return mySig == null;
}
Future<void> _accept(BuildContext context) async {
final id = view.plantare.pledgeId;
if (service == null || id == null) return;
final t = context.t;
try {
await service!.accept(id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.plantare.acceptedToast)),
);
}
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.plantare.offline)),
);
}
}
}
Future<void> _decline(BuildContext context) async {
final id = view.plantare.pledgeId;
if (service == null || id == null) return;
await service!.decline(id);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -156,12 +244,37 @@ class _PlantareTile extends StatelessWidget {
fontWeight: overdue ? FontWeight.w600 : null, fontWeight: overdue ? FontWeight.w600 : null,
), ),
), ),
if (p.remoteState != null) _RemoteStateBadge(p.remoteState!),
// An incoming proposal awaiting my signature: sign it or turn it down,
// right here.
if (_awaitingMe && service != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
children: [
FilledButton.icon(
key: Key('plantare.accept.${p.id}'),
onPressed: () => _accept(context),
icon: const Icon(Icons.draw_outlined, size: 18),
label: Text(t.plantare.accept),
),
const SizedBox(width: 8),
TextButton(
key: Key('plantare.decline.${p.id}'),
onPressed: () => _decline(context),
child: Text(t.plantare.declineAction),
),
],
),
),
], ],
), ),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (!done) // A returned/mark button only makes sense once the deal is agreed
// (or for a plain local note). Hide it while a proposal is pending.
if (!done && !_awaitingMe)
IconButton( IconButton(
key: Key('plantare.return.${p.id}'), key: Key('plantare.return.${p.id}'),
icon: const Icon(Icons.check_circle_outline), icon: const Icon(Icons.check_circle_outline),

View file

@ -0,0 +1,100 @@
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/db/enums.dart';
import 'package:tane/i18n/strings.g.dart';
import 'package:tane/services/plantare_service.dart';
import 'package:tane/ui/plantare_propose_sheet.dart';
import '../support/test_support.dart';
/// A records-only transport so the sheet can be driven without a relay.
class _CapturingTransport implements PlantareTransport {
final proposed = <PlantarePledge>[];
@override
Future<void> propose(PlantarePledge pledge) async => proposed.add(pledge);
@override
Future<void> accept(PlantarePledge pledge) async {}
@override
Future<void> decline({
required String toPubkey,
required String pledgeId,
String reason = '',
}) async {}
@override
Stream<PlantareEnvelope> incoming() => const Stream.empty();
@override
Future<void> close() async {}
}
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);
await tester.pumpWidget(
TranslationProvider(
child: MaterialApp(
locale: const Locale('en'),
supportedLocales: AppLocaleUtils.supportedLocales,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () => showProposePlantareSheet(
context,
service: service,
peerPubkey: 'b' * 64,
peerName: 'Ana',
),
child: const Text('open'),
),
),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
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();
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.
final row = await repo.plantareByPledgeId(tx.proposed.single.pledgeId);
expect(row!.remoteState, PlantareRemoteState.proposed);
await service.stop();
});
}

View file

@ -63,6 +63,57 @@ void main() {
await disposeTree(tester); await disposeTree(tester);
}); });
testWidgets('a fully-signed bilateral pledge shows the "signed by both" badge',
(tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
final repo = newTestRepository(db);
await repo.createPlantare(
direction: PlantareDirection.owedToMe,
counterparty: 'Ana',
pledgeId: 'p-1',
debtorKey: 'dk',
creditorKey: 'ck',
debtorSignature: 'ds',
creditorSignature: 'cs',
remoteState: PlantareRemoteState.accepted,
);
await tester.pumpWidget(
wrapScreen(repository: repo, child: const PlantaresScreen()),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Signed by both'), findsOneWidget);
// No accept/decline for a closed deal.
expect(find.byKey(const Key('plantare.accept.')), findsNothing);
await disposeTree(tester);
});
testWidgets('a pending proposal shows the "awaiting signature" badge',
(tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
final repo = newTestRepository(db);
await repo.createPlantare(
direction: PlantareDirection.iReturn,
counterparty: 'Ben',
pledgeId: 'p-2',
debtorKey: 'dk',
creditorKey: 'ck',
creditorSignature: 'cs', // proposer (creditor) signed; I (debtor) haven't
remoteState: PlantareRemoteState.proposed,
);
await tester.pumpWidget(
wrapScreen(repository: repo, child: const PlantaresScreen()),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Awaiting signature'), findsOneWidget);
await disposeTree(tester);
});
testWidgets('tapping a linked commitment opens its variety', (tester) async { testWidgets('tapping a linked commitment opens its variety', (tester) async {
final repo = newTestRepository(db); final repo = newTestRepository(db);
final vid = await repo.addQuickVariety(label: 'Judía'); final vid = await repo.addQuickVariety(label: 'Judía');

View file

@ -67,6 +67,8 @@ class FakeSession implements SocialSession {
@override @override
ProfileTransport get profile => throw UnimplementedError(); ProfileTransport get profile => throw UnimplementedError();
@override @override
PlantareTransport get plantares => throw UnimplementedError();
@override
SyncTransport get sync => throw UnimplementedError(); SyncTransport get sync => throw UnimplementedError();
@override @override
Future<void> close() async {} Future<void> close() async {}