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 b607ddde41
commit e78656bc07
11 changed files with 2223 additions and 1459 deletions

View file

@ -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),

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 '../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),