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
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue