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:
parent
b607ddde41
commit
e78656bc07
11 changed files with 2223 additions and 1459 deletions
|
|
@ -644,7 +644,30 @@
|
|||
"dueByHint": "A gentle reminder, never enforced",
|
||||
"pickDate": "Pick a date",
|
||||
"clearDate": "Clear date",
|
||||
"sectionTitle": "Commitments"
|
||||
"sectionTitle": "Commitments",
|
||||
"propose": "Propose a signed Plantaré",
|
||||
"proposeHelp": "Both of you keep the same promise, signed by both — proof that this seed changed hands and will be grown out and returned.",
|
||||
"proposeTo": "With {name}",
|
||||
"sent": "Proposal sent — waiting for them to sign",
|
||||
"seedLabel": "Which seed?",
|
||||
"seedHint": "The variety this promise is about",
|
||||
"returnKindLabel": "What comes back?",
|
||||
"returnSimilar": "A similar amount of seed",
|
||||
"returnSimilarNote": "open-pollinated · non-GMO · organically grown",
|
||||
"returnWork": "Some hours of work",
|
||||
"returnOther": "Something else",
|
||||
"workHoursLabel": "How many hours?",
|
||||
"proposalsSection": "Waiting for your reply",
|
||||
"incomingFrom": "{name} proposes a Plantaré",
|
||||
"accept": "Accept & sign",
|
||||
"declineAction": "Decline",
|
||||
"declineReasonHint": "Reason (optional)",
|
||||
"acceptedToast": "Signed — you both hold it now",
|
||||
"declinedToast": "Declined",
|
||||
"badgeAwaiting": "Awaiting signature",
|
||||
"badgeSigned": "Signed by both",
|
||||
"badgeDeclined": "Declined",
|
||||
"offline": "You're offline — it'll be sent when you reconnect"
|
||||
},
|
||||
"handover": {
|
||||
"title": "Seeds changed hands",
|
||||
|
|
|
|||
|
|
@ -643,7 +643,30 @@
|
|||
"dueByHint": "Un recordatorio suave, nunca obligatorio",
|
||||
"pickDate": "Elegir fecha",
|
||||
"clearDate": "Quitar fecha",
|
||||
"sectionTitle": "Compromisos"
|
||||
"sectionTitle": "Compromisos",
|
||||
"propose": "Proponer un Plantaré firmado",
|
||||
"proposeHelp": "Los dos guardáis la misma promesa, firmada por ambos: prueba de que esta semilla cambió de manos y se cultivará y devolverá.",
|
||||
"proposeTo": "Con {name}",
|
||||
"sent": "Propuesta enviada — esperando su firma",
|
||||
"seedLabel": "¿Qué semilla?",
|
||||
"seedHint": "La variedad a la que se refiere la promesa",
|
||||
"returnKindLabel": "¿Qué se devuelve?",
|
||||
"returnSimilar": "Una cantidad similar de semilla",
|
||||
"returnSimilarNote": "polinización abierta · no transgénica · cultivada en ecológico",
|
||||
"returnWork": "Unas horas de trabajo",
|
||||
"returnOther": "Otra cosa",
|
||||
"workHoursLabel": "¿Cuántas horas?",
|
||||
"proposalsSection": "Esperando tu respuesta",
|
||||
"incomingFrom": "{name} te propone un Plantaré",
|
||||
"accept": "Aceptar y firmar",
|
||||
"declineAction": "Rechazar",
|
||||
"declineReasonHint": "Motivo (opcional)",
|
||||
"acceptedToast": "Firmado — ahora lo guardáis los dos",
|
||||
"declinedToast": "Rechazado",
|
||||
"badgeAwaiting": "Falta firma",
|
||||
"badgeSigned": "Firmado por ambos",
|
||||
"badgeDeclined": "Rechazado",
|
||||
"offline": "Estás sin conexión — se enviará al reconectar"
|
||||
},
|
||||
"handover": {
|
||||
"title": "Di o recibí semillas",
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// 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
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
|
|
|||
|
|
@ -1809,6 +1809,75 @@ class Translations$plantare$en {
|
|||
|
||||
/// en: '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
|
||||
|
|
@ -3020,6 +3089,31 @@ extension on Translations {
|
|||
'plantare.pickDate' => 'Pick a date',
|
||||
'plantare.clearDate' => 'Clear date',
|
||||
'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.help' => 'A gift, a swap or a sale — note it down, with a return promise or without.',
|
||||
'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.add' => 'Record a sale',
|
||||
'sale.empty' => 'No sales yet. Note here what you sell or buy.',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'sale.iSold' => 'I sold',
|
||||
'sale.iBought' => 'I bought',
|
||||
'sale.direction' => 'Sold or bought?',
|
||||
|
|
|
|||
|
|
@ -950,6 +950,29 @@ class _Translations$plantare$es extends Translations$plantare$en {
|
|||
@override String get pickDate => 'Elegir fecha';
|
||||
@override String get clearDate => 'Quitar fecha';
|
||||
@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
|
||||
|
|
@ -1910,6 +1933,31 @@ extension on TranslationsEs {
|
|||
'plantare.pickDate' => 'Elegir fecha',
|
||||
'plantare.clearDate' => 'Quitar fecha',
|
||||
'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.help' => 'Un regalo, un trueque o una venta: apúntalo, con promesa de devolver semilla o sin ella.',
|
||||
'handover.iGave' => 'Di semillas',
|
||||
|
|
@ -1926,8 +1974,6 @@ extension on TranslationsEs {
|
|||
'sale.add' => 'Registrar venta',
|
||||
'sale.empty' => 'Aún no hay ventas. Anota aquí lo que vendes o compras.',
|
||||
'sale.iSold' => 'Vendí',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'sale.iBought' => 'Compré',
|
||||
'sale.direction' => '¿Vendes o compras?',
|
||||
'sale.counterparty' => '¿Con quién?',
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import '../domain/message_rules.dart';
|
|||
import '../i18n/strings.g.dart';
|
||||
import '../services/message_store.dart';
|
||||
import '../services/onboarding_store.dart';
|
||||
import '../services/plantare_service.dart';
|
||||
import '../services/profile_cache.dart';
|
||||
import '../services/profile_store.dart';
|
||||
import '../services/social_connection.dart';
|
||||
|
|
@ -23,6 +24,7 @@ import '../state/peer_rating_cubit.dart';
|
|||
import '../state/trust_cubit.dart';
|
||||
import 'market_gate.dart';
|
||||
import 'peer_avatar.dart';
|
||||
import 'plantare_propose_sheet.dart';
|
||||
import 'rating_sheet.dart';
|
||||
import 'report_sheet.dart';
|
||||
import 'theme.dart';
|
||||
|
|
@ -205,6 +207,23 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
|
||||
/// Reports this peer (a standard report event to the community servers);
|
||||
/// 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 {
|
||||
final outcome = await showReportSheet(
|
||||
context,
|
||||
|
|
@ -305,10 +324,16 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
PopupMenuButton<String>(
|
||||
key: const Key('chat.menu'),
|
||||
onSelected: (value) {
|
||||
if (value == 'plantare') _proposePlantare();
|
||||
if (value == 'report') _reportPeer();
|
||||
if (value == 'block') _blockPeer();
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
if (getIt.isRegistered<PlantareService>())
|
||||
PopupMenuItem(
|
||||
value: 'plantare',
|
||||
child: Text(t.plantare.propose),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'report',
|
||||
child: Text(t.report.person),
|
||||
|
|
|
|||
289
apps/app_seeds/lib/ui/plantare_propose_sheet.dart
Normal file
289
apps/app_seeds/lib/ui/plantare_propose_sheet.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,9 @@ import 'package:go_router/go_router.dart';
|
|||
|
||||
import '../data/variety_repository.dart';
|
||||
import '../db/enums.dart';
|
||||
import '../di/injector.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/plantare_service.dart';
|
||||
import 'plantare_sheet.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
|
|
@ -18,6 +20,9 @@ class PlantaresScreen extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
final repo = context.read<VarietyRepository>();
|
||||
final service = getIt.isRegistered<PlantareService>()
|
||||
? getIt<PlantareService>()
|
||||
: null;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(t.plantare.title)),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
|
|
@ -53,9 +58,9 @@ class PlantaresScreen extends StatelessWidget {
|
|||
padding: const EdgeInsets.only(bottom: 88),
|
||||
children: [
|
||||
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),
|
||||
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 {
|
||||
const _SectionHeader(this.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 /
|
||||
/// reopen / remove).
|
||||
class _PlantareTile extends StatelessWidget {
|
||||
const _PlantareTile(this.view, this.repo);
|
||||
const _PlantareTile(this.view, this.repo, this.service);
|
||||
|
||||
final PlantareView view;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -156,12 +244,37 @@ class _PlantareTile extends StatelessWidget {
|
|||
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(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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(
|
||||
key: Key('plantare.return.${p.id}'),
|
||||
icon: const Icon(Icons.check_circle_outline),
|
||||
|
|
|
|||
100
apps/app_seeds/test/ui/plantare_propose_sheet_test.dart
Normal file
100
apps/app_seeds/test/ui/plantare_propose_sheet_test.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
|
|
@ -63,6 +63,57 @@ void main() {
|
|||
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 {
|
||||
final repo = newTestRepository(db);
|
||||
final vid = await repo.addQuickVariety(label: 'Judía');
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ class FakeSession implements SocialSession {
|
|||
@override
|
||||
ProfileTransport get profile => throw UnimplementedError();
|
||||
@override
|
||||
PlantareTransport get plantares => throw UnimplementedError();
|
||||
@override
|
||||
SyncTransport get sync => throw UnimplementedError();
|
||||
@override
|
||||
Future<void> close() async {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue