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
2e25e34d11
commit
f9c3dc018d
11 changed files with 2223 additions and 1459 deletions
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue