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.
312 lines
11 KiB
Dart
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)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|