From adf396d49d9fb56a6c6f22caf1f09a8a88a4b203 Mon Sep 17 00:00:00 2001 From: vjrj Date: Mon, 13 Jul 2026 18:06:33 +0200 Subject: [PATCH] feat(plantare): surface product link + dates; spec the bilateral record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Plantaré screen stored madeOn/dueBy/varietyId but showed none of them, so it felt half-built. Surface what's already there (no schema change): - watchPlantareViews() joins each commitment with its variety label - tile shows the made-on date, a "Return by {date}" line (amber + "overdue" when an open promise is past due, via new seedWarning colour), the variety name, and taps through to /variety/:id - add sheet gains an optional return-by date picker (createPlantare already took dueBy) - variety detail shows a read-only commitments section (hidden when empty, no add button — honours the single hand-over door) - i18n en/es/pt/ast; tests for the join, dueBy round-trip, tile, and section Also specs the deferred two-sided record in docs/design/plantare-bilateral.md (Nostr pubkey counterparty, PlantareTransport propose->accept->counter-sign, deferred key/signature/movement columns, provenance DAG, reputation anchor), with a faithful transcription of the paper Plantaré v0.4 (BAH-Semillero 2009, CC-BY-SA). Cross-linked from data-model §2.7 and sharing-model §6. --- .../lib/data/variety_repository.dart | 28 +++ apps/app_seeds/lib/i18n/ast.i18n.json | 9 +- apps/app_seeds/lib/i18n/en.i18n.json | 9 +- apps/app_seeds/lib/i18n/es.i18n.json | 9 +- apps/app_seeds/lib/i18n/pt.i18n.json | 9 +- apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_ast.g.dart | 18 +- apps/app_seeds/lib/i18n/strings_en.g.dart | 32 +++- apps/app_seeds/lib/i18n/strings_es.g.dart | 18 +- apps/app_seeds/lib/i18n/strings_pt.g.dart | 18 +- apps/app_seeds/lib/ui/plantare_sheet.dart | 50 ++++++ apps/app_seeds/lib/ui/plantares_screen.dart | 63 +++++-- apps/app_seeds/lib/ui/theme.dart | 1 + .../lib/ui/variety_detail_screen.dart | 101 +++++++++++ apps/app_seeds/test/data/plantare_test.dart | 28 +++ .../test/ui/plantares_screen_test.dart | 114 +++++++++++++ .../test/ui/theme_contrast_test.dart | 5 + .../test/ui/variety_detail_screen_test.dart | 31 ++++ docs/design/data-model.md | 5 + docs/design/plantare-bilateral.md | 160 ++++++++++++++++++ docs/design/sharing-model.md | 2 +- 21 files changed, 687 insertions(+), 27 deletions(-) create mode 100644 apps/app_seeds/test/ui/plantares_screen_test.dart create mode 100644 docs/design/plantare-bilateral.md diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index 69dfea0..b099c40 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -11,6 +11,10 @@ import 'export_import/import_reconciler.dart'; import 'export_import/inventory_csv_codec.dart'; import 'export_import/inventory_snapshot.dart'; +/// A commitment plus the label of the variety it's linked to (if any), for the +/// Plantares list — lets a tile name the seed and link back to its detail. +typedef PlantareView = ({Plantare plantare, String? varietyLabel}); + /// A lightweight row for the inventory list (only what the list renders). class VarietyListItem extends Equatable { const VarietyListItem({ @@ -1893,6 +1897,30 @@ class VarietyRepository { ..orderBy([(p) => OrderingTerm.desc(p.madeOn)])) .watch(); + /// Live commitments joined with the label of the variety each is linked to + /// (null when unlinked or the variety is gone), newest first — so the list + /// can name the seed and offer a tap-through to its detail. + Stream> watchPlantareViews() { + final query = _db.select(_db.plantares).join([ + leftOuterJoin( + _db.varieties, + _db.varieties.id.equalsExp(_db.plantares.varietyId), + ), + ]) + ..where(_db.plantares.isDeleted.equals(false)) + ..orderBy([OrderingTerm.desc(_db.plantares.madeOn)]); + return query.watch().map((rows) => [ + for (final row in rows) + ( + plantare: row.readTable(_db.plantares), + varietyLabel: switch (row.readTableOrNull(_db.varieties)) { + final v? when !v.isDeleted => v.label, + _ => null, + }, + ), + ]); + } + /// The commitments recorded against one variety. Stream> watchPlantaresForVariety(String varietyId) => (_db.select(_db.plantares) diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index 48eff43..87e871b 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -631,7 +631,14 @@ "statusForgiven": "Saldáu", "openSection": "Pendientes", "settledSection": "Fechos", - "removeConfirm": "¿Quitar esti compromisu?" + "removeConfirm": "¿Quitar esti compromisu?", + "returnBy": "Devolver enantes del {date}", + "overdue": "vencíu", + "dueByLabel": "Devolver enantes de (opcional)", + "dueByHint": "Un recordatoriu suave, nunca obligatoriu", + "pickDate": "Escoyer fecha", + "clearDate": "Quitar fecha", + "sectionTitle": "Compromisos" }, "handover": { "title": "Di o recibí semientes", diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 83bc904..809a794 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -634,7 +634,14 @@ "statusForgiven": "Settled", "openSection": "Open", "settledSection": "Done", - "removeConfirm": "Remove this commitment?" + "removeConfirm": "Remove this commitment?", + "returnBy": "Return by {date}", + "overdue": "overdue", + "dueByLabel": "Return by (optional)", + "dueByHint": "A gentle reminder, never enforced", + "pickDate": "Pick a date", + "clearDate": "Clear date", + "sectionTitle": "Commitments" }, "handover": { "title": "Seeds changed hands", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index ad94c58..b755a61 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -633,7 +633,14 @@ "statusForgiven": "Saldado", "openSection": "Pendientes", "settledSection": "Hechos", - "removeConfirm": "¿Quitar este compromiso?" + "removeConfirm": "¿Quitar este compromiso?", + "returnBy": "Devolver antes del {date}", + "overdue": "vencido", + "dueByLabel": "Devolver antes de (opcional)", + "dueByHint": "Un recordatorio suave, nunca obligatorio", + "pickDate": "Elegir fecha", + "clearDate": "Quitar fecha", + "sectionTitle": "Compromisos" }, "handover": { "title": "Di o recibí semillas", diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index e5fd994..546ce99 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -630,7 +630,14 @@ "statusForgiven": "Saldado", "openSection": "Pendentes", "settledSection": "Feitos", - "removeConfirm": "Remover este compromisso?" + "removeConfirm": "Remover este compromisso?", + "returnBy": "Devolver até {date}", + "overdue": "vencido", + "dueByLabel": "Devolver até (opcional)", + "dueByHint": "Um lembrete gentil, nunca imposto", + "pickDate": "Escolher data", + "clearDate": "Limpar data", + "sectionTitle": "Compromissos" }, "handover": { "title": "Dei ou recebi sementes", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 56c19c9..02498ab 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 4 -/// Strings: 2208 (552 per locale) +/// Strings: 2236 (559 per locale) /// -/// Built on 2026-07-13 at 06:24 UTC +/// Built on 2026-07-13 at 15:47 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index c0d98b9..ba63935 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -938,6 +938,13 @@ class _Translations$plantare$ast extends Translations$plantare$en { @override String get openSection => 'Pendientes'; @override String get settledSection => 'Fechos'; @override String get removeConfirm => '¿Quitar esti compromisu?'; + @override String returnBy({required Object date}) => 'Devolver enantes del ${date}'; + @override String get overdue => 'vencíu'; + @override String get dueByLabel => 'Devolver enantes de (opcional)'; + @override String get dueByHint => 'Un recordatoriu suave, nunca obligatoriu'; + @override String get pickDate => 'Escoyer fecha'; + @override String get clearDate => 'Quitar fecha'; + @override String get sectionTitle => 'Compromisos'; } // Path: handover @@ -1886,6 +1893,13 @@ extension on TranslationsAst { 'plantare.openSection' => 'Pendientes', 'plantare.settledSection' => 'Fechos', 'plantare.removeConfirm' => '¿Quitar esti compromisu?', + 'plantare.returnBy' => ({required Object date}) => 'Devolver enantes del ${date}', + 'plantare.overdue' => 'vencíu', + 'plantare.dueByLabel' => 'Devolver enantes de (opcional)', + 'plantare.dueByHint' => 'Un recordatoriu suave, nunca obligatoriu', + 'plantare.pickDate' => 'Escoyer fecha', + 'plantare.clearDate' => 'Quitar fecha', + 'plantare.sectionTitle' => 'Compromisos', 'handover.title' => 'Di o recibí semientes', 'handover.help' => 'Un regalu, un truecu o una venta: apúntalo, con promesa de devolver semiente o ensin ella.', 'handover.iGave' => 'Di semientes', @@ -1907,6 +1921,8 @@ extension on TranslationsAst { 'sale.counterparty' => '¿Con quién?', 'sale.counterpartyHint' => 'Una persona o un coleutivu (opcional)', 'sale.amount' => 'Importe', + _ => null, + } ?? switch (path) { 'sale.currency' => 'Moneda', 'sale.currencyHint' => '€, Ğ1, hores… (opcional)', 'sale.hours' => 'hores', @@ -1914,8 +1930,6 @@ extension on TranslationsAst { 'sale.save' => 'Guardar', 'sale.delete' => 'Quitar', 'sale.removeConfirm' => '¿Quitar esta venta?', - _ => null, - } ?? switch (path) { 'legal.title' => 'Privacidá y normes', 'legal.subtitle' => 'La to privacidá, les normes del mercáu y la llegalidá de la simiente', 'legal.privacyTitle' => 'La to privacidá', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index dc75842..c7c0085 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -1779,6 +1779,27 @@ class Translations$plantare$en { /// en: 'Remove this commitment?' String get removeConfirm => 'Remove this commitment?'; + + /// en: 'Return by {date}' + String returnBy({required Object date}) => 'Return by ${date}'; + + /// en: 'overdue' + String get overdue => 'overdue'; + + /// en: 'Return by (optional)' + String get dueByLabel => 'Return by (optional)'; + + /// en: 'A gentle reminder, never enforced' + String get dueByHint => 'A gentle reminder, never enforced'; + + /// en: 'Pick a date' + String get pickDate => 'Pick a date'; + + /// en: 'Clear date' + String get clearDate => 'Clear date'; + + /// en: 'Commitments' + String get sectionTitle => 'Commitments'; } // Path: handover @@ -2980,6 +3001,13 @@ extension on Translations { 'plantare.openSection' => 'Open', 'plantare.settledSection' => 'Done', 'plantare.removeConfirm' => 'Remove this commitment?', + 'plantare.returnBy' => ({required Object date}) => 'Return by ${date}', + 'plantare.overdue' => 'overdue', + 'plantare.dueByLabel' => 'Return by (optional)', + 'plantare.dueByHint' => 'A gentle reminder, never enforced', + 'plantare.pickDate' => 'Pick a date', + 'plantare.clearDate' => 'Clear date', + 'plantare.sectionTitle' => 'Commitments', 'handover.title' => 'Seeds changed hands', 'handover.help' => 'A gift, a swap or a sale — note it down, with a return promise or without.', 'handover.iGave' => 'I gave seeds', @@ -2998,6 +3026,8 @@ extension on Translations { 'sale.iSold' => 'I sold', 'sale.iBought' => 'I bought', 'sale.direction' => 'Sold or bought?', + _ => null, + } ?? switch (path) { 'sale.counterparty' => 'With whom?', 'sale.counterpartyHint' => 'A person or a collective (optional)', 'sale.amount' => 'Amount', @@ -3005,8 +3035,6 @@ extension on Translations { 'sale.currencyHint' => '€, Ğ1, hours… (optional)', 'sale.hours' => 'hours', 'sale.note' => 'Note (optional)', - _ => null, - } ?? switch (path) { 'sale.save' => 'Save', 'sale.delete' => 'Remove', 'sale.removeConfirm' => 'Remove this sale?', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index e09b452..1ed4201 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -940,6 +940,13 @@ class _Translations$plantare$es extends Translations$plantare$en { @override String get openSection => 'Pendientes'; @override String get settledSection => 'Hechos'; @override String get removeConfirm => '¿Quitar este compromiso?'; + @override String returnBy({required Object date}) => 'Devolver antes del ${date}'; + @override String get overdue => 'vencido'; + @override String get dueByLabel => 'Devolver antes de (opcional)'; + @override String get dueByHint => 'Un recordatorio suave, nunca obligatorio'; + @override String get pickDate => 'Elegir fecha'; + @override String get clearDate => 'Quitar fecha'; + @override String get sectionTitle => 'Compromisos'; } // Path: handover @@ -1890,6 +1897,13 @@ extension on TranslationsEs { 'plantare.openSection' => 'Pendientes', 'plantare.settledSection' => 'Hechos', 'plantare.removeConfirm' => '¿Quitar este compromiso?', + 'plantare.returnBy' => ({required Object date}) => 'Devolver antes del ${date}', + 'plantare.overdue' => 'vencido', + 'plantare.dueByLabel' => 'Devolver antes de (opcional)', + 'plantare.dueByHint' => 'Un recordatorio suave, nunca obligatorio', + 'plantare.pickDate' => 'Elegir fecha', + 'plantare.clearDate' => 'Quitar fecha', + 'plantare.sectionTitle' => 'Compromisos', 'handover.title' => 'Di o recibí semillas', 'handover.help' => 'Un regalo, un trueque o una venta: apúntalo, con promesa de devolver semilla o sin ella.', 'handover.iGave' => 'Di semillas', @@ -1909,6 +1923,8 @@ extension on TranslationsEs { 'sale.iBought' => 'Compré', 'sale.direction' => '¿Vendes o compras?', 'sale.counterparty' => '¿Con quién?', + _ => null, + } ?? switch (path) { 'sale.counterpartyHint' => 'Una persona o un colectivo (opcional)', 'sale.amount' => 'Importe', 'sale.currency' => 'Moneda', @@ -1916,8 +1932,6 @@ extension on TranslationsEs { 'sale.hours' => 'horas', 'sale.note' => 'Nota (opcional)', 'sale.save' => 'Guardar', - _ => null, - } ?? switch (path) { 'sale.delete' => 'Quitar', 'sale.removeConfirm' => '¿Quitar esta venta?', 'legal.title' => 'Privacidad y normas', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 6d74351..50d6bcb 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -937,6 +937,13 @@ class _Translations$plantare$pt extends Translations$plantare$en { @override String get openSection => 'Pendentes'; @override String get settledSection => 'Feitos'; @override String get removeConfirm => 'Remover este compromisso?'; + @override String returnBy({required Object date}) => 'Devolver até ${date}'; + @override String get overdue => 'vencido'; + @override String get dueByLabel => 'Devolver até (opcional)'; + @override String get dueByHint => 'Um lembrete gentil, nunca imposto'; + @override String get pickDate => 'Escolher data'; + @override String get clearDate => 'Limpar data'; + @override String get sectionTitle => 'Compromissos'; } // Path: handover @@ -1884,6 +1891,13 @@ extension on TranslationsPt { 'plantare.openSection' => 'Pendentes', 'plantare.settledSection' => 'Feitos', 'plantare.removeConfirm' => 'Remover este compromisso?', + 'plantare.returnBy' => ({required Object date}) => 'Devolver até ${date}', + 'plantare.overdue' => 'vencido', + 'plantare.dueByLabel' => 'Devolver até (opcional)', + 'plantare.dueByHint' => 'Um lembrete gentil, nunca imposto', + 'plantare.pickDate' => 'Escolher data', + 'plantare.clearDate' => 'Limpar data', + 'plantare.sectionTitle' => 'Compromissos', 'handover.title' => 'Dei ou recebi sementes', 'handover.help' => 'Uma oferta, uma troca ou uma venda: anota-o, com promessa de devolver semente ou sem ela.', 'handover.iGave' => 'Dei sementes', @@ -1906,6 +1920,8 @@ extension on TranslationsPt { 'sale.counterpartyHint' => 'Uma pessoa ou um coletivo (opcional)', 'sale.amount' => 'Montante', 'sale.currency' => 'Moeda', + _ => null, + } ?? switch (path) { 'sale.currencyHint' => '€, Ğ1, horas… (opcional)', 'sale.hours' => 'horas', 'sale.note' => 'Nota (opcional)', @@ -1913,8 +1929,6 @@ extension on TranslationsPt { 'sale.delete' => 'Remover', 'sale.removeConfirm' => 'Remover esta venda?', 'legal.title' => 'Privacidade e regras', - _ => null, - } ?? switch (path) { 'legal.subtitle' => 'A tua privacidade, as regras do mercado e a legalidade das sementes', 'legal.privacyTitle' => 'A tua privacidade', 'legal.privacyBody' => 'O Tane funciona sem conta, e tudo o que registas fica no teu dispositivo, cifrado. Não há publicidade, nem rastreadores, nem servidor do Tane.\n\nNada é partilhado a não ser que tu queiras: quando publicas uma oferta ou o teu perfil, ou envias uma mensagem, viaja por servidores comunitários. As ofertas levam apenas uma zona aproximada — nunca a tua morada.\n\nO que publicas é público, e podem ficar cópias mesmo depois de o retirares — por isso partilha com cuidado.', diff --git a/apps/app_seeds/lib/ui/plantare_sheet.dart b/apps/app_seeds/lib/ui/plantare_sheet.dart index ef8d600..7f88be6 100644 --- a/apps/app_seeds/lib/ui/plantare_sheet.dart +++ b/apps/app_seeds/lib/ui/plantare_sheet.dart @@ -34,6 +34,7 @@ class _PlantareSheetState extends State<_PlantareSheet> { final _counterparty = TextEditingController(); final _owed = TextEditingController(); final _note = TextEditingController(); + DateTime? _dueBy; bool _saving = false; @override @@ -46,6 +47,17 @@ class _PlantareSheetState extends State<_PlantareSheet> { String? _nullIfBlank(String s) => s.trim().isEmpty ? null : s.trim(); + Future _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 _save() async { setState(() => _saving = true); await widget.repository.createPlantare( @@ -53,6 +65,7 @@ class _PlantareSheetState extends State<_PlantareSheet> { varietyId: widget.varietyId, counterparty: _nullIfBlank(_counterparty.text), owedDescription: _nullIfBlank(_owed.text), + dueBy: _dueBy?.millisecondsSinceEpoch, note: _nullIfBlank(_note.text), ); if (mounted) Navigator.of(context).pop(); @@ -121,6 +134,43 @@ class _PlantareSheetState extends State<_PlantareSheet> { ), ), const SizedBox(height: 12), + // Optional return-by date — a gentle reminder, never enforced. + 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( + key: const Key('plantare.dueBy.clear'), + icon: const Icon(Icons.clear, size: 20), + tooltip: t.plantare.clearDate, + onPressed: () => setState(() => _dueBy = null), + ), + IconButton( + key: const Key('plantare.dueBy.pick'), + icon: const Icon(Icons.event_outlined), + tooltip: t.plantare.pickDate, + onPressed: _pickDueBy, + ), + ], + ), + ), + const SizedBox(height: 12), TextField( key: const Key('plantare.note'), controller: _note, diff --git a/apps/app_seeds/lib/ui/plantares_screen.dart b/apps/app_seeds/lib/ui/plantares_screen.dart index d2bb348..7a16e22 100644 --- a/apps/app_seeds/lib/ui/plantares_screen.dart +++ b/apps/app_seeds/lib/ui/plantares_screen.dart @@ -1,8 +1,8 @@ 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/database.dart'; import '../db/enums.dart'; import '../i18n/strings.g.dart'; import 'plantare_sheet.dart'; @@ -26,8 +26,8 @@ class PlantaresScreen extends StatelessWidget { icon: const Icon(Icons.add), label: Text(t.plantare.add), ), - body: StreamBuilder>( - stream: repo.watchPlantares(), + body: StreamBuilder>( + stream: repo.watchPlantareViews(), builder: (context, snapshot) { final all = snapshot.data; if (all == null) { @@ -45,15 +45,17 @@ class PlantaresScreen extends StatelessWidget { ), ); } - final open = all.where((p) => p.status == PlantareStatus.open); - final settled = all.where((p) => p.status != PlantareStatus.open); + 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 p in open) _PlantareTile(p, repo), + for (final v in open) _PlantareTile(v, repo), if (settled.isNotEmpty) _SectionHeader(t.plantare.settledSection), - for (final p in settled) _PlantareTile(p, repo), + for (final v in settled) _PlantareTile(v, repo), ], ); }, @@ -82,30 +84,54 @@ class _SectionHeader extends StatelessWidget { } /// One commitment row. An open one shows a big "mark returned"; a settled one -/// reads muted. The overflow menu carries the rest (let go / reopen / remove). +/// 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.p, this.repo); + const _PlantareTile(this.view, this.repo); - final Plantare p; + final PlantareView view; final VarietyRepository repo; @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 = [ 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, @@ -117,8 +143,21 @@ class _PlantareTile extends StatelessWidget { color: done ? seedMuted : seedOnSurface, ), ), - subtitle: Text(subtitleParts.join(' · '), - maxLines: 2, overflow: TextOverflow.ellipsis), + 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, + ), + ), + ], + ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/apps/app_seeds/lib/ui/theme.dart b/apps/app_seeds/lib/ui/theme.dart index 27d649a..60b372c 100644 --- a/apps/app_seeds/lib/ui/theme.dart +++ b/apps/app_seeds/lib/ui/theme.dart @@ -26,6 +26,7 @@ const seedOnSurfaceVariant = Color(0xFF5C6B52); // scientific names, secondary const seedMuted = Color(0xFF617057); const seedRating = Color(0xFFE8A200); // stars const seedFavorite = Color(0xFFE53935); // saved/favorite heart (pops on green) +const seedWarning = Color(0xFFA84600); // overdue nudges (AA on white & canvas) ThemeData buildTaneTheme() { final scheme = diff --git a/apps/app_seeds/lib/ui/variety_detail_screen.dart b/apps/app_seeds/lib/ui/variety_detail_screen.dart index 1b1c270..03e36bb 100644 --- a/apps/app_seeds/lib/ui/variety_detail_screen.dart +++ b/apps/app_seeds/lib/ui/variety_detail_screen.dart @@ -9,6 +9,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../data/seed_saving_catalog.dart'; import '../data/species_repository.dart'; import '../data/variety_repository.dart'; +import '../db/database.dart'; import '../db/enums.dart'; import '../domain/crop_calendar.dart'; import '../domain/seed_saving.dart'; @@ -235,6 +236,106 @@ class _DetailView extends StatelessWidget { lot: lot, viabilityYears: detail.viabilityYears, ), + _PlantaresSection(varietyId: detail.id), + ], + ), + ); + } +} + +/// The reproduction commitments (Plantarés) recorded against this variety — so +/// a promise to grow it out and pass it on lives next to the seed it's about, +/// not only on the separate Plantares screen. Read-only: new ones are recorded +/// through the single hand-over door (or the Plantares screen), and the section +/// stays hidden until there's something to show, like the other detail blocks. +class _PlantaresSection extends StatelessWidget { + const _PlantaresSection({required this.varietyId}); + + final String varietyId; + + @override + Widget build(BuildContext context) { + final t = context.t; + final repo = context.read(); + return StreamBuilder>( + stream: repo.watchPlantaresForVariety(varietyId), + builder: (context, snapshot) { + final list = snapshot.data ?? const []; + if (list.isEmpty) return const SizedBox.shrink(); + return Column( + key: const Key('detail.plantares'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + _SectionTitle(t.plantare.sectionTitle), + for (final p in list) _PlantareLine(p), + ], + ); + }, + ); + } +} + +/// A compact, read-only commitment row for the variety detail: direction, +/// counterparty and when — with a return-by line (amber when overdue). +class _PlantareLine extends StatelessWidget { + const _PlantareLine(this.p); + + final Plantare p; + + @override + Widget build(BuildContext context) { + final t = context.t; + final locals = MaterialLocalizations.of(context); + 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 parts = [ + iReturn ? t.plantare.iReturn : t.plantare.owedToMe, + if (p.counterparty?.trim().isNotEmpty ?? false) p.counterparty!.trim(), + locals.formatShortDate(DateTime.fromMillisecondsSinceEpoch(p.madeOn)), + if (done) + p.status == PlantareStatus.returned + ? t.plantare.statusReturned + : t.plantare.statusForgiven, + ]; + 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!), + ), + ); + return ListTile( + contentPadding: EdgeInsets.zero, + 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(parts.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, + ), + ), ], ), ); diff --git a/apps/app_seeds/test/data/plantare_test.dart b/apps/app_seeds/test/data/plantare_test.dart index 45918c5..8916758 100644 --- a/apps/app_seeds/test/data/plantare_test.dart +++ b/apps/app_seeds/test/data/plantare_test.dart @@ -61,6 +61,34 @@ void main() { expect(await repo.watchPlantares().first, isEmpty); }); + test('watchPlantareViews joins the linked variety label (null when unlinked)', + () async { + final repo = newTestRepository(db); + final vid = await repo.addQuickVariety(label: 'Tomate rosa'); + await repo.createPlantare( + direction: PlantareDirection.iReturn, + varietyId: vid, + ); + await repo.createPlantare(direction: PlantareDirection.owedToMe); + + final views = await repo.watchPlantareViews().first; + expect(views, hasLength(2)); + final linked = views.firstWhere((v) => v.plantare.varietyId == vid); + expect(linked.varietyLabel, 'Tomate rosa'); + final unlinked = views.firstWhere((v) => v.plantare.varietyId == null); + expect(unlinked.varietyLabel, isNull); + }); + + test('a return-by date round-trips through create', () async { + final repo = newTestRepository(db); + final due = DateTime(2027, 3, 1).millisecondsSinceEpoch; + await repo.createPlantare( + direction: PlantareDirection.iReturn, + dueBy: due, + ); + expect((await repo.watchPlantares().first).single.dueBy, due); + }); + test('commitments survive a backup round-trip (in exportInventory/import)', () async { final repoA = newTestRepository(db); diff --git a/apps/app_seeds/test/ui/plantares_screen_test.dart b/apps/app_seeds/test/ui/plantares_screen_test.dart new file mode 100644 index 0000000..2034afe --- /dev/null +++ b/apps/app_seeds/test/ui/plantares_screen_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tane/app.dart' show materialLocaleFor; +import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; +import 'package:tane/i18n/strings.g.dart'; +import 'package:tane/ui/plantares_screen.dart'; + +import '../support/test_support.dart'; + +/// The Plantares screen used to hide the data it stored: no date, no link to +/// the seed. These pin the surfaced version — a named, dated, tappable +/// commitment with an overdue nudge. +void main() { + late AppDatabase db; + setUp(() => db = newTestDatabase()); + tearDown(() => db.close()); + + testWidgets('a tile names the linked seed and flags an overdue return-by', + (tester) async { + final repo = newTestRepository(db); + final vid = await repo.addQuickVariety(label: 'Tomate rosa'); + await repo.createPlantare( + direction: PlantareDirection.iReturn, + varietyId: vid, + counterparty: 'Ana', + dueBy: DateTime(2000, 1, 1).millisecondsSinceEpoch, // long past → overdue + ); + + await tester.pumpWidget( + wrapScreen(repository: repo, child: const PlantaresScreen()), + ); + await tester.pump(); // let the Drift stream emit + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.textContaining('Tomate rosa'), findsOneWidget); + expect(find.textContaining('Ana'), findsOneWidget); + expect(find.textContaining('Return by'), findsOneWidget); + expect(find.textContaining('overdue'), findsOneWidget); + await disposeTree(tester); + }); + + testWidgets('an open commitment with no due date shows no overdue flag', + (tester) async { + final repo = newTestRepository(db); + final vid = await repo.addQuickVariety(label: 'Maíz'); + await repo.createPlantare( + direction: PlantareDirection.owedToMe, + varietyId: vid, + ); + + await tester.pumpWidget( + wrapScreen(repository: repo, child: const PlantaresScreen()), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.textContaining('overdue'), findsNothing); + expect(find.textContaining('Return by'), findsNothing); + await disposeTree(tester); + }); + + testWidgets('tapping a linked commitment opens its variety', (tester) async { + final repo = newTestRepository(db); + final vid = await repo.addQuickVariety(label: 'Judía'); + await repo.createPlantare( + direction: PlantareDirection.iReturn, + varietyId: vid, + ); + + LocaleSettings.setLocaleSync(AppLocale.en); + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const PlantaresScreen(), + ), + GoRoute( + path: '/variety/:id', + builder: (_, state) => + Scaffold(body: Text('variety ${state.pathParameters['id']}')), + ), + ], + ); + await tester.pumpWidget( + TranslationProvider( + child: RepositoryProvider.value( + value: repo, + child: MaterialApp.router( + routerConfig: router, + locale: materialLocaleFor(AppLocale.en.flutterLocale), + supportedLocales: AppLocaleUtils.supportedLocales, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + await tester.tap(find.byType(ListTile).first); + await tester.pumpAndSettle(); + + expect(find.text('variety $vid'), findsOneWidget); + await disposeTree(tester); + }); +} diff --git a/apps/app_seeds/test/ui/theme_contrast_test.dart b/apps/app_seeds/test/ui/theme_contrast_test.dart index 1b514ac..682f3ef 100644 --- a/apps/app_seeds/test/ui/theme_contrast_test.dart +++ b/apps/app_seeds/test/ui/theme_contrast_test.dart @@ -48,5 +48,10 @@ void main() { expectAa(Colors.white, seedGreen, 'white on primary green'); expectAa(Colors.white, seedAppBar, 'white on app-bar green'); }); + + test('overdue warning text on every background it sits on', () { + expectAa(seedWarning, Colors.white, 'seedWarning on white'); + expectAa(seedWarning, seedCanvas, 'seedWarning on canvas'); + }); }); } diff --git a/apps/app_seeds/test/ui/variety_detail_screen_test.dart b/apps/app_seeds/test/ui/variety_detail_screen_test.dart index b03377e..e3c841d 100644 --- a/apps/app_seeds/test/ui/variety_detail_screen_test.dart +++ b/apps/app_seeds/test/ui/variety_detail_screen_test.dart @@ -610,4 +610,35 @@ void main() { expect(find.byIcon(Icons.star_border), findsNothing); await disposeTree(tester); }); + + testWidgets('shows a commitments section only when the variety has one', ( + tester, + ) async { + final repo = newTestRepository(db); + final id = await repo.addQuickVariety(label: 'Maize'); + + await tester.pumpWidget( + wrapDetail( + repository: repo, + varietyId: id, + species: newTestSpeciesRepository(db), + ), + ); + await tester.pumpAndSettle(); + // No commitments yet → the section stays hidden (progressive disclosure). + expect(find.byKey(const Key('detail.plantares')), findsNothing); + + await repo.createPlantare( + direction: PlantareDirection.iReturn, + varietyId: id, + counterparty: 'Ana', + owedDescription: 'un puñado', + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('detail.plantares')), findsOneWidget); + expect(find.textContaining('un puñado'), findsOneWidget); + expect(find.textContaining('Ana'), findsOneWidget); + await disposeTree(tester); + }); } diff --git a/docs/design/data-model.md b/docs/design/data-model.md index ee56089..1879524 100644 --- a/docs/design/data-model.md +++ b/docs/design/data-model.md @@ -151,6 +151,11 @@ The digital version of the paper Plantare. Bilateral, signed, held by both parti | `debtor_signature` / `creditor_signature` | TEXT | Cryptographic signatures (both stubs of the paper). | | `status` | enum | `open` / `returned` / `forgiven`. Framed as a promise, not a debt (microcopy). | +> **Shipped vs. planned.** `app_seeds` ships the local, one-sided version (variety link + +> dates + `direction`/`counterparty` free text); the key/signature/`movement_id` columns +> and the two-party handshake are **deferred**. Full spec of the bilateral signed record: +> [plantare-bilateral.md](plantare-bilateral.md). + ### 2.8 `Offer` — a published, discoverable listing The *shop window*: a signed statement published to the network so others can discover what you share. Distinct from `Lot` on purpose — publishing reveals only what you choose (privacy). Serializes to Nostr NIP-99 (`kind:30402`) or ActivityPub. Full rationale in [sharing-model.md](sharing-model.md). diff --git a/docs/design/plantare-bilateral.md b/docs/design/plantare-bilateral.md new file mode 100644 index 0000000..157ad1b --- /dev/null +++ b/docs/design/plantare-bilateral.md @@ -0,0 +1,160 @@ +# Plantaré — the bilateral signed record (design, not yet built) + +Status: **design locked, implementation deferred.** This doc specifies the two-sided +Plantaré so a later session can build it without re-deciding. It is the "formulario +bilateral firmado" that other docs wait on: + +- [`data-model.md` §2.7](data-model.md) — the `Plantare` entity, whose `debtor_key`, + `creditor_key`, `debtor_signature`/`creditor_signature` and `movement_id` columns are + specified there but **deferred** in the shipped Drift table. +- [`sharing-model.md` §1, §6](sharing-model.md) — the Plantaré as the *private bilateral + close* of an exchange; and the open question of anchoring reputation to a closed deal. +- [`PLAN.md` §5-bis](../../PLAN.md) — the paper original (BAH-Semillero, 2009): "partida + doble repartida entre dos bolsillos", device-to-device, provenance DAG. +- [`open-decisions.md`](open-decisions.md) — strong rating anchoring "espera al formulario + bilateral firmado". + +## What ships today (v1, local-only) + +A private local ledger only. `apps/app_seeds` `Plantares` table stores `varietyId`, +`direction` (`iReturn` / `owedToMe`), `counterparty` (**free text**), `owedDescription`, +`madeOn`, `dueBy`, `status`, `note`. No identity keys, no signatures, no transport — the +other party gets **no record**. The UI now surfaces the linked variety and the dates +(Phase 1), but it is still one-sided. + +## The paper original (v0.4, transcribed) + +Source: BAH-Semillero, *Plantaré — moneda comunitaria de intercambio de semilla*, v4.0, +© 2009–2010, CC-BY-SA 3.0 (`plantare.ourproject.org`). The printed form is **two matching +stubs** ("partida doble") on one sheet: + +**Stub A — "CORTAR Y ENTREGAR A CAMBIO DE LAS SEMILLAS"** (cut off and handed over with the +seeds; the **giver keeps it** as proof of what they're owed): +- **Yo** ______ (your name) +- **localizable en** ______ (*email, tel., etc.* — contact info) +- **recibo** ______ (*cantidad de semillas o lo que sea que recibo*) +- **y me comprometo a devolver antes de (mes/año):** __ / 20__ +- return options (*choose those that apply*): + - `[ ]` una **cantidad similar\*** de la recibida — *(\*) similar = no hibridada, no + transgénica, cultivada en ecológico* + - `[ ]` ó un **trabajo de ___ horas** equivalente + - `[ ]` ó ______ (free text — anything else) +- **Firmado,** ______ **el** __ / __ / 20__ (signature + date) + +**Stub B — "RESGUARDO QUE ME QUEDO"** (the receipt the **receiver keeps** to remember what +they owe): person/collective owed, quantity received, and "lo devolveré antes de __/20__". + +What the paper captures that v1 does **not** yet: +- **Contact** of the party ("localizable en") — v1 stores only a free-text name. Digitally + this becomes the **Nostr pubkey** (reachable by DM); keep a free-text contact for the + offline/paper case. +- **Structured return options** instead of one free-text line: *similar quantity* (default), + *equivalent work in N hours*, or *other*. The work-hours option is time-as-currency — it + overlaps Tane's hand-over payment model, which already allows a time/`tiempo` currency + ([project-sales]; €/Ğ1/time/none). +- **"Similar" is a defined term** — open-pollinated, non-GMO, organically grown — which ties + to Tane's existing organic/eco self-declaration flag. The default return preset should + carry this meaning, not just the word. +- **Return-by is month/year**, matched to cultivation cycles (v1 stores a full day; Phase 1's + picker is day-precision — display can round to month/year). + +## The goal + +Turn the one-sided note into the paper Plantaré's **two matching signed stubs**: when two +people close an exchange, both devices end up holding the *same* commitment, each signed by +both, so either can prove "recibí X, devolveré una cantidad similar antes de la fecha Y". + +Non-goals: interest, enforcement, debt framing (microcopy stays "promise, not debt"); +a central server or global ledger (it stays device-to-device / relay-as-enrichment). + +## Identity — counterparty becomes a real key + +Replace (or rather, *augment*) the free-text `counterparty` with the counterparty's **Nostr +pubkey / `Party`**, reusing the identity already present in the social layer (offers, chat). +The flow starts from an existing conversation or a closed offer, so the pubkey is in hand. +Keep the free-text field as the offline/paper fallback (a Plantaré made face-to-face with +someone who has no app, or ingested from a paper form / QR). + +## Schema delta (versioned Drift migration) + +Add the deferred columns to `Plantares`, nullable so v1 rows coexist untouched: + +- `debtorKey` TEXT — pubkey of who received and owes a return. +- `creditorKey` TEXT — pubkey of who gave. +- `debtorSignature` / `creditorSignature` TEXT — signatures over the canonical payload + (both "stubs of the paper"). +- `movementId` TEXT → `Movement` — the shared `given` event this accompanies (the + `Movement.plantareId` link already exists on the other side). +- `remoteState` enum — `proposed` / `accepted` / `declined`, distinct from `status` + (`open`/`returned`/`forgiven`), which tracks the *promise*, not the *handshake*. + +From the paper form: +- ~~`contact` (the paper's "localizable en", email/phone)~~ — **decided NOT needed** + (2026-07-13). It's a paper-era artifact: digitally the counterparty is a Nostr pubkey, + reachable by DM, so a manual contact field is redundant. Offline/paper Plantarés keep it + only inside the free-text `counterparty`. +- `returnKind` enum — `similar` / `workHours` / `other`, mirroring the form's checkboxes + (default `similar`, meaning open-pollinated · non-GMO · organically grown — surface that + definition, not just the word). `workHours` REAL for the "trabajo de N horas" option; + `owedDescription` still backs `other`. Note the work-hours option overlaps Tane's hand-over + time/`tiempo` currency — reconcile the two rather than duplicating. Kept for the Phase 2 + build; not added to v1. + +A v1 local row = both keys/signatures null, `remoteState` null. Migration test in CI per +[`data-model.md` §5](data-model.md). + +## Transport — `PlantareTransport` in `commons_core` + +A new interface alongside `OfferTransport` / `MessageTransport` / `TrustTransport` / +`RatingTransport` / `SyncTransport` / `ReportTransport` +(`packages/commons_core/lib/src/social/`), on the shared `NostrConnection`. Unlike ratings +(public signed events), a Plantaré is **private to the two parties**, so it is *encrypted*, +like DMs/sync. Sketch, mirroring the existing `*Transport` shape: + +```dart +abstract interface class PlantareTransport { + /// Send a signed proposal (my signature only) to [counterpartyPubkey]. + Future propose(PlantarePledge pledge); + /// Counter-sign a received proposal and send the fully-signed copy back. + Future accept(PlantarePledge pledge); + /// Decline a received proposal. + Future decline({required String pledgeId, String reason}); + /// Incoming proposals / accepts / declines addressed to this identity. + Stream incoming(); + Future close(); +} +``` + +**Handshake:** propose → accept → counter-sign. A proposes (signs, encrypts to B); B reviews, +accepts (adds B's signature), sends the doubly-signed copy back; both persist the identical +row (`remoteState = accepted`). Decline leaves A's row `declined`. + +**Channel:** prefer **NIP-17 encrypted DM** (already used for messaging) carrying a canonical +JSON payload — least new surface, rides the existing connection. A dedicated event kind is an +alternative if we want first-class filtering. **Offline QR/NFC** device-to-device (PLAN.md's +favoured, most-decentralized path) is the same payload exchanged without a relay — the +transport abstracts channel from payload. Relays are enrichment only; offline must fully work. + +## Provenance DAG + +Each accepted Plantaré links parent→child (this lot came from that exchange), forming the +variety's distributed **procedence DAG** ([PLAN.md §5-bis](../../PLAN.md)) — "un grafo de +commits, pero de semillas". `movementId` + the counterparty's prior movement give the edge. + +## Reputation anchor + +An `accepted` Plantaré is the **strong anchor** for a rating (both parties provably closed a +real deal), upgrading the current soft, per-conversation anchoring — closing the +[`open-decisions.md`](open-decisions.md) / [`network-trust.md`](network-trust.md) item. + +## Open questions (for the build session) + +- **Conflict / divergence:** if A and B end up with mismatched payloads (edited after + propose), which wins? Likely: accept is over an immutable hash; any change re-proposes. +- **Revocation / forgiveness sync:** `forgiven` is one-sided today; should it propagate to the + counterparty's copy, or stay each party's own view? +- **Expiry:** does `dueBy` passing change `remoteState`, or only the UI nudge (as in Phase 1)? +- **Paper ingest:** signature model for a Plantaré where one side has no key (photo/QR of a + paper stub) — store as `creditorSignature` null, `counterparty` free text. +- **Group/collective party:** a seed bank (not a person) as creditor — ties to the + collective-identity question in `sharing-model.md` §6. diff --git a/docs/design/sharing-model.md b/docs/design/sharing-model.md index 14cc777..ea960c9 100644 --- a/docs/design/sharing-model.md +++ b/docs/design/sharing-model.md @@ -103,7 +103,7 @@ Nueva entidad publicable (se serializa a NIP-99 / ActivityPub). En inglés, para ## 6. A decidir -- ¿Las valoraciones (reputación) se atan a un `Movement`/`Offer` cerrado para evitar reseñas falsas? (Probablemente sí, Capa 4.) +- ¿Las valoraciones (reputación) se atan a un `Movement`/`Offer` cerrado para evitar reseñas falsas? (Probablemente sí, Capa 4.) El ancla fuerte es el Plantaré bilateral firmado — spec en [plantare-bilateral.md](plantare-bilateral.md). - ¿Permitir monedas comunitarias/tiempo explícitamente en `price_currency` (guiño al espíritu Plantare como "moneda de semillas")? Interesante. - ¿Ofertas de banco colectivo (publica el banco, no la persona)? Afecta a identidad/permisos (Capa 2–3). - ¿Cómo se revoca/expira una Offer publicada en relays que ya la replicaron? (Estado `closed` + caducidad; los relays acaban soltando lo viejo.)