tane/apps/app_seeds/lib/ui/plantares_screen.dart
vjrj e78656bc07 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.
2026-07-14 11:23:09 +02:00

312 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
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';
/// The Plantares screen — your reproduction commitments (data-model §2.7).
/// Lists what's open (still to grow out & return) and what's done, and lets you
/// add or settle one. Local-first; reads the encrypted inventory DB.
class PlantaresScreen extends StatelessWidget {
const PlantaresScreen({super.key});
@override
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(
key: const Key('plantares.add'),
onPressed: () => showPlantareSheet(context, repository: repo),
icon: const Icon(Icons.add),
label: Text(t.plantare.add),
),
body: StreamBuilder<List<PlantareView>>(
stream: repo.watchPlantareViews(),
builder: (context, snapshot) {
final all = snapshot.data;
if (all == null) {
return const Center(child: CircularProgressIndicator());
}
if (all.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(
t.plantare.empty,
textAlign: TextAlign.center,
style: const TextStyle(color: seedMuted, fontSize: 15),
),
),
);
}
final open =
all.where((v) => v.plantare.status == PlantareStatus.open);
final settled =
all.where((v) => v.plantare.status != PlantareStatus.open);
return ListView(
padding: const EdgeInsets.only(bottom: 88),
children: [
if (open.isNotEmpty) _SectionHeader(t.plantare.openSection),
for (final v in open) _PlantareTile(v, repo, service),
if (settled.isNotEmpty) _SectionHeader(t.plantare.settledSection),
for (final v in settled) _PlantareTile(v, repo, service),
],
);
},
),
);
}
}
/// 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;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
label.toUpperCase(),
style: const TextStyle(
color: seedMuted,
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
);
}
/// One commitment row. An open one shows a big "mark returned"; a settled one
/// reads muted. It names the seed and (when linked) taps through to its detail,
/// shows when it was made and — for a promise with a return-by date — whether
/// 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, 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) {
final t = context.t;
final p = view.plantare;
final varietyLabel = view.varietyLabel;
final done = p.status != PlantareStatus.open;
final iReturn = p.direction == PlantareDirection.iReturn;
final title = (p.owedDescription?.trim().isNotEmpty ?? false)
? p.owedDescription!.trim()
: (iReturn ? t.plantare.iReturn : t.plantare.owedToMe);
final locals = MaterialLocalizations.of(context);
final made = locals
.formatShortDate(DateTime.fromMillisecondsSinceEpoch(p.madeOn));
final subtitleParts = <String>[
iReturn ? t.plantare.iReturn : t.plantare.owedToMe,
?varietyLabel,
if (p.counterparty?.trim().isNotEmpty ?? false) p.counterparty!.trim(),
made,
if (done)
p.status == PlantareStatus.returned
? t.plantare.statusReturned
: t.plantare.statusForgiven,
];
// A return-by date is a gentle nudge, not a debt: only an open, past-due
// promise reads as overdue.
final overdue = !done &&
p.dueBy != null &&
p.dueBy! < DateTime.now().millisecondsSinceEpoch;
final returnBy = p.dueBy == null
? null
: t.plantare.returnBy(
date: locals.formatShortDate(
DateTime.fromMillisecondsSinceEpoch(p.dueBy!),
),
);
final linkedId = varietyLabel == null ? null : p.varietyId;
return ListTile(
onTap: linkedId == null ? null : () => context.push('/variety/$linkedId'),
leading: Icon(
iReturn ? Icons.volunteer_activism_outlined : Icons.eco_outlined,
color: done ? seedMuted : seedGreen,
),
title: Text(
title,
style: TextStyle(
decoration: done ? TextDecoration.lineThrough : null,
color: done ? seedMuted : seedOnSurface,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(subtitleParts.join(' · '),
maxLines: 2, overflow: TextOverflow.ellipsis),
if (returnBy != null)
Text(
overdue ? '$returnBy · ${t.plantare.overdue}' : returnBy,
style: TextStyle(
color: overdue ? seedWarning : seedMuted,
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: [
// 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),
tooltip: t.plantare.markReturned,
color: seedGreen,
onPressed: () =>
repo.setPlantareStatus(p.id, PlantareStatus.returned),
),
PopupMenuButton<String>(
key: Key('plantare.menu.${p.id}'),
onSelected: (v) async {
switch (v) {
case 'forgiven':
await repo.setPlantareStatus(p.id, PlantareStatus.forgiven);
case 'reopen':
await repo.setPlantareStatus(p.id, PlantareStatus.open);
case 'delete':
await repo.deletePlantare(p.id);
}
},
itemBuilder: (context) => [
if (!done)
PopupMenuItem(
value: 'forgiven', child: Text(t.plantare.markForgiven)),
if (done)
PopupMenuItem(
value: 'reopen', child: Text(t.plantare.reopen)),
PopupMenuItem(value: 'delete', child: Text(t.plantare.delete)),
],
),
],
),
);
}
}