Merge branch 'claude/festive-einstein-384c45'
Ego-centric trust replaces the global Duniter WoT, plus Wallapop-style ratings v1 (kind 30778). Conflicts resolved: pubspec assets (kept seed_saving, dropped trust referents), chat_screen (avatars/no-links from main + rating strip and simplified TrustCubit from the branch), strings.g.dart regenerated from merged sources.
This commit is contained in:
commit
15511ee761
40 changed files with 1777 additions and 876 deletions
|
|
@ -63,9 +63,9 @@ Decided schema details: `Quantity` is a shared value type (Lot + Movement); `off
|
|||
## Phasing — build order
|
||||
|
||||
**Block 1: Inventory.** Shippable alone, useful with zero network. Delivered (beta).
|
||||
**Block 2 (STARTED — the social leap):** offers + messaging + relays + web of trust — big and indivisible. The de-risking spike ([`docs/design/spike-block2-findings.md`](docs/design/spike-block2-findings.md)) validated the whole happy path, so the **social round is now open**. Build order within Block 2: **(1) the transport foundation in `commons_core`** — one `NostrConnection` + three interfaces (`OfferTransport`/`MessageTransport`/`TrustTransport`) + the pure `WebOfTrust` rule, on vetted libraries — then (2) offers UI, (3) messaging hardening, (4) WoT parameters + cold-start. It is still large: scope honestly, keep it behind the transport interfaces, and don't let it regress Block 1.
|
||||
**Block 2 (STARTED — the social leap):** offers + messaging + relays + trust — big and indivisible. The de-risking spike ([`docs/design/spike-block2-findings.md`](docs/design/spike-block2-findings.md)) validated the whole happy path, so the **social round is now open**. Build order within Block 2: **(1) the transport foundation in `commons_core`** — one `NostrConnection` + N interfaces (`OfferTransport`/`MessageTransport`/`TrustTransport`/`RatingTransport`) + the pure `WebOfTrust` engine, on vetted libraries — then (2) offers UI, (3) messaging hardening, (4) trust & reputation polish. It is still large: scope honestly, keep it behind the transport interfaces, and don't let it regress Block 1.
|
||||
|
||||
Social layer uses: Nostr (offers NIP-99, DMs NIP-17, via the pure-Dart `nostr` package), Duniter-style **web of trust** (Duniter/Ğ1 compatible, ~5 certifications), optional **Ğ1** currency (levels: price → wallet deep-link → optional WoT import). Identity: a **secp256k1 Nostr key derived (HKDF, domain-separated, one-way) from the Ğ1 root seed** — user backs up ONE seed. Cold-start via real Ğ1 seed groups + fairs. See [`docs/design/network-trust.md`](docs/design/network-trust.md), [`docs/design/g1-integration.md`](docs/design/g1-integration.md), and the spike findings above.
|
||||
Social layer uses: Nostr (offers NIP-99, DMs NIP-17, via the pure-Dart `nostr` package), **ego-centric trust** (you vouch for people you've met — kind 30777, one live cert per pair, expiring; your circle = friends-of-friends from YOUR key; **no global membership, no bootstrap referents, no user-facing parameters** — the global Duniter rule was dropped 2026-07-11 as not internationally viable, see open-decisions.md), **Wallapop-style ratings** (kind 30778, one live rating per pair, circle-weighted display), optional **Ğ1** currency (levels: price → wallet deep-link → optional WoT import remains a possible future enrichment). Identity: a **secp256k1 Nostr key derived (HKDF, domain-separated, one-way) from the Ğ1 root seed** — user backs up ONE seed. Cold-start via real Ğ1 seed groups + fairs. See [`docs/design/network-trust.md`](docs/design/network-trust.md), [`docs/design/g1-integration.md`](docs/design/g1-integration.md), and the spike findings above.
|
||||
|
||||
## Where things live
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"_comment": "Web-of-trust bootstrap referents (Duniter 'seeds'): the founding identities every membership calculation trusts by construction. Fill 'referents' with npub (or 64-char hex) public keys of real founding members — e.g. Ğ1 seed-group organisers running Tanemaki. Empty by design until curated; users can also add their own referents in-app. Locale-agnostic.",
|
||||
"version": 1,
|
||||
"referents": []
|
||||
}
|
||||
|
|
@ -21,8 +21,6 @@ import 'services/social_account_store.dart';
|
|||
import 'services/social_connection.dart';
|
||||
import 'services/social_service.dart';
|
||||
import 'services/social_settings.dart';
|
||||
import 'services/trust_referents.dart';
|
||||
import 'services/wot_settings.dart';
|
||||
import 'state/inventory_cubit.dart';
|
||||
import 'state/variety_detail_cubit.dart';
|
||||
import 'ui/about_screen.dart';
|
||||
|
|
@ -41,8 +39,8 @@ import 'ui/offline_banner.dart';
|
|||
import 'ui/profile_screen.dart';
|
||||
import 'ui/settings_screen.dart';
|
||||
import 'ui/theme.dart';
|
||||
import 'ui/trust_network_screen.dart';
|
||||
import 'ui/variety_detail_screen.dart';
|
||||
import 'ui/your_people_screen.dart';
|
||||
|
||||
/// Root widget. Provides the repositories to the tree and wires go_router:
|
||||
/// `/` is the home menu, `/inventory` the list, `/variety/:id` the item detail.
|
||||
|
|
@ -61,8 +59,6 @@ class TaneApp extends StatelessWidget {
|
|||
this.profileStore,
|
||||
this.profileCache,
|
||||
this.socialAccounts,
|
||||
this.trustReferents,
|
||||
this.wotSettings,
|
||||
this.inbox,
|
||||
this.notifications,
|
||||
this.showIntro = false,
|
||||
|
|
@ -81,8 +77,6 @@ class TaneApp extends StatelessWidget {
|
|||
profileStore,
|
||||
profileCache,
|
||||
socialAccounts,
|
||||
trustReferents,
|
||||
wotSettings,
|
||||
inbox,
|
||||
) {
|
||||
// A tapped message notification opens that peer's chat. Wired here because
|
||||
|
|
@ -121,10 +115,6 @@ class TaneApp extends StatelessWidget {
|
|||
/// Optional store of the active social identity, for the profile switcher.
|
||||
final SocialAccountStore? socialAccounts;
|
||||
|
||||
/// Web-of-trust bootstrap referents + parameters (network membership).
|
||||
final TrustReferents? trustReferents;
|
||||
final WotSettings? wotSettings;
|
||||
|
||||
/// App-wide inbox listener; drives the messages list's live refresh.
|
||||
final InboxService? inbox;
|
||||
|
||||
|
|
@ -151,8 +141,6 @@ class TaneApp extends StatelessWidget {
|
|||
ProfileStore? profileStore,
|
||||
ProfileCache? profileCache,
|
||||
SocialAccountStore? socialAccounts,
|
||||
TrustReferents? trustReferents,
|
||||
WotSettings? wotSettings,
|
||||
InboxService? inbox,
|
||||
) {
|
||||
return GoRouter(
|
||||
|
|
@ -184,6 +172,7 @@ class TaneApp extends StatelessWidget {
|
|||
connection: connection,
|
||||
mine: offer.authorPubkeyHex == social.publicKeyHex,
|
||||
profileCache: profileCache,
|
||||
selfPubkey: social.publicKeyHex,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -208,16 +197,16 @@ class TaneApp extends StatelessWidget {
|
|||
connection: connection,
|
||||
profileStore: profileStore,
|
||||
accounts: socialAccounts,
|
||||
trustNetworkEnabled:
|
||||
trustReferents != null && wotSettings != null,
|
||||
yourPeopleEnabled: true,
|
||||
),
|
||||
),
|
||||
if (trustReferents != null && wotSettings != null)
|
||||
if (social != null && connection != null)
|
||||
GoRoute(
|
||||
path: '/trust',
|
||||
builder: (context, state) => TrustNetworkScreen(
|
||||
referents: trustReferents,
|
||||
wotSettings: wotSettings,
|
||||
path: '/your-people',
|
||||
builder: (context, state) => YourPeopleScreen(
|
||||
social: social,
|
||||
connection: connection,
|
||||
profileCache: profileCache,
|
||||
),
|
||||
),
|
||||
if (social != null && connection != null)
|
||||
|
|
@ -229,8 +218,6 @@ class TaneApp extends StatelessWidget {
|
|||
peerPubkey: state.pathParameters['pubkey']!,
|
||||
messageStore: messageStore,
|
||||
profileCache: profileCache,
|
||||
trustReferents: trustReferents,
|
||||
wotSettings: wotSettings,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
|
|
|
|||
|
|
@ -23,8 +23,6 @@ import 'services/social_connection.dart';
|
|||
import 'services/social_service.dart';
|
||||
import 'services/social_settings.dart';
|
||||
import 'services/sync_service.dart';
|
||||
import 'services/trust_referents.dart';
|
||||
import 'services/wot_settings.dart';
|
||||
import 'ui/theme.dart';
|
||||
|
||||
/// Boots the app WITHOUT blocking the first frame: paints a splash immediately,
|
||||
|
|
@ -88,8 +86,6 @@ class _BootstrapState extends State<Bootstrap> {
|
|||
profileStore: getIt<ProfileStore>(),
|
||||
profileCache: getIt<ProfileCache>(),
|
||||
socialAccounts: getIt<SocialAccountStore>(),
|
||||
trustReferents: getIt<TrustReferents>(),
|
||||
wotSettings: getIt<WotSettings>(),
|
||||
inbox: inbox,
|
||||
notifications: notifications,
|
||||
showIntro: !await onboarding.introSeen(),
|
||||
|
|
|
|||
|
|
@ -44,9 +44,7 @@ import '../services/social_connection.dart';
|
|||
import '../services/social_service.dart';
|
||||
import '../services/social_settings.dart';
|
||||
import '../services/sync_service.dart';
|
||||
import '../services/trust_referents.dart';
|
||||
import '../services/unread_service.dart';
|
||||
import '../services/wot_settings.dart';
|
||||
|
||||
/// The app's service locator. Kept to the composition root — widgets get their
|
||||
/// repositories from here (or via BlocProvider), never by reaching into it deep
|
||||
|
|
@ -175,9 +173,6 @@ Future<void> configureDependencies() async {
|
|||
..registerSingleton<LocaleStore>(LocaleStore(secretStore))
|
||||
..registerSingleton<SocialSettings>(SocialSettings(secretStore))
|
||||
..registerSingleton<SocialAccountStore>(accounts)
|
||||
// Web of trust: network-wide (not per-identity), so registered once.
|
||||
..registerSingleton<TrustReferents>(TrustReferents(secretStore))
|
||||
..registerSingleton<WotSettings>(WotSettings(secretStore))
|
||||
..registerSingleton<OfferOutbox>(OfferOutbox(secretStore))
|
||||
// Per-identity stores are namespaced by the active account's scope.
|
||||
..registerSingleton<MessageStore>(
|
||||
|
|
|
|||
|
|
@ -568,29 +568,26 @@
|
|||
"count": "Avalada por {n}",
|
||||
"vouch": "Conozo a esta persona",
|
||||
"vouched": "Avales a esta persona",
|
||||
"circle": "Nel to círculu",
|
||||
"member": "Miembru de confianza de la rede"
|
||||
"circle": "Nel to círculu"
|
||||
},
|
||||
"wot": {
|
||||
"title": "Rede de confianza",
|
||||
"open": "Rede de confianza",
|
||||
"help": "Cómo se decide quién ye miembru de confianza: la xente aválase ente sí, y la confianza estiéndese dende un conxuntu de raíces fundadores.",
|
||||
"roots": "Raíces de confianza",
|
||||
"rootsHelp": "Les identidaes fundadores qu'anclen la rede. Amiesta a quien te fíes (apega'l so códigu d'identidá); la pertenencia mídese p'afuera dende elles.",
|
||||
"noRoots": "Entá nun hai raíces de confianza — nun se pue calcular la pertenencia hasta semar la rede.",
|
||||
"addRoot": "Amestar una raíz de confianza",
|
||||
"rootHint": "Apega un códigu d'identidá (npub…)",
|
||||
"add": "Amestar",
|
||||
"rootAdded": "Raíz de confianza amestada",
|
||||
"rootInvalid": "Esi nun ye un códigu d'identidá válidu",
|
||||
"remove": "Quitar",
|
||||
"params": "Parámetros avanzaos",
|
||||
"paramsHelp": "Regles Duniter. Aflóxales pa una rede nueva; apriétales según crez.",
|
||||
"sigQty": "Avales pa ser miembru",
|
||||
"stepMax": "Distancia máxima a una raíz",
|
||||
"validityDays": "Validez del aval (díes)",
|
||||
"reset": "Reafitar a valores Duniter",
|
||||
"saved": "Guardáu"
|
||||
"yourPeople": {
|
||||
"title": "La to xente",
|
||||
"help": "Persones que conoces y avales, y persones que t'avalen.",
|
||||
"youVouchFor": "Tu avales a",
|
||||
"vouchesForYou": "Aválente",
|
||||
"youVouchForEmpty": "Entá nun avales a naide. Cuando conozas a daquién, abri la vuestra conversación y calca \"Conozo a esta persona\".",
|
||||
"vouchesForYouEmpty": "Naide t'avala entá",
|
||||
"revoke": "Dexar d'avalar",
|
||||
"revokeConfirm": "¿Dexar d'avalar a esta persona?",
|
||||
"offline": "Ensin conexón — vuelvi tentalo cuando teas en llinia"
|
||||
},
|
||||
"ratings": {
|
||||
"rate": "Valorar a esta persona",
|
||||
"edit": "Editar la to valoración",
|
||||
"commentHint": "¿Qué tal foi? (opcional)",
|
||||
"fromYourCircle": "{n} de xente que conoces",
|
||||
"retract": "Quitar la to valoración",
|
||||
"saved": "Valoración guardada"
|
||||
},
|
||||
"notifications": {
|
||||
"newMessageFrom": "Mensaxe nuevu de {name}"
|
||||
|
|
|
|||
|
|
@ -571,29 +571,26 @@
|
|||
"count": "Vouched for by {n}",
|
||||
"vouch": "I know this person",
|
||||
"vouched": "You vouch for them",
|
||||
"circle": "In your circle",
|
||||
"member": "Trusted member of the network"
|
||||
"circle": "In your circle"
|
||||
},
|
||||
"wot": {
|
||||
"title": "Network of trust",
|
||||
"open": "Network of trust",
|
||||
"help": "How trusted membership is decided: people vouch for each other, and trust spreads outward from a set of founding roots.",
|
||||
"roots": "Trust roots",
|
||||
"rootsHelp": "The founding identities the network is anchored to. Add people you trust (paste their identity code); membership is measured outward from them.",
|
||||
"noRoots": "No trust roots yet — membership can't be worked out until the network is seeded.",
|
||||
"addRoot": "Add a trust root",
|
||||
"rootHint": "Paste an identity code (npub…)",
|
||||
"add": "Add",
|
||||
"rootAdded": "Trust root added",
|
||||
"rootInvalid": "That's not a valid identity code",
|
||||
"remove": "Remove",
|
||||
"params": "Advanced parameters",
|
||||
"paramsHelp": "Duniter rules. Loosen them for a young network; tighten as it grows.",
|
||||
"sigQty": "Vouches to become a member",
|
||||
"stepMax": "Max distance from a root",
|
||||
"validityDays": "Vouch validity (days)",
|
||||
"reset": "Reset to Duniter defaults",
|
||||
"saved": "Saved"
|
||||
"yourPeople": {
|
||||
"title": "Your people",
|
||||
"help": "People you've met and vouch for, and people who vouch for you.",
|
||||
"youVouchFor": "You vouch for",
|
||||
"vouchesForYou": "They vouch for you",
|
||||
"youVouchForEmpty": "You don't vouch for anyone yet. When you meet someone, open your chat with them and tap \"I know this person\".",
|
||||
"vouchesForYouEmpty": "No one vouches for you yet",
|
||||
"revoke": "Stop vouching",
|
||||
"revokeConfirm": "Stop vouching for this person?",
|
||||
"offline": "You're offline — try again when you're connected"
|
||||
},
|
||||
"ratings": {
|
||||
"rate": "Rate this person",
|
||||
"edit": "Edit your rating",
|
||||
"commentHint": "How did it go? (optional)",
|
||||
"fromYourCircle": "{n} from people you know",
|
||||
"retract": "Remove your rating",
|
||||
"saved": "Rating saved"
|
||||
},
|
||||
"notifications": {
|
||||
"newMessageFrom": "New message from {name}"
|
||||
|
|
|
|||
|
|
@ -570,29 +570,26 @@
|
|||
"count": "Avalada por {n}",
|
||||
"vouch": "Conozco a esta persona",
|
||||
"vouched": "Avalas a esta persona",
|
||||
"circle": "En tu círculo",
|
||||
"member": "Miembro de confianza de la red"
|
||||
"circle": "En tu círculo"
|
||||
},
|
||||
"wot": {
|
||||
"title": "Red de confianza",
|
||||
"open": "Red de confianza",
|
||||
"help": "Cómo se decide quién es miembro de confianza: la gente se avala entre sí, y la confianza se extiende desde un conjunto de raíces fundadoras.",
|
||||
"roots": "Raíces de confianza",
|
||||
"rootsHelp": "Las identidades fundadoras que anclan la red. Añade a quien te fíes (pega su código de identidad); la membresía se mide hacia fuera desde ellas.",
|
||||
"noRoots": "Aún no hay raíces de confianza — no se puede calcular la membresía hasta sembrar la red.",
|
||||
"addRoot": "Añadir una raíz de confianza",
|
||||
"rootHint": "Pega un código de identidad (npub…)",
|
||||
"add": "Añadir",
|
||||
"rootAdded": "Raíz de confianza añadida",
|
||||
"rootInvalid": "Ese no es un código de identidad válido",
|
||||
"remove": "Quitar",
|
||||
"params": "Parámetros avanzados",
|
||||
"paramsHelp": "Reglas Duniter. Aflójalas para una red joven; apriétalas según crece.",
|
||||
"sigQty": "Avales para ser miembro",
|
||||
"stepMax": "Distancia máxima a una raíz",
|
||||
"validityDays": "Validez del aval (días)",
|
||||
"reset": "Restablecer a valores Duniter",
|
||||
"saved": "Guardado"
|
||||
"yourPeople": {
|
||||
"title": "Tu gente",
|
||||
"help": "Personas que conoces y avalas, y personas que te avalan.",
|
||||
"youVouchFor": "Tú avalas a",
|
||||
"vouchesForYou": "Te avalan",
|
||||
"youVouchForEmpty": "Aún no avalas a nadie. Cuando conozcas a alguien, abre vuestro chat y toca \"Conozco a esta persona\".",
|
||||
"vouchesForYouEmpty": "Nadie te avala todavía",
|
||||
"revoke": "Dejar de avalar",
|
||||
"revokeConfirm": "¿Dejar de avalar a esta persona?",
|
||||
"offline": "Sin conexión — inténtalo cuando estés en línea"
|
||||
},
|
||||
"ratings": {
|
||||
"rate": "Valorar a esta persona",
|
||||
"edit": "Editar tu valoración",
|
||||
"commentHint": "¿Qué tal fue? (opcional)",
|
||||
"fromYourCircle": "{n} de gente que conoces",
|
||||
"retract": "Quitar tu valoración",
|
||||
"saved": "Valoración guardada"
|
||||
},
|
||||
"notifications": {
|
||||
"newMessageFrom": "Nuevo mensaje de {name}"
|
||||
|
|
|
|||
|
|
@ -567,29 +567,26 @@
|
|||
"count": "Avalizada por {n}",
|
||||
"vouch": "Conheço esta pessoa",
|
||||
"vouched": "Avalizas esta pessoa",
|
||||
"circle": "No teu círculo",
|
||||
"member": "Membro de confiança da rede"
|
||||
"circle": "No teu círculo"
|
||||
},
|
||||
"wot": {
|
||||
"title": "Rede de confiança",
|
||||
"open": "Rede de confiança",
|
||||
"help": "Como se decide quem é membro de confiança: as pessoas avalizam-se entre si, e a confiança estende-se a partir de um conjunto de raízes fundadoras.",
|
||||
"roots": "Raízes de confiança",
|
||||
"rootsHelp": "As identidades fundadoras que ancoram a rede. Adiciona quem confias (cola o seu código de identidade); a filiação mede-se para fora a partir delas.",
|
||||
"noRoots": "Ainda não há raízes de confiança — não se pode calcular a filiação até semear a rede.",
|
||||
"addRoot": "Adicionar uma raiz de confiança",
|
||||
"rootHint": "Cola um código de identidade (npub…)",
|
||||
"add": "Adicionar",
|
||||
"rootAdded": "Raiz de confiança adicionada",
|
||||
"rootInvalid": "Esse não é um código de identidade válido",
|
||||
"remove": "Remover",
|
||||
"params": "Parâmetros avançados",
|
||||
"paramsHelp": "Regras Duniter. Afrouxa-as para uma rede jovem; aperta-as à medida que cresce.",
|
||||
"sigQty": "Avais para ser membro",
|
||||
"stepMax": "Distância máxima a uma raiz",
|
||||
"validityDays": "Validade do aval (dias)",
|
||||
"reset": "Repor para valores Duniter",
|
||||
"saved": "Guardado"
|
||||
"yourPeople": {
|
||||
"title": "A tua gente",
|
||||
"help": "Pessoas que conheces e avalizas, e pessoas que te avalizam.",
|
||||
"youVouchFor": "Tu avalizas",
|
||||
"vouchesForYou": "Avalizam-te",
|
||||
"youVouchForEmpty": "Ainda não avalizas ninguém. Quando conheceres alguém, abre a vossa conversa e toca em \"Conheço esta pessoa\".",
|
||||
"vouchesForYouEmpty": "Ainda ninguém te avaliza",
|
||||
"revoke": "Deixar de avalizar",
|
||||
"revokeConfirm": "Deixar de avalizar esta pessoa?",
|
||||
"offline": "Sem ligação — tenta novamente quando estiveres em linha"
|
||||
},
|
||||
"ratings": {
|
||||
"rate": "Avaliar esta pessoa",
|
||||
"edit": "Editar a tua avaliação",
|
||||
"commentHint": "Como correu? (opcional)",
|
||||
"fromYourCircle": "{n} de pessoas que conheces",
|
||||
"retract": "Remover a tua avaliação",
|
||||
"saved": "Avaliação guardada"
|
||||
},
|
||||
"notifications": {
|
||||
"newMessageFrom": "Nova mensagem de {name}"
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 4
|
||||
/// Strings: 2020 (505 per locale)
|
||||
/// Strings: 2000 (500 per locale)
|
||||
///
|
||||
/// Built on 2026-07-11 at 06:00 UTC
|
||||
/// Built on 2026-07-11 at 11:22 UTC
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
|
|
|||
|
|
@ -77,7 +77,8 @@ class TranslationsAst extends Translations with BaseTranslations<AppLocale, Tran
|
|||
@override late final _Translations$chatList$ast chatList = _Translations$chatList$ast._(_root);
|
||||
@override late final _Translations$chat$ast chat = _Translations$chat$ast._(_root);
|
||||
@override late final _Translations$trust$ast trust = _Translations$trust$ast._(_root);
|
||||
@override late final _Translations$wot$ast wot = _Translations$wot$ast._(_root);
|
||||
@override late final _Translations$yourPeople$ast yourPeople = _Translations$yourPeople$ast._(_root);
|
||||
@override late final _Translations$ratings$ast ratings = _Translations$ratings$ast._(_root);
|
||||
@override late final _Translations$notifications$ast notifications = _Translations$notifications$ast._(_root);
|
||||
@override late final _Translations$plantare$ast plantare = _Translations$plantare$ast._(_root);
|
||||
@override late final _Translations$handover$ast handover = _Translations$handover$ast._(_root);
|
||||
|
|
@ -826,35 +827,39 @@ class _Translations$trust$ast extends Translations$trust$en {
|
|||
@override String get vouch => 'Conozo a esta persona';
|
||||
@override String get vouched => 'Avales a esta persona';
|
||||
@override String get circle => 'Nel to círculu';
|
||||
@override String get member => 'Miembru de confianza de la rede';
|
||||
}
|
||||
|
||||
// Path: wot
|
||||
class _Translations$wot$ast extends Translations$wot$en {
|
||||
_Translations$wot$ast._(TranslationsAst root) : this._root = root, super.internal(root);
|
||||
// Path: yourPeople
|
||||
class _Translations$yourPeople$ast extends Translations$yourPeople$en {
|
||||
_Translations$yourPeople$ast._(TranslationsAst root) : this._root = root, super.internal(root);
|
||||
|
||||
final TranslationsAst _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get title => 'Rede de confianza';
|
||||
@override String get open => 'Rede de confianza';
|
||||
@override String get help => 'Cómo se decide quién ye miembru de confianza: la xente aválase ente sí, y la confianza estiéndese dende un conxuntu de raíces fundadores.';
|
||||
@override String get roots => 'Raíces de confianza';
|
||||
@override String get rootsHelp => 'Les identidaes fundadores qu\'anclen la rede. Amiesta a quien te fíes (apega\'l so códigu d\'identidá); la pertenencia mídese p\'afuera dende elles.';
|
||||
@override String get noRoots => 'Entá nun hai raíces de confianza — nun se pue calcular la pertenencia hasta semar la rede.';
|
||||
@override String get addRoot => 'Amestar una raíz de confianza';
|
||||
@override String get rootHint => 'Apega un códigu d\'identidá (npub…)';
|
||||
@override String get add => 'Amestar';
|
||||
@override String get rootAdded => 'Raíz de confianza amestada';
|
||||
@override String get rootInvalid => 'Esi nun ye un códigu d\'identidá válidu';
|
||||
@override String get remove => 'Quitar';
|
||||
@override String get params => 'Parámetros avanzaos';
|
||||
@override String get paramsHelp => 'Regles Duniter. Aflóxales pa una rede nueva; apriétales según crez.';
|
||||
@override String get sigQty => 'Avales pa ser miembru';
|
||||
@override String get stepMax => 'Distancia máxima a una raíz';
|
||||
@override String get validityDays => 'Validez del aval (díes)';
|
||||
@override String get reset => 'Reafitar a valores Duniter';
|
||||
@override String get saved => 'Guardáu';
|
||||
@override String get title => 'La to xente';
|
||||
@override String get help => 'Persones que conoces y avales, y persones que t\'avalen.';
|
||||
@override String get youVouchFor => 'Tu avales a';
|
||||
@override String get vouchesForYou => 'Aválente';
|
||||
@override String get youVouchForEmpty => 'Entá nun avales a naide. Cuando conozas a daquién, abri la vuestra conversación y calca "Conozo a esta persona".';
|
||||
@override String get vouchesForYouEmpty => 'Naide t\'avala entá';
|
||||
@override String get revoke => 'Dexar d\'avalar';
|
||||
@override String get revokeConfirm => '¿Dexar d\'avalar a esta persona?';
|
||||
@override String get offline => 'Ensin conexón — vuelvi tentalo cuando teas en llinia';
|
||||
}
|
||||
|
||||
// Path: ratings
|
||||
class _Translations$ratings$ast extends Translations$ratings$en {
|
||||
_Translations$ratings$ast._(TranslationsAst root) : this._root = root, super.internal(root);
|
||||
|
||||
final TranslationsAst _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get rate => 'Valorar a esta persona';
|
||||
@override String get edit => 'Editar la to valoración';
|
||||
@override String get commentHint => '¿Qué tal foi? (opcional)';
|
||||
@override String fromYourCircle({required Object n}) => '${n} de xente que conoces';
|
||||
@override String get retract => 'Quitar la to valoración';
|
||||
@override String get saved => 'Valoración guardada';
|
||||
}
|
||||
|
||||
// Path: notifications
|
||||
|
|
@ -1718,26 +1723,21 @@ extension on TranslationsAst {
|
|||
'trust.vouch' => 'Conozo a esta persona',
|
||||
'trust.vouched' => 'Avales a esta persona',
|
||||
'trust.circle' => 'Nel to círculu',
|
||||
'trust.member' => 'Miembru de confianza de la rede',
|
||||
'wot.title' => 'Rede de confianza',
|
||||
'wot.open' => 'Rede de confianza',
|
||||
'wot.help' => 'Cómo se decide quién ye miembru de confianza: la xente aválase ente sí, y la confianza estiéndese dende un conxuntu de raíces fundadores.',
|
||||
'wot.roots' => 'Raíces de confianza',
|
||||
'wot.rootsHelp' => 'Les identidaes fundadores qu\'anclen la rede. Amiesta a quien te fíes (apega\'l so códigu d\'identidá); la pertenencia mídese p\'afuera dende elles.',
|
||||
'wot.noRoots' => 'Entá nun hai raíces de confianza — nun se pue calcular la pertenencia hasta semar la rede.',
|
||||
'wot.addRoot' => 'Amestar una raíz de confianza',
|
||||
'wot.rootHint' => 'Apega un códigu d\'identidá (npub…)',
|
||||
'wot.add' => 'Amestar',
|
||||
'wot.rootAdded' => 'Raíz de confianza amestada',
|
||||
'wot.rootInvalid' => 'Esi nun ye un códigu d\'identidá válidu',
|
||||
'wot.remove' => 'Quitar',
|
||||
'wot.params' => 'Parámetros avanzaos',
|
||||
'wot.paramsHelp' => 'Regles Duniter. Aflóxales pa una rede nueva; apriétales según crez.',
|
||||
'wot.sigQty' => 'Avales pa ser miembru',
|
||||
'wot.stepMax' => 'Distancia máxima a una raíz',
|
||||
'wot.validityDays' => 'Validez del aval (díes)',
|
||||
'wot.reset' => 'Reafitar a valores Duniter',
|
||||
'wot.saved' => 'Guardáu',
|
||||
'yourPeople.title' => 'La to xente',
|
||||
'yourPeople.help' => 'Persones que conoces y avales, y persones que t\'avalen.',
|
||||
'yourPeople.youVouchFor' => 'Tu avales a',
|
||||
'yourPeople.vouchesForYou' => 'Aválente',
|
||||
'yourPeople.youVouchForEmpty' => 'Entá nun avales a naide. Cuando conozas a daquién, abri la vuestra conversación y calca "Conozo a esta persona".',
|
||||
'yourPeople.vouchesForYouEmpty' => 'Naide t\'avala entá',
|
||||
'yourPeople.revoke' => 'Dexar d\'avalar',
|
||||
'yourPeople.revokeConfirm' => '¿Dexar d\'avalar a esta persona?',
|
||||
'yourPeople.offline' => 'Ensin conexón — vuelvi tentalo cuando teas en llinia',
|
||||
'ratings.rate' => 'Valorar a esta persona',
|
||||
'ratings.edit' => 'Editar la to valoración',
|
||||
'ratings.commentHint' => '¿Qué tal foi? (opcional)',
|
||||
'ratings.fromYourCircle' => ({required Object n}) => '${n} de xente que conoces',
|
||||
'ratings.retract' => 'Quitar la to valoración',
|
||||
'ratings.saved' => 'Valoración guardada',
|
||||
'notifications.newMessageFrom' => ({required Object name}) => 'Mensaxe nuevu de ${name}',
|
||||
'plantare.title' => 'Plantares',
|
||||
'plantare.help' => 'Un Plantare ye\'l compromisu de reproducir una semilla y devolver una parte — asina una variedá sigue viaxando de mano en mano. Nun ye una venta.',
|
||||
|
|
|
|||
|
|
@ -78,7 +78,8 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
|||
late final Translations$chatList$en chatList = Translations$chatList$en.internal(_root);
|
||||
late final Translations$chat$en chat = Translations$chat$en.internal(_root);
|
||||
late final Translations$trust$en trust = Translations$trust$en.internal(_root);
|
||||
late final Translations$wot$en wot = Translations$wot$en.internal(_root);
|
||||
late final Translations$yourPeople$en yourPeople = Translations$yourPeople$en.internal(_root);
|
||||
late final Translations$ratings$en ratings = Translations$ratings$en.internal(_root);
|
||||
late final Translations$notifications$en notifications = Translations$notifications$en.internal(_root);
|
||||
late final Translations$plantare$en plantare = Translations$plantare$en.internal(_root);
|
||||
late final Translations$handover$en handover = Translations$handover$en.internal(_root);
|
||||
|
|
@ -1565,75 +1566,69 @@ class Translations$trust$en {
|
|||
|
||||
/// en: 'In your circle'
|
||||
String get circle => 'In your circle';
|
||||
|
||||
/// en: 'Trusted member of the network'
|
||||
String get member => 'Trusted member of the network';
|
||||
}
|
||||
|
||||
// Path: wot
|
||||
class Translations$wot$en {
|
||||
Translations$wot$en.internal(this._root);
|
||||
// Path: yourPeople
|
||||
class Translations$yourPeople$en {
|
||||
Translations$yourPeople$en.internal(this._root);
|
||||
|
||||
final Translations _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
|
||||
/// en: 'Network of trust'
|
||||
String get title => 'Network of trust';
|
||||
/// en: 'Your people'
|
||||
String get title => 'Your people';
|
||||
|
||||
/// en: 'Network of trust'
|
||||
String get open => 'Network of trust';
|
||||
/// en: 'People you've met and vouch for, and people who vouch for you.'
|
||||
String get help => 'People you\'ve met and vouch for, and people who vouch for you.';
|
||||
|
||||
/// en: 'How trusted membership is decided: people vouch for each other, and trust spreads outward from a set of founding roots.'
|
||||
String get help => 'How trusted membership is decided: people vouch for each other, and trust spreads outward from a set of founding roots.';
|
||||
/// en: 'You vouch for'
|
||||
String get youVouchFor => 'You vouch for';
|
||||
|
||||
/// en: 'Trust roots'
|
||||
String get roots => 'Trust roots';
|
||||
/// en: 'They vouch for you'
|
||||
String get vouchesForYou => 'They vouch for you';
|
||||
|
||||
/// en: 'The founding identities the network is anchored to. Add people you trust (paste their identity code); membership is measured outward from them.'
|
||||
String get rootsHelp => 'The founding identities the network is anchored to. Add people you trust (paste their identity code); membership is measured outward from them.';
|
||||
/// en: 'You don't vouch for anyone yet. When you meet someone, open your chat with them and tap "I know this person".'
|
||||
String get youVouchForEmpty => 'You don\'t vouch for anyone yet. When you meet someone, open your chat with them and tap "I know this person".';
|
||||
|
||||
/// en: 'No trust roots yet — membership can't be worked out until the network is seeded.'
|
||||
String get noRoots => 'No trust roots yet — membership can\'t be worked out until the network is seeded.';
|
||||
/// en: 'No one vouches for you yet'
|
||||
String get vouchesForYouEmpty => 'No one vouches for you yet';
|
||||
|
||||
/// en: 'Add a trust root'
|
||||
String get addRoot => 'Add a trust root';
|
||||
/// en: 'Stop vouching'
|
||||
String get revoke => 'Stop vouching';
|
||||
|
||||
/// en: 'Paste an identity code (npub…)'
|
||||
String get rootHint => 'Paste an identity code (npub…)';
|
||||
/// en: 'Stop vouching for this person?'
|
||||
String get revokeConfirm => 'Stop vouching for this person?';
|
||||
|
||||
/// en: 'Add'
|
||||
String get add => 'Add';
|
||||
/// en: 'You're offline — try again when you're connected'
|
||||
String get offline => 'You\'re offline — try again when you\'re connected';
|
||||
}
|
||||
|
||||
/// en: 'Trust root added'
|
||||
String get rootAdded => 'Trust root added';
|
||||
// Path: ratings
|
||||
class Translations$ratings$en {
|
||||
Translations$ratings$en.internal(this._root);
|
||||
|
||||
/// en: 'That's not a valid identity code'
|
||||
String get rootInvalid => 'That\'s not a valid identity code';
|
||||
final Translations _root; // ignore: unused_field
|
||||
|
||||
/// en: 'Remove'
|
||||
String get remove => 'Remove';
|
||||
// Translations
|
||||
|
||||
/// en: 'Advanced parameters'
|
||||
String get params => 'Advanced parameters';
|
||||
/// en: 'Rate this person'
|
||||
String get rate => 'Rate this person';
|
||||
|
||||
/// en: 'Duniter rules. Loosen them for a young network; tighten as it grows.'
|
||||
String get paramsHelp => 'Duniter rules. Loosen them for a young network; tighten as it grows.';
|
||||
/// en: 'Edit your rating'
|
||||
String get edit => 'Edit your rating';
|
||||
|
||||
/// en: 'Vouches to become a member'
|
||||
String get sigQty => 'Vouches to become a member';
|
||||
/// en: 'How did it go? (optional)'
|
||||
String get commentHint => 'How did it go? (optional)';
|
||||
|
||||
/// en: 'Max distance from a root'
|
||||
String get stepMax => 'Max distance from a root';
|
||||
/// en: '{n} from people you know'
|
||||
String fromYourCircle({required Object n}) => '${n} from people you know';
|
||||
|
||||
/// en: 'Vouch validity (days)'
|
||||
String get validityDays => 'Vouch validity (days)';
|
||||
/// en: 'Remove your rating'
|
||||
String get retract => 'Remove your rating';
|
||||
|
||||
/// en: 'Reset to Duniter defaults'
|
||||
String get reset => 'Reset to Duniter defaults';
|
||||
|
||||
/// en: 'Saved'
|
||||
String get saved => 'Saved';
|
||||
/// en: 'Rating saved'
|
||||
String get saved => 'Rating saved';
|
||||
}
|
||||
|
||||
// Path: notifications
|
||||
|
|
@ -2718,26 +2713,21 @@ extension on Translations {
|
|||
'trust.vouch' => 'I know this person',
|
||||
'trust.vouched' => 'You vouch for them',
|
||||
'trust.circle' => 'In your circle',
|
||||
'trust.member' => 'Trusted member of the network',
|
||||
'wot.title' => 'Network of trust',
|
||||
'wot.open' => 'Network of trust',
|
||||
'wot.help' => 'How trusted membership is decided: people vouch for each other, and trust spreads outward from a set of founding roots.',
|
||||
'wot.roots' => 'Trust roots',
|
||||
'wot.rootsHelp' => 'The founding identities the network is anchored to. Add people you trust (paste their identity code); membership is measured outward from them.',
|
||||
'wot.noRoots' => 'No trust roots yet — membership can\'t be worked out until the network is seeded.',
|
||||
'wot.addRoot' => 'Add a trust root',
|
||||
'wot.rootHint' => 'Paste an identity code (npub…)',
|
||||
'wot.add' => 'Add',
|
||||
'wot.rootAdded' => 'Trust root added',
|
||||
'wot.rootInvalid' => 'That\'s not a valid identity code',
|
||||
'wot.remove' => 'Remove',
|
||||
'wot.params' => 'Advanced parameters',
|
||||
'wot.paramsHelp' => 'Duniter rules. Loosen them for a young network; tighten as it grows.',
|
||||
'wot.sigQty' => 'Vouches to become a member',
|
||||
'wot.stepMax' => 'Max distance from a root',
|
||||
'wot.validityDays' => 'Vouch validity (days)',
|
||||
'wot.reset' => 'Reset to Duniter defaults',
|
||||
'wot.saved' => 'Saved',
|
||||
'yourPeople.title' => 'Your people',
|
||||
'yourPeople.help' => 'People you\'ve met and vouch for, and people who vouch for you.',
|
||||
'yourPeople.youVouchFor' => 'You vouch for',
|
||||
'yourPeople.vouchesForYou' => 'They vouch for you',
|
||||
'yourPeople.youVouchForEmpty' => 'You don\'t vouch for anyone yet. When you meet someone, open your chat with them and tap "I know this person".',
|
||||
'yourPeople.vouchesForYouEmpty' => 'No one vouches for you yet',
|
||||
'yourPeople.revoke' => 'Stop vouching',
|
||||
'yourPeople.revokeConfirm' => 'Stop vouching for this person?',
|
||||
'yourPeople.offline' => 'You\'re offline — try again when you\'re connected',
|
||||
'ratings.rate' => 'Rate this person',
|
||||
'ratings.edit' => 'Edit your rating',
|
||||
'ratings.commentHint' => 'How did it go? (optional)',
|
||||
'ratings.fromYourCircle' => ({required Object n}) => '${n} from people you know',
|
||||
'ratings.retract' => 'Remove your rating',
|
||||
'ratings.saved' => 'Rating saved',
|
||||
'notifications.newMessageFrom' => ({required Object name}) => 'New message from ${name}',
|
||||
'plantare.title' => 'Plantares',
|
||||
'plantare.help' => 'A Plantare is a promise to reproduce a seed and return some — how a variety keeps travelling from hand to hand. It\'s not a sale.',
|
||||
|
|
|
|||
|
|
@ -77,7 +77,8 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
|||
@override late final _Translations$chatList$es chatList = _Translations$chatList$es._(_root);
|
||||
@override late final _Translations$chat$es chat = _Translations$chat$es._(_root);
|
||||
@override late final _Translations$trust$es trust = _Translations$trust$es._(_root);
|
||||
@override late final _Translations$wot$es wot = _Translations$wot$es._(_root);
|
||||
@override late final _Translations$yourPeople$es yourPeople = _Translations$yourPeople$es._(_root);
|
||||
@override late final _Translations$ratings$es ratings = _Translations$ratings$es._(_root);
|
||||
@override late final _Translations$notifications$es notifications = _Translations$notifications$es._(_root);
|
||||
@override late final _Translations$plantare$es plantare = _Translations$plantare$es._(_root);
|
||||
@override late final _Translations$handover$es handover = _Translations$handover$es._(_root);
|
||||
|
|
@ -828,35 +829,39 @@ class _Translations$trust$es extends Translations$trust$en {
|
|||
@override String get vouch => 'Conozco a esta persona';
|
||||
@override String get vouched => 'Avalas a esta persona';
|
||||
@override String get circle => 'En tu círculo';
|
||||
@override String get member => 'Miembro de confianza de la red';
|
||||
}
|
||||
|
||||
// Path: wot
|
||||
class _Translations$wot$es extends Translations$wot$en {
|
||||
_Translations$wot$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||
// Path: yourPeople
|
||||
class _Translations$yourPeople$es extends Translations$yourPeople$en {
|
||||
_Translations$yourPeople$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||
|
||||
final TranslationsEs _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get title => 'Red de confianza';
|
||||
@override String get open => 'Red de confianza';
|
||||
@override String get help => 'Cómo se decide quién es miembro de confianza: la gente se avala entre sí, y la confianza se extiende desde un conjunto de raíces fundadoras.';
|
||||
@override String get roots => 'Raíces de confianza';
|
||||
@override String get rootsHelp => 'Las identidades fundadoras que anclan la red. Añade a quien te fíes (pega su código de identidad); la membresía se mide hacia fuera desde ellas.';
|
||||
@override String get noRoots => 'Aún no hay raíces de confianza — no se puede calcular la membresía hasta sembrar la red.';
|
||||
@override String get addRoot => 'Añadir una raíz de confianza';
|
||||
@override String get rootHint => 'Pega un código de identidad (npub…)';
|
||||
@override String get add => 'Añadir';
|
||||
@override String get rootAdded => 'Raíz de confianza añadida';
|
||||
@override String get rootInvalid => 'Ese no es un código de identidad válido';
|
||||
@override String get remove => 'Quitar';
|
||||
@override String get params => 'Parámetros avanzados';
|
||||
@override String get paramsHelp => 'Reglas Duniter. Aflójalas para una red joven; apriétalas según crece.';
|
||||
@override String get sigQty => 'Avales para ser miembro';
|
||||
@override String get stepMax => 'Distancia máxima a una raíz';
|
||||
@override String get validityDays => 'Validez del aval (días)';
|
||||
@override String get reset => 'Restablecer a valores Duniter';
|
||||
@override String get saved => 'Guardado';
|
||||
@override String get title => 'Tu gente';
|
||||
@override String get help => 'Personas que conoces y avalas, y personas que te avalan.';
|
||||
@override String get youVouchFor => 'Tú avalas a';
|
||||
@override String get vouchesForYou => 'Te avalan';
|
||||
@override String get youVouchForEmpty => 'Aún no avalas a nadie. Cuando conozcas a alguien, abre vuestro chat y toca "Conozco a esta persona".';
|
||||
@override String get vouchesForYouEmpty => 'Nadie te avala todavía';
|
||||
@override String get revoke => 'Dejar de avalar';
|
||||
@override String get revokeConfirm => '¿Dejar de avalar a esta persona?';
|
||||
@override String get offline => 'Sin conexión — inténtalo cuando estés en línea';
|
||||
}
|
||||
|
||||
// Path: ratings
|
||||
class _Translations$ratings$es extends Translations$ratings$en {
|
||||
_Translations$ratings$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||
|
||||
final TranslationsEs _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get rate => 'Valorar a esta persona';
|
||||
@override String get edit => 'Editar tu valoración';
|
||||
@override String get commentHint => '¿Qué tal fue? (opcional)';
|
||||
@override String fromYourCircle({required Object n}) => '${n} de gente que conoces';
|
||||
@override String get retract => 'Quitar tu valoración';
|
||||
@override String get saved => 'Valoración guardada';
|
||||
}
|
||||
|
||||
// Path: notifications
|
||||
|
|
@ -1722,26 +1727,21 @@ extension on TranslationsEs {
|
|||
'trust.vouch' => 'Conozco a esta persona',
|
||||
'trust.vouched' => 'Avalas a esta persona',
|
||||
'trust.circle' => 'En tu círculo',
|
||||
'trust.member' => 'Miembro de confianza de la red',
|
||||
'wot.title' => 'Red de confianza',
|
||||
'wot.open' => 'Red de confianza',
|
||||
'wot.help' => 'Cómo se decide quién es miembro de confianza: la gente se avala entre sí, y la confianza se extiende desde un conjunto de raíces fundadoras.',
|
||||
'wot.roots' => 'Raíces de confianza',
|
||||
'wot.rootsHelp' => 'Las identidades fundadoras que anclan la red. Añade a quien te fíes (pega su código de identidad); la membresía se mide hacia fuera desde ellas.',
|
||||
'wot.noRoots' => 'Aún no hay raíces de confianza — no se puede calcular la membresía hasta sembrar la red.',
|
||||
'wot.addRoot' => 'Añadir una raíz de confianza',
|
||||
'wot.rootHint' => 'Pega un código de identidad (npub…)',
|
||||
'wot.add' => 'Añadir',
|
||||
'wot.rootAdded' => 'Raíz de confianza añadida',
|
||||
'wot.rootInvalid' => 'Ese no es un código de identidad válido',
|
||||
'wot.remove' => 'Quitar',
|
||||
'wot.params' => 'Parámetros avanzados',
|
||||
'wot.paramsHelp' => 'Reglas Duniter. Aflójalas para una red joven; apriétalas según crece.',
|
||||
'wot.sigQty' => 'Avales para ser miembro',
|
||||
'wot.stepMax' => 'Distancia máxima a una raíz',
|
||||
'wot.validityDays' => 'Validez del aval (días)',
|
||||
'wot.reset' => 'Restablecer a valores Duniter',
|
||||
'wot.saved' => 'Guardado',
|
||||
'yourPeople.title' => 'Tu gente',
|
||||
'yourPeople.help' => 'Personas que conoces y avalas, y personas que te avalan.',
|
||||
'yourPeople.youVouchFor' => 'Tú avalas a',
|
||||
'yourPeople.vouchesForYou' => 'Te avalan',
|
||||
'yourPeople.youVouchForEmpty' => 'Aún no avalas a nadie. Cuando conozcas a alguien, abre vuestro chat y toca "Conozco a esta persona".',
|
||||
'yourPeople.vouchesForYouEmpty' => 'Nadie te avala todavía',
|
||||
'yourPeople.revoke' => 'Dejar de avalar',
|
||||
'yourPeople.revokeConfirm' => '¿Dejar de avalar a esta persona?',
|
||||
'yourPeople.offline' => 'Sin conexión — inténtalo cuando estés en línea',
|
||||
'ratings.rate' => 'Valorar a esta persona',
|
||||
'ratings.edit' => 'Editar tu valoración',
|
||||
'ratings.commentHint' => '¿Qué tal fue? (opcional)',
|
||||
'ratings.fromYourCircle' => ({required Object n}) => '${n} de gente que conoces',
|
||||
'ratings.retract' => 'Quitar tu valoración',
|
||||
'ratings.saved' => 'Valoración guardada',
|
||||
'notifications.newMessageFrom' => ({required Object name}) => 'Nuevo mensaje de ${name}',
|
||||
'plantare.title' => 'Plantares',
|
||||
'plantare.help' => 'Un Plantare es el compromiso de reproducir una semilla y devolver una parte — así una variedad sigue viajando de mano en mano. No es una venta.',
|
||||
|
|
|
|||
|
|
@ -77,7 +77,8 @@ class TranslationsPt extends Translations with BaseTranslations<AppLocale, Trans
|
|||
@override late final _Translations$chatList$pt chatList = _Translations$chatList$pt._(_root);
|
||||
@override late final _Translations$chat$pt chat = _Translations$chat$pt._(_root);
|
||||
@override late final _Translations$trust$pt trust = _Translations$trust$pt._(_root);
|
||||
@override late final _Translations$wot$pt wot = _Translations$wot$pt._(_root);
|
||||
@override late final _Translations$yourPeople$pt yourPeople = _Translations$yourPeople$pt._(_root);
|
||||
@override late final _Translations$ratings$pt ratings = _Translations$ratings$pt._(_root);
|
||||
@override late final _Translations$notifications$pt notifications = _Translations$notifications$pt._(_root);
|
||||
@override late final _Translations$plantare$pt plantare = _Translations$plantare$pt._(_root);
|
||||
@override late final _Translations$handover$pt handover = _Translations$handover$pt._(_root);
|
||||
|
|
@ -825,35 +826,39 @@ class _Translations$trust$pt extends Translations$trust$en {
|
|||
@override String get vouch => 'Conheço esta pessoa';
|
||||
@override String get vouched => 'Avalizas esta pessoa';
|
||||
@override String get circle => 'No teu círculo';
|
||||
@override String get member => 'Membro de confiança da rede';
|
||||
}
|
||||
|
||||
// Path: wot
|
||||
class _Translations$wot$pt extends Translations$wot$en {
|
||||
_Translations$wot$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
||||
// Path: yourPeople
|
||||
class _Translations$yourPeople$pt extends Translations$yourPeople$en {
|
||||
_Translations$yourPeople$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
||||
|
||||
final TranslationsPt _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get title => 'Rede de confiança';
|
||||
@override String get open => 'Rede de confiança';
|
||||
@override String get help => 'Como se decide quem é membro de confiança: as pessoas avalizam-se entre si, e a confiança estende-se a partir de um conjunto de raízes fundadoras.';
|
||||
@override String get roots => 'Raízes de confiança';
|
||||
@override String get rootsHelp => 'As identidades fundadoras que ancoram a rede. Adiciona quem confias (cola o seu código de identidade); a filiação mede-se para fora a partir delas.';
|
||||
@override String get noRoots => 'Ainda não há raízes de confiança — não se pode calcular a filiação até semear a rede.';
|
||||
@override String get addRoot => 'Adicionar uma raiz de confiança';
|
||||
@override String get rootHint => 'Cola um código de identidade (npub…)';
|
||||
@override String get add => 'Adicionar';
|
||||
@override String get rootAdded => 'Raiz de confiança adicionada';
|
||||
@override String get rootInvalid => 'Esse não é um código de identidade válido';
|
||||
@override String get remove => 'Remover';
|
||||
@override String get params => 'Parâmetros avançados';
|
||||
@override String get paramsHelp => 'Regras Duniter. Afrouxa-as para uma rede jovem; aperta-as à medida que cresce.';
|
||||
@override String get sigQty => 'Avais para ser membro';
|
||||
@override String get stepMax => 'Distância máxima a uma raiz';
|
||||
@override String get validityDays => 'Validade do aval (dias)';
|
||||
@override String get reset => 'Repor para valores Duniter';
|
||||
@override String get saved => 'Guardado';
|
||||
@override String get title => 'A tua gente';
|
||||
@override String get help => 'Pessoas que conheces e avalizas, e pessoas que te avalizam.';
|
||||
@override String get youVouchFor => 'Tu avalizas';
|
||||
@override String get vouchesForYou => 'Avalizam-te';
|
||||
@override String get youVouchForEmpty => 'Ainda não avalizas ninguém. Quando conheceres alguém, abre a vossa conversa e toca em "Conheço esta pessoa".';
|
||||
@override String get vouchesForYouEmpty => 'Ainda ninguém te avaliza';
|
||||
@override String get revoke => 'Deixar de avalizar';
|
||||
@override String get revokeConfirm => 'Deixar de avalizar esta pessoa?';
|
||||
@override String get offline => 'Sem ligação — tenta novamente quando estiveres em linha';
|
||||
}
|
||||
|
||||
// Path: ratings
|
||||
class _Translations$ratings$pt extends Translations$ratings$en {
|
||||
_Translations$ratings$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
||||
|
||||
final TranslationsPt _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get rate => 'Avaliar esta pessoa';
|
||||
@override String get edit => 'Editar a tua avaliação';
|
||||
@override String get commentHint => 'Como correu? (opcional)';
|
||||
@override String fromYourCircle({required Object n}) => '${n} de pessoas que conheces';
|
||||
@override String get retract => 'Remover a tua avaliação';
|
||||
@override String get saved => 'Avaliação guardada';
|
||||
}
|
||||
|
||||
// Path: notifications
|
||||
|
|
@ -1716,26 +1721,21 @@ extension on TranslationsPt {
|
|||
'trust.vouch' => 'Conheço esta pessoa',
|
||||
'trust.vouched' => 'Avalizas esta pessoa',
|
||||
'trust.circle' => 'No teu círculo',
|
||||
'trust.member' => 'Membro de confiança da rede',
|
||||
'wot.title' => 'Rede de confiança',
|
||||
'wot.open' => 'Rede de confiança',
|
||||
'wot.help' => 'Como se decide quem é membro de confiança: as pessoas avalizam-se entre si, e a confiança estende-se a partir de um conjunto de raízes fundadoras.',
|
||||
'wot.roots' => 'Raízes de confiança',
|
||||
'wot.rootsHelp' => 'As identidades fundadoras que ancoram a rede. Adiciona quem confias (cola o seu código de identidade); a filiação mede-se para fora a partir delas.',
|
||||
'wot.noRoots' => 'Ainda não há raízes de confiança — não se pode calcular a filiação até semear a rede.',
|
||||
'wot.addRoot' => 'Adicionar uma raiz de confiança',
|
||||
'wot.rootHint' => 'Cola um código de identidade (npub…)',
|
||||
'wot.add' => 'Adicionar',
|
||||
'wot.rootAdded' => 'Raiz de confiança adicionada',
|
||||
'wot.rootInvalid' => 'Esse não é um código de identidade válido',
|
||||
'wot.remove' => 'Remover',
|
||||
'wot.params' => 'Parâmetros avançados',
|
||||
'wot.paramsHelp' => 'Regras Duniter. Afrouxa-as para uma rede jovem; aperta-as à medida que cresce.',
|
||||
'wot.sigQty' => 'Avais para ser membro',
|
||||
'wot.stepMax' => 'Distância máxima a uma raiz',
|
||||
'wot.validityDays' => 'Validade do aval (dias)',
|
||||
'wot.reset' => 'Repor para valores Duniter',
|
||||
'wot.saved' => 'Guardado',
|
||||
'yourPeople.title' => 'A tua gente',
|
||||
'yourPeople.help' => 'Pessoas que conheces e avalizas, e pessoas que te avalizam.',
|
||||
'yourPeople.youVouchFor' => 'Tu avalizas',
|
||||
'yourPeople.vouchesForYou' => 'Avalizam-te',
|
||||
'yourPeople.youVouchForEmpty' => 'Ainda não avalizas ninguém. Quando conheceres alguém, abre a vossa conversa e toca em "Conheço esta pessoa".',
|
||||
'yourPeople.vouchesForYouEmpty' => 'Ainda ninguém te avaliza',
|
||||
'yourPeople.revoke' => 'Deixar de avalizar',
|
||||
'yourPeople.revokeConfirm' => 'Deixar de avalizar esta pessoa?',
|
||||
'yourPeople.offline' => 'Sem ligação — tenta novamente quando estiveres em linha',
|
||||
'ratings.rate' => 'Avaliar esta pessoa',
|
||||
'ratings.edit' => 'Editar a tua avaliação',
|
||||
'ratings.commentHint' => 'Como correu? (opcional)',
|
||||
'ratings.fromYourCircle' => ({required Object n}) => '${n} de pessoas que conheces',
|
||||
'ratings.retract' => 'Remover a tua avaliação',
|
||||
'ratings.saved' => 'Avaliação guardada',
|
||||
'notifications.newMessageFrom' => ({required Object name}) => 'Nova mensagem de ${name}',
|
||||
'plantare.title' => 'Plantares',
|
||||
'plantare.help' => 'Um Plantare é o compromisso de reproduzir uma semente e devolver uma parte — assim uma variedade continua a viajar de mão em mão. Não é uma venda.',
|
||||
|
|
|
|||
|
|
@ -89,13 +89,14 @@ class SocialService {
|
|||
}
|
||||
}
|
||||
|
||||
/// One relay channel with the three transports on top — the
|
||||
/// "one channel, three interfaces" shape, at the app layer.
|
||||
/// One relay channel with the transports on top — the
|
||||
/// "one channel, N interfaces" shape, at the app layer.
|
||||
class SocialSession {
|
||||
SocialSession(this._channel, {String deviceId = ''})
|
||||
: offers = NostrOfferTransport(_channel),
|
||||
messages = NostrMessageTransport(_channel),
|
||||
trust = NostrTrustTransport(_channel),
|
||||
ratings = NostrRatingTransport(_channel),
|
||||
profile = NostrProfileTransport(_channel),
|
||||
sync = NostrSyncTransport(
|
||||
_channel,
|
||||
|
|
@ -108,6 +109,7 @@ class SocialSession {
|
|||
final OfferTransport offers;
|
||||
final MessageTransport messages;
|
||||
final TrustTransport trust;
|
||||
final RatingTransport ratings;
|
||||
final ProfileTransport profile;
|
||||
|
||||
/// Device-to-device inventory sync for THIS identity's own devices.
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
|
||||
import '../security/secret_store.dart';
|
||||
|
||||
/// The web-of-trust bootstrap referents (Duniter "seeds"): identities every
|
||||
/// membership calculation trusts by construction. The network's roots.
|
||||
///
|
||||
/// Sources, unioned: a bundled asset (`assets/trust/referents.json`, a curated
|
||||
/// founding set — empty until real founders are added) plus referents the user
|
||||
/// adds in-app (by npub / QR), kept in the keystore. All keys are stored and
|
||||
/// returned as 64-char hex. Solving cold-start honestly: no invented identities
|
||||
/// ship, and a user can bootstrap their own roots.
|
||||
class TrustReferents {
|
||||
TrustReferents(this._store, {this.assetPath = 'assets/trust/referents.json'});
|
||||
|
||||
final SecretStore _store;
|
||||
final String assetPath;
|
||||
|
||||
static const _userKey = 'tane.social.trust.referents';
|
||||
|
||||
List<String>? _bundledCache;
|
||||
|
||||
/// Every referent (bundled ∪ user-added), as hex public keys.
|
||||
Future<Set<String>> all() async =>
|
||||
{...await _bundled(), ...await userAdded()};
|
||||
|
||||
/// Referents the user added themselves (hex), for the management UI.
|
||||
Future<Set<String>> userAdded() async {
|
||||
final raw = await _store.read(_userKey);
|
||||
if (raw == null || raw.isEmpty) return {};
|
||||
return raw.split('\n').where((s) => s.isNotEmpty).toSet();
|
||||
}
|
||||
|
||||
/// Adds a referent from an [npubOrHex]; returns its hex form. Idempotent.
|
||||
/// Throws [FormatException] if it is neither an npub nor a hex key.
|
||||
Future<String> add(String npubOrHex) async {
|
||||
final hex = npubToHex(npubOrHex); // validates
|
||||
final current = await userAdded();
|
||||
if (current.add(hex)) {
|
||||
await _store.write(_userKey, current.join('\n'));
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
/// Removes a user-added referent (bundled ones can't be removed).
|
||||
Future<void> remove(String hex) async {
|
||||
final current = await userAdded()..remove(hex);
|
||||
await _store.write(_userKey, current.join('\n'));
|
||||
}
|
||||
|
||||
Future<List<String>> _bundled() async {
|
||||
final cached = _bundledCache;
|
||||
if (cached != null) return cached;
|
||||
try {
|
||||
final decoded = jsonDecode(await rootBundle.loadString(assetPath));
|
||||
final list = (decoded is Map ? decoded['referents'] : decoded) as List?;
|
||||
final out = <String>[];
|
||||
for (final entry in list ?? const []) {
|
||||
try {
|
||||
out.add(npubToHex(entry as String));
|
||||
} catch (_) {
|
||||
// Skip a malformed entry rather than break the whole set.
|
||||
}
|
||||
}
|
||||
return _bundledCache = out;
|
||||
} catch (_) {
|
||||
return _bundledCache = const []; // no/invalid asset → no bundled referents
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import 'package:commons_core/commons_core.dart';
|
||||
|
||||
import '../security/secret_store.dart';
|
||||
|
||||
/// Persists the web-of-trust parameters (Duniter sigQty / stepMax / validity),
|
||||
/// so a young network can loosen them and tighten as it grows — exactly how Ğ1
|
||||
/// itself evolved. Defaults to [WotParams.duniter]. Keystore-backed.
|
||||
class WotSettings {
|
||||
WotSettings(this._store);
|
||||
|
||||
final SecretStore _store;
|
||||
|
||||
static const _sigQtyKey = 'tane.social.wot.sigQty';
|
||||
static const _stepMaxKey = 'tane.social.wot.stepMax';
|
||||
static const _validityDaysKey = 'tane.social.wot.validityDays';
|
||||
|
||||
/// The active parameters (any unset field falls back to the Duniter default).
|
||||
Future<WotParams> params() async {
|
||||
const base = WotParams.duniter;
|
||||
final sigQty = await _readInt(_sigQtyKey) ?? base.sigQty;
|
||||
final stepMax = await _readInt(_stepMaxKey) ?? base.stepMax;
|
||||
final validityDays =
|
||||
await _readInt(_validityDaysKey) ?? base.sigValidity.inDays;
|
||||
return WotParams(
|
||||
sigQty: sigQty,
|
||||
stepMax: stepMax,
|
||||
sigValidity: Duration(days: validityDays),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> save(WotParams params) async {
|
||||
await _store.write(_sigQtyKey, '${params.sigQty}');
|
||||
await _store.write(_stepMaxKey, '${params.stepMax}');
|
||||
await _store.write(_validityDaysKey, '${params.sigValidity.inDays}');
|
||||
}
|
||||
|
||||
/// Restores the Ğ1 reference values.
|
||||
Future<void> reset() => save(WotParams.duniter);
|
||||
|
||||
Future<int?> _readInt(String key) async {
|
||||
final raw = await _store.read(key);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
return int.tryParse(raw);
|
||||
}
|
||||
}
|
||||
145
apps/app_seeds/lib/state/peer_rating_cubit.dart
Normal file
145
apps/app_seeds/lib/state/peer_rating_cubit.dart
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
/// Reputation of one peer, seen from this user's position: everyone's active
|
||||
/// ratings plus how many of those come from people in your own circle — the
|
||||
/// signal that makes strangers' review-stuffing irrelevant.
|
||||
class PeerRatingState extends Equatable {
|
||||
const PeerRatingState({
|
||||
this.count = 0,
|
||||
this.average = 0,
|
||||
this.circleCount = 0,
|
||||
this.myStars,
|
||||
this.myComment = '',
|
||||
this.loading = true,
|
||||
this.busy = false,
|
||||
});
|
||||
|
||||
/// Active ratings of the peer, one per rater.
|
||||
final int count;
|
||||
|
||||
/// Mean stars over [count]; 0 when there are none.
|
||||
final double average;
|
||||
|
||||
/// How many of those ratings come from your circle (you or a
|
||||
/// friend-of-a-friend). These are the ones worth highlighting.
|
||||
final int circleCount;
|
||||
|
||||
/// Your own rating of the peer, if any.
|
||||
final int? myStars;
|
||||
final String myComment;
|
||||
|
||||
final bool loading;
|
||||
final bool busy;
|
||||
|
||||
bool get hasRatings => count > 0;
|
||||
bool get iRated => myStars != null;
|
||||
|
||||
PeerRatingState copyWith({
|
||||
int? count,
|
||||
double? average,
|
||||
int? circleCount,
|
||||
int? Function()? myStars,
|
||||
String? myComment,
|
||||
bool? loading,
|
||||
bool? busy,
|
||||
}) =>
|
||||
PeerRatingState(
|
||||
count: count ?? this.count,
|
||||
average: average ?? this.average,
|
||||
circleCount: circleCount ?? this.circleCount,
|
||||
myStars: myStars != null ? myStars() : this.myStars,
|
||||
myComment: myComment ?? this.myComment,
|
||||
loading: loading ?? this.loading,
|
||||
busy: busy ?? this.busy,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[count, average, circleCount, myStars, myComment, loading, busy];
|
||||
}
|
||||
|
||||
/// Reads and edits this user's rating of [peerPubkey] over a
|
||||
/// [RatingTransport], and summarises everyone else's. The circle weighting
|
||||
/// reuses the trust graph (same ego-centric rule as TrustCubit).
|
||||
class PeerRatingCubit extends Cubit<PeerRatingState> {
|
||||
PeerRatingCubit(
|
||||
this._ratings, {
|
||||
required this.peerPubkey,
|
||||
required this.selfPubkey,
|
||||
TrustTransport? trust,
|
||||
}) : _trust = trust,
|
||||
super(const PeerRatingState());
|
||||
|
||||
final RatingTransport? _ratings;
|
||||
final TrustTransport? _trust;
|
||||
final String peerPubkey;
|
||||
final String selfPubkey;
|
||||
|
||||
bool get isOnline => _ratings != null;
|
||||
|
||||
/// Same personal "circle" rule as TrustCubit: one vouch from your side, out
|
||||
/// to a friend-of-a-friend.
|
||||
static const _circleThreshold = 1;
|
||||
static const _circleDistance = 2;
|
||||
|
||||
Future<void> load() async {
|
||||
final ratings = _ratings;
|
||||
if (ratings == null) {
|
||||
emit(state.copyWith(loading: false));
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(loading: true));
|
||||
final all = await ratings.ratingsOf(peerPubkey);
|
||||
final mine = await ratings.myRatingOf(peerPubkey);
|
||||
|
||||
var circle = const <String>{};
|
||||
final trust = _trust;
|
||||
if (trust != null && all.isNotEmpty) {
|
||||
final wot = WebOfTrust.fromCertifications(
|
||||
await trust.allCertifications(),
|
||||
now: DateTime.now(),
|
||||
);
|
||||
circle = wot.members(
|
||||
seeds: {selfPubkey},
|
||||
threshold: _circleThreshold,
|
||||
maxDistance: _circleDistance,
|
||||
);
|
||||
}
|
||||
|
||||
final total = all.fold<int>(0, (sum, r) => sum + r.stars);
|
||||
emit(state.copyWith(
|
||||
count: all.length,
|
||||
average: all.isEmpty ? 0 : total / all.length,
|
||||
circleCount: all.where((r) => circle.contains(r.rater)).length,
|
||||
myStars: () => mine?.stars,
|
||||
myComment: mine?.comment ?? '',
|
||||
loading: false,
|
||||
));
|
||||
}
|
||||
|
||||
/// Publishes (or replaces) your rating, then reloads. Never rates self.
|
||||
Future<void> rate(int stars, {String comment = ''}) async {
|
||||
final ratings = _ratings;
|
||||
if (ratings == null || peerPubkey == selfPubkey) return;
|
||||
emit(state.copyWith(busy: true));
|
||||
await ratings.rate(
|
||||
subjectPubkey: peerPubkey,
|
||||
stars: stars,
|
||||
comment: comment,
|
||||
);
|
||||
await load();
|
||||
emit(state.copyWith(busy: false));
|
||||
}
|
||||
|
||||
/// Takes your rating back, then reloads.
|
||||
Future<void> retract() async {
|
||||
final ratings = _ratings;
|
||||
if (ratings == null) return;
|
||||
emit(state.copyWith(busy: true));
|
||||
await ratings.retract(subjectPubkey: peerPubkey);
|
||||
await load();
|
||||
emit(state.copyWith(busy: false));
|
||||
}
|
||||
}
|
||||
|
|
@ -2,32 +2,28 @@ import 'package:commons_core/commons_core.dart';
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
/// Where a peer stands, from strongest to weakest signal.
|
||||
/// Where a peer stands, from strongest to weakest signal. Trust is
|
||||
/// ego-centric: computed from YOUR position in the public vouch graph,
|
||||
/// never from a global membership verdict.
|
||||
enum TrustTier {
|
||||
/// Passes the full Duniter membership rule (sigQty certifications from members,
|
||||
/// within stepMax of a bootstrap referent).
|
||||
networkMember,
|
||||
|
||||
/// In your personal circle (you vouch, or a friend-of-a-friend does).
|
||||
inYourCircle,
|
||||
|
||||
/// Vouched for by someone, but not (yet) a member nor in your circle.
|
||||
/// Vouched for by someone, but not (yet) in your circle.
|
||||
vouched,
|
||||
|
||||
/// No certifications seen.
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// Trust standing of one peer: the full Duniter membership verdict, your own
|
||||
/// circle, and the raw certifier count — computed from the public certification
|
||||
/// graph with the active [WotParams] and bootstrap referents.
|
||||
/// Trust standing of one peer, seen from this user's position: your own
|
||||
/// vouch, your circle (friend-of-a-friend), and the raw certifier count —
|
||||
/// computed from the public certification graph.
|
||||
class TrustState extends Equatable {
|
||||
const TrustState({
|
||||
this.certifierCount = 0,
|
||||
this.iVouch = false,
|
||||
this.knownToYou = false,
|
||||
this.isNetworkMember = false,
|
||||
this.networkBootstrapped = false,
|
||||
this.loading = true,
|
||||
this.busy = false,
|
||||
});
|
||||
|
|
@ -42,21 +38,11 @@ class TrustState extends Equatable {
|
|||
/// friend-of-a-friend, vouch). Spam-resistant and works from day one.
|
||||
final bool knownToYou;
|
||||
|
||||
/// Whether the peer is a full member per the Duniter rule (sigQty + stepMax
|
||||
/// from the bootstrap referents).
|
||||
final bool isNetworkMember;
|
||||
|
||||
/// Whether any bootstrap referents exist at all. When false, network
|
||||
/// membership can't be determined yet (the trust net isn't seeded) and the UI
|
||||
/// should say so instead of implying the peer failed the rule.
|
||||
final bool networkBootstrapped;
|
||||
|
||||
final bool loading;
|
||||
final bool busy;
|
||||
|
||||
/// The strongest applicable signal, for the badge.
|
||||
TrustTier get tier {
|
||||
if (isNetworkMember) return TrustTier.networkMember;
|
||||
if (knownToYou) return TrustTier.inYourCircle;
|
||||
if (certifierCount > 0) return TrustTier.vouched;
|
||||
return TrustTier.unknown;
|
||||
|
|
@ -66,8 +52,6 @@ class TrustState extends Equatable {
|
|||
int? certifierCount,
|
||||
bool? iVouch,
|
||||
bool? knownToYou,
|
||||
bool? isNetworkMember,
|
||||
bool? networkBootstrapped,
|
||||
bool? loading,
|
||||
bool? busy,
|
||||
}) =>
|
||||
|
|
@ -75,8 +59,6 @@ class TrustState extends Equatable {
|
|||
certifierCount: certifierCount ?? this.certifierCount,
|
||||
iVouch: iVouch ?? this.iVouch,
|
||||
knownToYou: knownToYou ?? this.knownToYou,
|
||||
isNetworkMember: isNetworkMember ?? this.isNetworkMember,
|
||||
networkBootstrapped: networkBootstrapped ?? this.networkBootstrapped,
|
||||
loading: loading ?? this.loading,
|
||||
busy: busy ?? this.busy,
|
||||
);
|
||||
|
|
@ -86,23 +68,19 @@ class TrustState extends Equatable {
|
|||
certifierCount,
|
||||
iVouch,
|
||||
knownToYou,
|
||||
isNetworkMember,
|
||||
networkBootstrapped,
|
||||
loading,
|
||||
busy,
|
||||
];
|
||||
}
|
||||
|
||||
/// Reads and toggles this user's vouch for [peerPubkey] over a [TrustTransport],
|
||||
/// and computes the full Duniter membership verdict against the bootstrap
|
||||
/// [referents] and active [params].
|
||||
/// Reads and toggles this user's vouch for [peerPubkey] over a
|
||||
/// [TrustTransport], and computes the peer's standing from this user's own
|
||||
/// position in the vouch graph (ego-centric — no referents, no parameters).
|
||||
class TrustCubit extends Cubit<TrustState> {
|
||||
TrustCubit(
|
||||
this._transport, {
|
||||
required this.peerPubkey,
|
||||
required this.selfPubkey,
|
||||
this.referents = const {},
|
||||
this.params = WotParams.duniter,
|
||||
Future<void> Function()? onDispose,
|
||||
}) : _onDispose = onDispose,
|
||||
super(const TrustState());
|
||||
|
|
@ -110,26 +88,20 @@ class TrustCubit extends Cubit<TrustState> {
|
|||
final TrustTransport? _transport;
|
||||
final String peerPubkey;
|
||||
final String selfPubkey;
|
||||
|
||||
/// Bootstrap referents (Duniter seeds) the membership rule trusts by
|
||||
/// construction. Empty = the network isn't seeded yet.
|
||||
final Set<String> referents;
|
||||
|
||||
/// Active web-of-trust parameters (sigQty / stepMax / validity).
|
||||
final WotParams params;
|
||||
final Future<void> Function()? _onDispose;
|
||||
|
||||
bool get isOnline => _transport != null;
|
||||
|
||||
/// Personal "circle" rule: one vouch from your side, out to a friend-of-a-
|
||||
/// friend. Loose by design — "people near you", separate from formal
|
||||
/// membership.
|
||||
/// friend. Loose by design — "people near you".
|
||||
static const _circleThreshold = 1;
|
||||
static const _circleDistance = 2;
|
||||
|
||||
/// Vouches expire and must be renewed, so stale trust prunes itself.
|
||||
static const _vouchValidity = Duration(days: 365);
|
||||
|
||||
/// Loads the certification graph and computes: the peer's certifier count,
|
||||
/// whether you vouch, whether they're in your circle, and whether they pass
|
||||
/// the full Duniter membership rule against the bootstrap referents.
|
||||
/// whether you vouch, and whether they're in your circle.
|
||||
Future<void> load() async {
|
||||
final transport = _transport;
|
||||
if (transport == null) {
|
||||
|
|
@ -147,23 +119,17 @@ class TrustCubit extends Cubit<TrustState> {
|
|||
threshold: _circleThreshold,
|
||||
maxDistance: _circleDistance,
|
||||
);
|
||||
// Full membership: only meaningful once the trust net has referents.
|
||||
final members = referents.isEmpty
|
||||
? const <String>{}
|
||||
: wot.membersWith(seeds: referents, params: params);
|
||||
emit(state.copyWith(
|
||||
certifierCount: certifiers.length,
|
||||
iVouch: certifiers.contains(selfPubkey),
|
||||
knownToYou: circle.contains(peerPubkey),
|
||||
isNetworkMember: members.contains(peerPubkey),
|
||||
networkBootstrapped: referents.isNotEmpty,
|
||||
loading: false,
|
||||
));
|
||||
}
|
||||
|
||||
/// Adds or removes this user's vouch, then reloads. Never vouches for self.
|
||||
/// The certification is issued with the active validity so it expires and
|
||||
/// must be renewed (Duniter rule).
|
||||
/// The certification is issued with a validity so it expires and must be
|
||||
/// renewed.
|
||||
Future<void> toggleVouch() async {
|
||||
final transport = _transport;
|
||||
if (transport == null || peerPubkey == selfPubkey) return;
|
||||
|
|
@ -173,7 +139,7 @@ class TrustCubit extends Cubit<TrustState> {
|
|||
} else {
|
||||
await transport.certify(
|
||||
subjectPubkey: peerPubkey,
|
||||
validity: params.sigValidity,
|
||||
validity: _vouchValidity,
|
||||
);
|
||||
}
|
||||
await load();
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@ import '../services/profile_cache.dart';
|
|||
import '../services/profile_store.dart';
|
||||
import '../services/social_connection.dart';
|
||||
import '../services/social_service.dart';
|
||||
import '../services/trust_referents.dart';
|
||||
import '../services/unread_service.dart';
|
||||
import '../services/wot_settings.dart';
|
||||
import '../state/messages_cubit.dart';
|
||||
import '../state/peer_rating_cubit.dart';
|
||||
import '../state/trust_cubit.dart';
|
||||
import 'peer_avatar.dart';
|
||||
import 'rating_sheet.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// A 1:1 encrypted chat with one peer (redesign screen 10). Private and
|
||||
|
|
@ -33,8 +33,6 @@ class ChatScreen extends StatefulWidget {
|
|||
required this.peerPubkey,
|
||||
this.messageStore,
|
||||
this.profileCache,
|
||||
this.trustReferents,
|
||||
this.wotSettings,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -51,10 +49,6 @@ class ChatScreen extends StatefulWidget {
|
|||
/// Optional cache of peer display names; null in tests.
|
||||
final ProfileCache? profileCache;
|
||||
|
||||
/// Web-of-trust bootstrap referents + parameters, for the membership verdict.
|
||||
final TrustReferents? trustReferents;
|
||||
final WotSettings? wotSettings;
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
|
@ -62,6 +56,7 @@ class ChatScreen extends StatefulWidget {
|
|||
class _ChatScreenState extends State<ChatScreen> {
|
||||
MessagesCubit? _messages;
|
||||
TrustCubit? _trust;
|
||||
PeerRatingCubit? _rating;
|
||||
bool _loading = true;
|
||||
String? _peerName;
|
||||
String? _selfName;
|
||||
|
|
@ -95,8 +90,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
final selfName = getIt.isRegistered<ProfileStore>()
|
||||
? await getIt<ProfileStore>().name()
|
||||
: '';
|
||||
final referents = await widget.trustReferents?.all() ?? const <String>{};
|
||||
final wotParams = await widget.wotSettings?.params() ?? WotParams.duniter;
|
||||
if (!mounted) return; // shared session is owned by the connection, not us
|
||||
final self = widget.social.publicKeyHex;
|
||||
final messages = MessagesCubit(
|
||||
|
|
@ -109,13 +102,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
session?.trust,
|
||||
peerPubkey: widget.peerPubkey,
|
||||
selfPubkey: self,
|
||||
referents: referents,
|
||||
params: wotParams,
|
||||
);
|
||||
unawaited(trust.load());
|
||||
final rating = PeerRatingCubit(
|
||||
session?.ratings,
|
||||
peerPubkey: widget.peerPubkey,
|
||||
selfPubkey: self,
|
||||
trust: session?.trust,
|
||||
);
|
||||
unawaited(rating.load());
|
||||
setState(() {
|
||||
_messages = messages;
|
||||
_trust = trust;
|
||||
_rating = rating;
|
||||
_peerName = cachedName;
|
||||
_selfName = selfName.isEmpty ? null : selfName;
|
||||
_loading = false;
|
||||
|
|
@ -193,6 +192,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
unawaited(_unread?.markRead(widget.peerPubkey));
|
||||
_messages?.close();
|
||||
_trust?.close();
|
||||
_rating?.close();
|
||||
// The session belongs to the shared connection — don't close it here.
|
||||
_input.dispose();
|
||||
super.dispose();
|
||||
|
|
@ -202,6 +202,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
Widget build(BuildContext context) {
|
||||
final messages = _messages;
|
||||
final trust = _trust;
|
||||
final rating = _rating;
|
||||
final t = context.t;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
|
|
@ -216,12 +217,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
body: _loading || messages == null || trust == null
|
||||
body: _loading || messages == null || trust == null || rating == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: messages),
|
||||
BlocProvider.value(value: trust),
|
||||
BlocProvider.value(value: rating),
|
||||
],
|
||||
child: _ChatBody(
|
||||
controller: _input,
|
||||
|
|
@ -274,6 +276,7 @@ class _ChatBody extends StatelessWidget {
|
|||
child: Column(
|
||||
children: [
|
||||
const _TrustBanner(),
|
||||
const _RatingStrip(),
|
||||
Expanded(
|
||||
child: ChatMessageList(peerName: peerName, selfName: selfName),
|
||||
),
|
||||
|
|
@ -348,10 +351,9 @@ class _TrustBanner extends StatelessWidget {
|
|||
builder: (context, state) {
|
||||
final cubit = context.read<TrustCubit>();
|
||||
if (!cubit.isOnline || state.loading) return const SizedBox.shrink();
|
||||
// Strongest applicable signal decides the badge (member > circle >
|
||||
// vouched > unknown).
|
||||
// Strongest applicable signal decides the badge (circle > vouched >
|
||||
// unknown).
|
||||
final (IconData icon, String label, bool strong) = switch (state.tier) {
|
||||
TrustTier.networkMember => (Icons.verified, t.trust.member, true),
|
||||
TrustTier.inYourCircle => (Icons.verified_user, t.trust.circle, true),
|
||||
TrustTier.vouched => (
|
||||
Icons.people_outline,
|
||||
|
|
@ -391,6 +393,56 @@ class _TrustBanner extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// A slim reputation strip under the trust banner: everyone's stars for this
|
||||
/// peer (highlighting how many come from people you know) and, once you've
|
||||
/// actually talked with them, a button to leave or edit your own rating.
|
||||
class _RatingStrip extends StatelessWidget {
|
||||
const _RatingStrip();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
return BlocBuilder<PeerRatingCubit, PeerRatingState>(
|
||||
builder: (context, state) {
|
||||
final cubit = context.read<PeerRatingCubit>();
|
||||
if (!cubit.isOnline || state.loading) return const SizedBox.shrink();
|
||||
// Soft anchor: you can only rate someone you've talked with.
|
||||
final canRate = context.select<MessagesCubit, bool>(
|
||||
(m) => m.state.messages.isNotEmpty,
|
||||
);
|
||||
if (!state.hasRatings && !canRate) return const SizedBox.shrink();
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: seedPrimaryContainer.withValues(alpha: 0.3),
|
||||
padding: const EdgeInsetsDirectional.fromSTEB(16, 4, 8, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: state.hasRatings
|
||||
? RatingStars(
|
||||
average: state.average,
|
||||
count: state.count,
|
||||
circleCount: state.circleCount,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
if (canRate)
|
||||
TextButton(
|
||||
key: const Key('chat.rate'),
|
||||
onPressed: state.busy
|
||||
? null
|
||||
: () => showRatingSheet(context, cubit),
|
||||
child:
|
||||
Text(state.iRated ? t.ratings.edit : t.ratings.rate),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A centered pill introducing a new day ("Today", "Yesterday", or a
|
||||
/// locale-formatted date). Localized via [chatDayLabel].
|
||||
class _DaySeparator extends StatelessWidget {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ import 'package:go_router/go_router.dart';
|
|||
import '../i18n/strings.g.dart';
|
||||
import '../services/profile_cache.dart';
|
||||
import '../services/social_connection.dart';
|
||||
import '../services/social_service.dart';
|
||||
import '../state/peer_rating_cubit.dart';
|
||||
import 'market_widgets.dart';
|
||||
import 'rating_sheet.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// Read-only "product page" for one discovered [Offer] (the Wallapop-style
|
||||
|
|
@ -22,6 +25,7 @@ class MarketOfferDetailScreen extends StatefulWidget {
|
|||
required this.connection,
|
||||
this.mine = false,
|
||||
this.profileCache,
|
||||
this.selfPubkey,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -36,6 +40,10 @@ class MarketOfferDetailScreen extends StatefulWidget {
|
|||
/// Optional cache of peers' published display names; null in tests.
|
||||
final ProfileCache? profileCache;
|
||||
|
||||
/// This user's key (hex), to weigh the author's ratings by your own circle;
|
||||
/// null in tests (circle weighting then stays off).
|
||||
final String? selfPubkey;
|
||||
|
||||
@override
|
||||
State<MarketOfferDetailScreen> createState() =>
|
||||
_MarketOfferDetailScreenState();
|
||||
|
|
@ -46,6 +54,7 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
|||
String? _sellerAbout;
|
||||
String? _sellerG1;
|
||||
bool _profileLoading = true;
|
||||
PeerRatingState? _sellerRating;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -65,6 +74,7 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
|||
setState(() => _profileLoading = false);
|
||||
return;
|
||||
}
|
||||
unawaited(_loadSellerRating(session));
|
||||
try {
|
||||
final profile = await session.profile.fetch(widget.offer.authorPubkeyHex);
|
||||
if (profile != null && profile.name.isNotEmpty) {
|
||||
|
|
@ -86,6 +96,25 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Best-effort summary of the author's ratings, circle-weighted when we know
|
||||
/// who this user is. Reuses the cubit's aggregation without providing it.
|
||||
Future<void> _loadSellerRating(SocialSession session) async {
|
||||
final cubit = PeerRatingCubit(
|
||||
session.ratings,
|
||||
peerPubkey: widget.offer.authorPubkeyHex,
|
||||
selfPubkey: widget.selfPubkey ?? '',
|
||||
trust: widget.selfPubkey == null ? null : session.trust,
|
||||
);
|
||||
try {
|
||||
await cubit.load();
|
||||
if (mounted) setState(() => _sellerRating = cubit.state);
|
||||
} catch (_) {
|
||||
// offline / unreachable — the seller section simply shows no stars.
|
||||
} finally {
|
||||
await cubit.close();
|
||||
}
|
||||
}
|
||||
|
||||
// No dispose of the session — it belongs to the shared connection.
|
||||
|
||||
Future<void> _copyId() async {
|
||||
|
|
@ -167,6 +196,7 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
|||
title: t.market.sharedBy,
|
||||
name: _sellerName ?? shortPubkey(o.authorPubkeyHex),
|
||||
about: _sellerAbout,
|
||||
rating: _sellerRating,
|
||||
loading: _profileLoading,
|
||||
hasProfile:
|
||||
_sellerName != null || _sellerAbout != null || _sellerG1 != null,
|
||||
|
|
@ -227,11 +257,16 @@ class _SellerSection extends StatelessWidget {
|
|||
required this.noProfileLabel,
|
||||
required this.onCopyId,
|
||||
required this.copyIdTooltip,
|
||||
this.rating,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String name;
|
||||
final String? about;
|
||||
|
||||
/// Circle-weighted summary of the author's ratings; stars show only when
|
||||
/// someone actually rated them (absence is never a reproach).
|
||||
final PeerRatingState? rating;
|
||||
final bool loading;
|
||||
final bool hasProfile;
|
||||
final String noProfileLabel;
|
||||
|
|
@ -261,13 +296,26 @@ class _SellerSection extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: seedOnSurface,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: seedOnSurface,
|
||||
),
|
||||
),
|
||||
if (rating != null && rating!.hasRatings) ...[
|
||||
const SizedBox(height: 2),
|
||||
RatingStars(
|
||||
average: rating!.average,
|
||||
count: rating!.count,
|
||||
circleCount: rating!.circleCount,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ class ProfileScreen extends StatefulWidget {
|
|||
required this.connection,
|
||||
required this.profileStore,
|
||||
required this.accounts,
|
||||
this.trustNetworkEnabled = false,
|
||||
this.yourPeopleEnabled = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -33,8 +33,8 @@ class ProfileScreen extends StatefulWidget {
|
|||
final ProfileStore profileStore;
|
||||
final SocialAccountStore accounts;
|
||||
|
||||
/// Whether to offer the "Network of trust" entry (needs the WoT services).
|
||||
final bool trustNetworkEnabled;
|
||||
/// Whether to offer the "Your people" entry (needs the social layer).
|
||||
final bool yourPeopleEnabled;
|
||||
|
||||
@override
|
||||
State<ProfileScreen> createState() => _ProfileScreenState();
|
||||
|
|
@ -210,16 +210,17 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||
onSwitch: (a) => _switchTo(a),
|
||||
onNew: _newIdentity,
|
||||
),
|
||||
if (widget.trustNetworkEnabled) ...[
|
||||
if (widget.yourPeopleEnabled) ...[
|
||||
const SizedBox(height: 12),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
key: const Key('profile.trustNetwork'),
|
||||
key: const Key('profile.yourPeople'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.hub_outlined, color: seedGreen),
|
||||
title: Text(t.wot.open),
|
||||
leading:
|
||||
const Icon(Icons.people_alt_outlined, color: seedGreen),
|
||||
title: Text(t.yourPeople.title),
|
||||
trailing: const Icon(Icons.chevron_right, color: seedMuted),
|
||||
onTap: () => context.push('/trust'),
|
||||
onTap: () => context.push('/your-people'),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
|
|
|||
180
apps/app_seeds/lib/ui/rating_sheet.dart
Normal file
180
apps/app_seeds/lib/ui/rating_sheet.dart
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../state/peer_rating_cubit.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// A compact "★ 4,5 (12)" pill, plus how many ratings come from people you
|
||||
/// know — the signal that makes strangers' reviews easy to weigh.
|
||||
class RatingStars extends StatelessWidget {
|
||||
const RatingStars({
|
||||
required this.average,
|
||||
required this.count,
|
||||
this.circleCount = 0,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final double average;
|
||||
final int count;
|
||||
final int circleCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final avg = NumberFormat('0.#', locale).format(average);
|
||||
return Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 6,
|
||||
children: [
|
||||
const Icon(Icons.star_rounded, size: 18, color: seedGreen),
|
||||
Text(
|
||||
'$avg ($count)',
|
||||
style: const TextStyle(
|
||||
color: seedOnSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (circleCount > 0)
|
||||
Text(
|
||||
t.ratings.fromYourCircle(n: circleCount),
|
||||
style: const TextStyle(color: seedGreen, fontSize: 13),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the bottom sheet to leave, edit or take back your rating of the
|
||||
/// peer behind [cubit]. Pre-filled when you already rated.
|
||||
Future<void> showRatingSheet(BuildContext context, PeerRatingCubit cubit) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: _RatingForm(cubit: cubit),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _RatingForm extends StatefulWidget {
|
||||
const _RatingForm({required this.cubit});
|
||||
|
||||
final PeerRatingCubit cubit;
|
||||
|
||||
@override
|
||||
State<_RatingForm> createState() => _RatingFormState();
|
||||
}
|
||||
|
||||
class _RatingFormState extends State<_RatingForm> {
|
||||
late int _stars;
|
||||
late final TextEditingController _comment;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final state = widget.cubit.state;
|
||||
_stars = state.myStars ?? 0;
|
||||
_comment = TextEditingController(text: state.myComment);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_comment.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
final saved = context.t.ratings.saved;
|
||||
setState(() => _saving = true);
|
||||
await widget.cubit.rate(_stars, comment: _comment.text.trim());
|
||||
navigator.pop();
|
||||
messenger.showSnackBar(SnackBar(content: Text(saved)));
|
||||
}
|
||||
|
||||
Future<void> _retract() async {
|
||||
final navigator = Navigator.of(context);
|
||||
setState(() => _saving = true);
|
||||
await widget.cubit.retract();
|
||||
navigator.pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
final iRated = widget.cubit.state.iRated;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
iRated ? t.ratings.edit : t.ratings.rate,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: seedTitle,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (var s = 1; s <= 5; s++)
|
||||
IconButton(
|
||||
key: Key('rating.star.$s'),
|
||||
iconSize: 36,
|
||||
onPressed:
|
||||
_saving ? null : () => setState(() => _stars = s),
|
||||
icon: Icon(
|
||||
s <= _stars
|
||||
? Icons.star_rounded
|
||||
: Icons.star_outline_rounded,
|
||||
color: s <= _stars ? seedGreen : seedMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
key: const Key('rating.comment'),
|
||||
controller: _comment,
|
||||
enabled: !_saving,
|
||||
maxLength: 280,
|
||||
maxLines: 2,
|
||||
decoration: InputDecoration(
|
||||
hintText: t.ratings.commentHint,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
if (iRated)
|
||||
TextButton(
|
||||
key: const Key('rating.retract'),
|
||||
onPressed: _saving ? null : _retract,
|
||||
child: Text(t.ratings.retract),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
key: const Key('rating.save'),
|
||||
onPressed: _saving || _stars == 0 ? null : _save,
|
||||
child: Text(t.common.save),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,228 +0,0 @@
|
|||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/profile_cache.dart' show shortPubkey;
|
||||
import '../services/trust_referents.dart';
|
||||
import '../services/wot_settings.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// Manage the web of trust: the bootstrap "roots" (Duniter seeds) membership is
|
||||
/// measured from, and the advanced parameters (sigQty / stepMax / validity).
|
||||
/// This is where a young network is seeded and tuned — progressive disclosure,
|
||||
/// reached from the profile.
|
||||
class TrustNetworkScreen extends StatefulWidget {
|
||||
const TrustNetworkScreen({
|
||||
required this.referents,
|
||||
required this.wotSettings,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TrustReferents referents;
|
||||
final WotSettings wotSettings;
|
||||
|
||||
@override
|
||||
State<TrustNetworkScreen> createState() => _TrustNetworkScreenState();
|
||||
}
|
||||
|
||||
class _TrustNetworkScreenState extends State<TrustNetworkScreen> {
|
||||
final _input = TextEditingController();
|
||||
Set<String> _roots = {};
|
||||
WotParams _params = WotParams.duniter;
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final roots = await widget.referents.userAdded();
|
||||
final params = await widget.wotSettings.params();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_roots = roots;
|
||||
_params = params;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _addRoot() async {
|
||||
final t = context.t;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
await widget.referents.add(_input.text);
|
||||
_input.clear();
|
||||
await _load();
|
||||
messenger.showSnackBar(SnackBar(content: Text(t.wot.rootAdded)));
|
||||
} on FormatException {
|
||||
setState(() => _error = t.wot.rootInvalid);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeRoot(String hex) async {
|
||||
await widget.referents.remove(hex);
|
||||
await _load();
|
||||
}
|
||||
|
||||
Future<void> _saveParams(WotParams next) async {
|
||||
await widget.wotSettings.save(next);
|
||||
if (!mounted) return;
|
||||
setState(() => _params = next);
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(context.t.wot.saved)));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_input.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(t.wot.title)),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
Text(t.wot.help,
|
||||
style: const TextStyle(color: seedMuted, fontSize: 13)),
|
||||
const SizedBox(height: 20),
|
||||
Text(t.wot.roots,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600, color: seedOnSurface)),
|
||||
const SizedBox(height: 4),
|
||||
Text(t.wot.rootsHelp,
|
||||
style: const TextStyle(color: seedMuted, fontSize: 12)),
|
||||
const SizedBox(height: 8),
|
||||
if (_roots.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(t.wot.noRoots,
|
||||
style:
|
||||
const TextStyle(color: seedMuted, fontSize: 13)),
|
||||
),
|
||||
for (final hex in _roots)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.hub_outlined, color: seedGreen),
|
||||
title: Text(shortPubkey(hex),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace', fontSize: 13)),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
tooltip: t.wot.remove,
|
||||
onPressed: () => _removeRoot(hex),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
key: const Key('wot.rootInput'),
|
||||
controller: _input,
|
||||
onChanged: (_) {
|
||||
if (_error != null) setState(() => _error = null);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: t.wot.addRoot,
|
||||
hintText: t.wot.rootHint,
|
||||
errorText: _error,
|
||||
suffixIcon: IconButton(
|
||||
key: const Key('wot.addRoot'),
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: _addRoot,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(),
|
||||
ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
title: Text(t.wot.params),
|
||||
subtitle: Text(t.wot.paramsHelp,
|
||||
style: const TextStyle(fontSize: 12)),
|
||||
children: [
|
||||
_Stepper(
|
||||
label: t.wot.sigQty,
|
||||
value: _params.sigQty,
|
||||
min: 1,
|
||||
onChanged: (v) =>
|
||||
_saveParams(_params.copyWith(sigQty: v)),
|
||||
),
|
||||
_Stepper(
|
||||
label: t.wot.stepMax,
|
||||
value: _params.stepMax,
|
||||
min: 1,
|
||||
onChanged: (v) =>
|
||||
_saveParams(_params.copyWith(stepMax: v)),
|
||||
),
|
||||
_Stepper(
|
||||
label: t.wot.validityDays,
|
||||
value: _params.sigValidity.inDays,
|
||||
min: 1,
|
||||
step: 30,
|
||||
onChanged: (v) => _saveParams(
|
||||
_params.copyWith(sigValidity: Duration(days: v))),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: TextButton(
|
||||
onPressed: () => _saveParams(WotParams.duniter),
|
||||
child: Text(t.wot.reset),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A "- value +" row for an integer parameter, clamped to [min].
|
||||
class _Stepper extends StatelessWidget {
|
||||
const _Stepper({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.min = 0,
|
||||
this.step = 1,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int value;
|
||||
final int min;
|
||||
final int step;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(label)),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
onPressed:
|
||||
value - step >= min ? () => onChanged(value - step) : null,
|
||||
),
|
||||
SizedBox(
|
||||
width: 44,
|
||||
child: Text('$value', textAlign: TextAlign.center),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
onPressed: () => onChanged(value + step),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
240
apps/app_seeds/lib/ui/your_people_screen.dart
Normal file
240
apps/app_seeds/lib/ui/your_people_screen.dart
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/profile_cache.dart';
|
||||
import '../services/social_connection.dart';
|
||||
import '../services/social_service.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// "Your people": who you vouch for and who vouches for you, in human
|
||||
/// language. Ego-centric by design — no roots, no parameters, no graph talk.
|
||||
/// Reached from the profile; rows open the 1:1 chat.
|
||||
class YourPeopleScreen extends StatefulWidget {
|
||||
const YourPeopleScreen({
|
||||
required this.social,
|
||||
required this.connection,
|
||||
this.profileCache,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final SocialService social;
|
||||
|
||||
/// The shared relay connection (one per identity), carrying trust.
|
||||
final SocialConnection connection;
|
||||
|
||||
/// Optional cache of peer display names; null in tests.
|
||||
final ProfileCache? profileCache;
|
||||
|
||||
@override
|
||||
State<YourPeopleScreen> createState() => _YourPeopleScreenState();
|
||||
}
|
||||
|
||||
class _YourPeopleScreenState extends State<YourPeopleScreen> {
|
||||
TrustTransport? _transport;
|
||||
bool _loading = true;
|
||||
bool _busy = false;
|
||||
List<String> _youVouchFor = const [];
|
||||
List<String> _vouchForYou = const [];
|
||||
final Map<String, String> _names = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
final session = await widget.connection.session();
|
||||
if (!mounted) return; // shared session is owned by the connection, not us
|
||||
_transport = session?.trust;
|
||||
await _load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final transport = _transport;
|
||||
if (transport == null) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
return;
|
||||
}
|
||||
final self = widget.social.publicKeyHex;
|
||||
final now = DateTime.now();
|
||||
final certs = (await transport.allCertifications())
|
||||
.where((c) => c.isValidAt(now))
|
||||
.toList();
|
||||
final given = [
|
||||
for (final c in certs)
|
||||
if (c.issuer == self) c.subject,
|
||||
]..sort();
|
||||
final received = [
|
||||
for (final c in certs)
|
||||
if (c.subject == self) c.issuer,
|
||||
]..sort();
|
||||
final cache = widget.profileCache;
|
||||
if (cache != null) {
|
||||
for (final peer in {...given, ...received}) {
|
||||
final name = await cache.name(peer);
|
||||
if (name != null) _names[peer] = name;
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_youVouchFor = given;
|
||||
_vouchForYou = received;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _revoke(String peer) async {
|
||||
final t = context.t;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(t.yourPeople.revokeConfirm),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(t.yourPeople.revoke),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final transport = _transport;
|
||||
if (confirmed != true || transport == null || !mounted) return;
|
||||
setState(() => _busy = true);
|
||||
await transport.revoke(subjectPubkey: peer);
|
||||
await _load();
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(t.yourPeople.title)),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _transport == null
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(
|
||||
t.yourPeople.offline,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: seedMuted, fontSize: 15),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.fromSTEB(
|
||||
16, 8, 16, 8),
|
||||
child: Text(
|
||||
t.yourPeople.help,
|
||||
style:
|
||||
const TextStyle(color: seedMuted, fontSize: 14),
|
||||
),
|
||||
),
|
||||
_SectionHeader(label: t.yourPeople.youVouchFor),
|
||||
if (_youVouchFor.isEmpty)
|
||||
_EmptyNote(text: t.yourPeople.youVouchForEmpty)
|
||||
else
|
||||
for (final peer in _youVouchFor)
|
||||
_PersonTile(
|
||||
key: Key('yourPeople.given.$peer'),
|
||||
name: _names[peer] ?? shortPubkey(peer),
|
||||
onOpen: () => context.push('/chat/$peer'),
|
||||
trailing: TextButton(
|
||||
onPressed: _busy ? null : () => _revoke(peer),
|
||||
child: Text(t.yourPeople.revoke),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_SectionHeader(label: t.yourPeople.vouchesForYou),
|
||||
if (_vouchForYou.isEmpty)
|
||||
_EmptyNote(text: t.yourPeople.vouchesForYouEmpty)
|
||||
else
|
||||
for (final peer in _vouchForYou)
|
||||
_PersonTile(
|
||||
key: Key('yourPeople.received.$peer'),
|
||||
name: _names[peer] ?? shortPubkey(peer),
|
||||
onOpen: () => context.push('/chat/$peer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
const _SectionHeader({required this.label});
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.fromSTEB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: seedGreen,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyNote extends StatelessWidget {
|
||||
const _EmptyNote({required this.text});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.fromSTEB(16, 4, 16, 4),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(color: seedMuted, fontSize: 14),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PersonTile extends StatelessWidget {
|
||||
const _PersonTile({
|
||||
required this.name,
|
||||
required this.onOpen,
|
||||
this.trailing,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final VoidCallback onOpen;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: const CircleAvatar(
|
||||
backgroundColor: seedAvatar,
|
||||
child: Icon(Icons.person, color: seedOnAvatar),
|
||||
),
|
||||
title: Text(name),
|
||||
trailing: trailing,
|
||||
onTap: onOpen,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -132,9 +132,6 @@ flutter:
|
|||
- assets/catalog/species.json
|
||||
# Curated seed-saving guidance (pollination, isolation, difficulty…).
|
||||
- assets/catalog/seed_saving.json
|
||||
# Web-of-trust bootstrap referents (Duniter "seeds"). Empty until real
|
||||
# founding identities are curated; users bootstrap by adding their own.
|
||||
- assets/trust/referents.json
|
||||
- assets/logo.png
|
||||
# Bundled so the Linux runner can use it as the GTK window icon.
|
||||
- assets/icon.png
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/services/trust_referents.dart';
|
||||
|
||||
import '../support/test_support.dart';
|
||||
|
||||
void main() {
|
||||
const npub =
|
||||
'npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6';
|
||||
const hex =
|
||||
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d';
|
||||
|
||||
// No bundled asset in the unit test bundle → _bundled() degrades to empty, so
|
||||
// these exercise the user-added set (the cold-start bootstrap path).
|
||||
late TrustReferents referents;
|
||||
setUp(() => referents = TrustReferents(InMemorySecretStore()));
|
||||
|
||||
test('starts empty', () async {
|
||||
expect(await referents.all(), isEmpty);
|
||||
expect(await referents.userAdded(), isEmpty);
|
||||
});
|
||||
|
||||
test('adds a referent from an npub, storing hex', () async {
|
||||
final stored = await referents.add(npub);
|
||||
expect(stored, hex);
|
||||
expect(await referents.userAdded(), {hex});
|
||||
expect(await referents.all(), contains(hex));
|
||||
});
|
||||
|
||||
test('accepts a hex key directly and is idempotent', () async {
|
||||
await referents.add(hex);
|
||||
await referents.add(npub); // same identity, npub form
|
||||
expect(await referents.userAdded(), {hex});
|
||||
});
|
||||
|
||||
test('rejects an invalid identity code', () async {
|
||||
expect(() => referents.add('not-a-key'), throwsFormatException);
|
||||
expect(await referents.userAdded(), isEmpty);
|
||||
});
|
||||
|
||||
test('removes a referent', () async {
|
||||
await referents.add(hex);
|
||||
await referents.remove(hex);
|
||||
expect(await referents.userAdded(), isEmpty);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/services/wot_settings.dart';
|
||||
|
||||
import '../support/test_support.dart';
|
||||
|
||||
void main() {
|
||||
late WotSettings settings;
|
||||
setUp(() => settings = WotSettings(InMemorySecretStore()));
|
||||
|
||||
test('defaults to the Duniter parameters', () async {
|
||||
expect(await settings.params(), WotParams.duniter);
|
||||
});
|
||||
|
||||
test('persists saved parameters', () async {
|
||||
const custom = WotParams(
|
||||
sigQty: 3, stepMax: 4, sigValidity: Duration(days: 180));
|
||||
await settings.save(custom);
|
||||
expect(await settings.params(), custom);
|
||||
});
|
||||
|
||||
test('reset restores the Duniter defaults', () async {
|
||||
await settings.save(const WotParams(
|
||||
sigQty: 1, stepMax: 1, sigValidity: Duration(days: 30)));
|
||||
await settings.reset();
|
||||
expect(await settings.params(), WotParams.duniter);
|
||||
});
|
||||
}
|
||||
162
apps/app_seeds/test/state/peer_rating_cubit_test.dart
Normal file
162
apps/app_seeds/test/state/peer_rating_cubit_test.dart
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/state/peer_rating_cubit.dart';
|
||||
|
||||
/// In-memory [RatingTransport]: rate/retract act as this device's identity,
|
||||
/// one live rating per (rater, subject) like the Nostr transport.
|
||||
class FakeRatingTransport implements RatingTransport {
|
||||
FakeRatingTransport(this.selfId);
|
||||
final String selfId;
|
||||
final Map<String, Map<String, Rating>> _bySubject = {}; // subject -> rater
|
||||
|
||||
/// Test helper: record an arbitrary rater→subject rating.
|
||||
void addRating(String rater, String subject, int stars,
|
||||
{String comment = ''}) =>
|
||||
(_bySubject[subject] ??= {})[rater] = Rating(
|
||||
rater: rater,
|
||||
subject: subject,
|
||||
stars: stars,
|
||||
ratedAt: DateTime(2026),
|
||||
comment: comment,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> rate({
|
||||
required String subjectPubkey,
|
||||
required int stars,
|
||||
String comment = '',
|
||||
}) async =>
|
||||
addRating(selfId, subjectPubkey, stars, comment: comment);
|
||||
|
||||
@override
|
||||
Future<void> retract({required String subjectPubkey}) async =>
|
||||
_bySubject[subjectPubkey]?.remove(selfId);
|
||||
|
||||
@override
|
||||
Future<List<Rating>> ratingsOf(String subjectPubkey) async =>
|
||||
List.of(_bySubject[subjectPubkey]?.values ?? const <Rating>[]);
|
||||
|
||||
@override
|
||||
Future<Rating?> myRatingOf(String subjectPubkey) async =>
|
||||
_bySubject[subjectPubkey]?[selfId];
|
||||
|
||||
@override
|
||||
Future<void> close() async {}
|
||||
}
|
||||
|
||||
/// Minimal trust transport exposing a fixed certification graph, for the
|
||||
/// circle weighting.
|
||||
class FakeTrustTransport implements TrustTransport {
|
||||
final List<Certification> certs = [];
|
||||
|
||||
void addCert(String issuer, String subject) => certs.add(Certification(
|
||||
issuer: issuer,
|
||||
subject: subject,
|
||||
issuedAt: DateTime(2026),
|
||||
));
|
||||
|
||||
@override
|
||||
Future<List<Certification>> allCertifications() async => List.of(certs);
|
||||
|
||||
@override
|
||||
Future<Set<String>> certifiersOf(String subjectPubkey) async => {
|
||||
for (final c in certs)
|
||||
if (c.subject == subjectPubkey) c.issuer,
|
||||
};
|
||||
|
||||
@override
|
||||
Future<void> certify({
|
||||
required String subjectPubkey,
|
||||
Duration validity = const Duration(days: 365),
|
||||
String note = '',
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> revoke({required String subjectPubkey}) async {}
|
||||
|
||||
@override
|
||||
Future<void> close() async {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
const me = 'me';
|
||||
const peer = 'peer';
|
||||
|
||||
test('aggregates count and average over active ratings', () async {
|
||||
final ratings = FakeRatingTransport(me)
|
||||
..addRating('a', peer, 5)
|
||||
..addRating('b', peer, 2);
|
||||
final cubit = PeerRatingCubit(ratings, peerPubkey: peer, selfPubkey: me);
|
||||
await cubit.load();
|
||||
expect(cubit.state.count, 2);
|
||||
expect(cubit.state.average, closeTo(3.5, 0.001));
|
||||
expect(cubit.state.circleCount, 0); // no trust graph wired
|
||||
expect(cubit.state.iRated, isFalse);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('counts ratings coming from your circle', () async {
|
||||
final ratings = FakeRatingTransport(me)
|
||||
..addRating('friend', peer, 5) // you vouch for 'friend'
|
||||
..addRating('stranger', peer, 1);
|
||||
final trust = FakeTrustTransport()..addCert(me, 'friend');
|
||||
final cubit = PeerRatingCubit(
|
||||
ratings,
|
||||
peerPubkey: peer,
|
||||
selfPubkey: me,
|
||||
trust: trust,
|
||||
);
|
||||
await cubit.load();
|
||||
expect(cubit.state.count, 2);
|
||||
expect(cubit.state.circleCount, 1);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('rate publishes then reloads; re-rating replaces', () async {
|
||||
final ratings = FakeRatingTransport(me);
|
||||
final cubit = PeerRatingCubit(ratings, peerPubkey: peer, selfPubkey: me);
|
||||
await cubit.load();
|
||||
|
||||
await cubit.rate(4, comment: 'Nice seeds');
|
||||
expect(cubit.state.myStars, 4);
|
||||
expect(cubit.state.myComment, 'Nice seeds');
|
||||
expect(cubit.state.count, 1);
|
||||
|
||||
await cubit.rate(2);
|
||||
expect(cubit.state.myStars, 2);
|
||||
expect(cubit.state.count, 1); // replaced, not piled up
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('retract removes your rating', () async {
|
||||
final ratings = FakeRatingTransport(me)..addRating(me, peer, 3);
|
||||
final cubit = PeerRatingCubit(ratings, peerPubkey: peer, selfPubkey: me);
|
||||
await cubit.load();
|
||||
expect(cubit.state.iRated, isTrue);
|
||||
|
||||
await cubit.retract();
|
||||
expect(cubit.state.iRated, isFalse);
|
||||
expect(cubit.state.count, 0);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('never rates self', () async {
|
||||
final ratings = FakeRatingTransport(me);
|
||||
final cubit = PeerRatingCubit(ratings, peerPubkey: me, selfPubkey: me);
|
||||
await cubit.load();
|
||||
await cubit.rate(5);
|
||||
expect(cubit.state.iRated, isFalse);
|
||||
expect(cubit.state.count, 0);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('offline (no transport) never throws', () async {
|
||||
final cubit = PeerRatingCubit(null, peerPubkey: peer, selfPubkey: me);
|
||||
expect(cubit.isOnline, isFalse);
|
||||
await cubit.load();
|
||||
await cubit.rate(5);
|
||||
await cubit.retract();
|
||||
expect(cubit.state.loading, isFalse);
|
||||
await cubit.close();
|
||||
});
|
||||
}
|
||||
|
|
@ -83,6 +83,7 @@ void main() {
|
|||
final inCircle = TrustCubit(transport, peerPubkey: peer, selfPubkey: me);
|
||||
await inCircle.load();
|
||||
expect(inCircle.state.knownToYou, isTrue);
|
||||
expect(inCircle.state.tier, TrustTier.inYourCircle);
|
||||
await inCircle.close();
|
||||
|
||||
final outside = TrustCubit(transport, peerPubkey: 'other', selfPubkey: me);
|
||||
|
|
@ -91,39 +92,6 @@ void main() {
|
|||
await outside.close();
|
||||
});
|
||||
|
||||
test('network membership: enough referent vouches → member', () async {
|
||||
const params =
|
||||
WotParams(sigQty: 2, stepMax: 5, sigValidity: Duration(days: 365));
|
||||
final transport = FakeTrustTransport(me)
|
||||
..addCert('r1', peer)
|
||||
..addCert('r2', peer);
|
||||
final cubit = TrustCubit(
|
||||
transport,
|
||||
peerPubkey: peer,
|
||||
selfPubkey: me,
|
||||
referents: {'r1', 'r2'},
|
||||
params: params,
|
||||
);
|
||||
await cubit.load();
|
||||
expect(cubit.state.networkBootstrapped, isTrue);
|
||||
expect(cubit.state.isNetworkMember, isTrue);
|
||||
expect(cubit.state.tier, TrustTier.networkMember);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('with no referents, membership is undetermined (not a member)',
|
||||
() async {
|
||||
final transport = FakeTrustTransport(me)
|
||||
..addCert('r1', peer)
|
||||
..addCert('r2', peer);
|
||||
// referents default to {} → the trust net isn't seeded.
|
||||
final cubit = TrustCubit(transport, peerPubkey: peer, selfPubkey: me);
|
||||
await cubit.load();
|
||||
expect(cubit.state.networkBootstrapped, isFalse);
|
||||
expect(cubit.state.isNetworkMember, isFalse);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('tier falls back to vouched, then unknown', () async {
|
||||
final vouched = TrustCubit(
|
||||
FakeTrustTransport(me)..addCert('someone', peer),
|
||||
|
|
|
|||
124
apps/app_seeds/test/ui/rating_sheet_test.dart
Normal file
124
apps/app_seeds/test/ui/rating_sheet_test.dart
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/i18n/strings.g.dart';
|
||||
import 'package:tane/state/peer_rating_cubit.dart';
|
||||
import 'package:tane/ui/rating_sheet.dart';
|
||||
|
||||
/// In-memory [RatingTransport] (one live rating per pair).
|
||||
class FakeRatingTransport implements RatingTransport {
|
||||
FakeRatingTransport(this.selfId);
|
||||
final String selfId;
|
||||
final Map<String, Rating> byRater = {};
|
||||
|
||||
@override
|
||||
Future<void> rate({
|
||||
required String subjectPubkey,
|
||||
required int stars,
|
||||
String comment = '',
|
||||
}) async =>
|
||||
byRater[selfId] = Rating(
|
||||
rater: selfId,
|
||||
subject: subjectPubkey,
|
||||
stars: stars,
|
||||
ratedAt: DateTime(2026),
|
||||
comment: comment,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> retract({required String subjectPubkey}) async =>
|
||||
byRater.remove(selfId);
|
||||
|
||||
@override
|
||||
Future<List<Rating>> ratingsOf(String subjectPubkey) async =>
|
||||
List.of(byRater.values);
|
||||
|
||||
@override
|
||||
Future<Rating?> myRatingOf(String subjectPubkey) async => byRater[selfId];
|
||||
|
||||
@override
|
||||
Future<void> close() async {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
const me = 'me';
|
||||
const peer = 'peer';
|
||||
|
||||
late FakeRatingTransport transport;
|
||||
late PeerRatingCubit cubit;
|
||||
|
||||
setUp(() async {
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
transport = FakeRatingTransport(me);
|
||||
cubit = PeerRatingCubit(transport, peerPubkey: peer, selfPubkey: me);
|
||||
await cubit.load();
|
||||
});
|
||||
|
||||
tearDown(() => cubit.close());
|
||||
|
||||
Widget host() => TranslationProvider(
|
||||
child: MaterialApp(
|
||||
locale: AppLocale.en.flutterLocale,
|
||||
supportedLocales: AppLocaleUtils.supportedLocales,
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => Center(
|
||||
child: TextButton(
|
||||
onPressed: () => showRatingSheet(context, cubit),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('picking stars and saving publishes the rating',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(host());
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Rate this person'), findsOneWidget);
|
||||
// Save is disabled until stars are picked.
|
||||
expect(
|
||||
tester.widget<FilledButton>(find.byKey(const Key('rating.save'))).enabled,
|
||||
isFalse,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('rating.star.4')));
|
||||
await tester.pump();
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('rating.comment')), 'Great swap');
|
||||
await tester.tap(find.byKey(const Key('rating.save')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final mine = transport.byRater[me]!;
|
||||
expect(mine.stars, 4);
|
||||
expect(mine.comment, 'Great swap');
|
||||
expect(find.text('Rating saved'), findsOneWidget); // snackbar
|
||||
});
|
||||
|
||||
testWidgets('opens pre-filled when you already rated; retract removes',
|
||||
(tester) async {
|
||||
await cubit.rate(3, comment: 'Fine');
|
||||
|
||||
await tester.pumpWidget(host());
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Edit your rating'), findsOneWidget);
|
||||
expect(find.text('Fine'), findsOneWidget); // comment pre-filled
|
||||
|
||||
await tester.tap(find.byKey(const Key('rating.retract')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(transport.byRater, isEmpty);
|
||||
});
|
||||
}
|
||||
176
apps/app_seeds/test/ui/your_people_screen_test.dart
Normal file
176
apps/app_seeds/test/ui/your_people_screen_test.dart
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/i18n/strings.g.dart';
|
||||
import 'package:tane/services/profile_cache.dart';
|
||||
import 'package:tane/services/social_connection.dart';
|
||||
import 'package:tane/services/social_service.dart';
|
||||
import 'package:tane/services/social_settings.dart';
|
||||
import 'package:tane/ui/your_people_screen.dart';
|
||||
|
||||
import '../support/test_support.dart';
|
||||
|
||||
/// In-memory [TrustTransport] with a mutable certification graph.
|
||||
class FakeTrustTransport implements TrustTransport {
|
||||
FakeTrustTransport(this.selfId);
|
||||
final String selfId;
|
||||
final List<Certification> certs = [];
|
||||
|
||||
void addCert(String issuer, String subject) => certs.add(Certification(
|
||||
issuer: issuer,
|
||||
subject: subject,
|
||||
issuedAt: DateTime(2026),
|
||||
));
|
||||
|
||||
@override
|
||||
Future<List<Certification>> allCertifications() async => List.of(certs);
|
||||
|
||||
@override
|
||||
Future<Set<String>> certifiersOf(String subjectPubkey) async => {
|
||||
for (final c in certs)
|
||||
if (c.subject == subjectPubkey) c.issuer,
|
||||
};
|
||||
|
||||
@override
|
||||
Future<void> certify({
|
||||
required String subjectPubkey,
|
||||
Duration validity = const Duration(days: 365),
|
||||
String note = '',
|
||||
}) async =>
|
||||
addCert(selfId, subjectPubkey);
|
||||
|
||||
@override
|
||||
Future<void> revoke({required String subjectPubkey}) async => certs
|
||||
.removeWhere((c) => c.issuer == selfId && c.subject == subjectPubkey);
|
||||
|
||||
@override
|
||||
Future<void> close() async {}
|
||||
}
|
||||
|
||||
/// A [SocialSession] stand-in that only carries the trust transport — the
|
||||
/// screen under test never touches the other transports.
|
||||
class FakeSession implements SocialSession {
|
||||
FakeSession(this.trust);
|
||||
|
||||
@override
|
||||
final TrustTransport trust;
|
||||
|
||||
@override
|
||||
OfferTransport get offers => throw UnimplementedError();
|
||||
@override
|
||||
MessageTransport get messages => throw UnimplementedError();
|
||||
@override
|
||||
RatingTransport get ratings => throw UnimplementedError();
|
||||
@override
|
||||
ProfileTransport get profile => throw UnimplementedError();
|
||||
@override
|
||||
SyncTransport get sync => throw UnimplementedError();
|
||||
@override
|
||||
Future<void> close() async {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
const seedHex =
|
||||
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
|
||||
final peerA = 'aa' * 32;
|
||||
final peerB = 'bb' * 32;
|
||||
|
||||
late SocialService social;
|
||||
|
||||
setUp(() async {
|
||||
social = await SocialService.fromRootSeedHex(seedHex);
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
});
|
||||
|
||||
Widget app(SocialConnection connection, {ProfileCache? cache}) =>
|
||||
TranslationProvider(
|
||||
child: MaterialApp(
|
||||
locale: AppLocale.en.flutterLocale,
|
||||
supportedLocales: AppLocaleUtils.supportedLocales,
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
home: YourPeopleScreen(
|
||||
social: social,
|
||||
connection: connection,
|
||||
profileCache: cache,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
SocialConnection online(TrustTransport trust) => SocialConnection(
|
||||
social: social,
|
||||
settings: SocialSettings(InMemorySecretStore()),
|
||||
open: (_) async => FakeSession(trust) as SocialSession,
|
||||
);
|
||||
|
||||
testWidgets('offline shows the friendly note', (tester) async {
|
||||
final settings = SocialSettings(InMemorySecretStore());
|
||||
await settings.setRelayUrls(const []); // offline: don't hit the network
|
||||
final connection = SocialConnection(social: social, settings: settings);
|
||||
|
||||
await tester.pumpWidget(app(connection));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text("You're offline — try again when you're connected"),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('lists who you vouch for and who vouches for you, with names',
|
||||
(tester) async {
|
||||
final trust = FakeTrustTransport(social.publicKeyHex)
|
||||
..addCert(social.publicKeyHex, peerA) // you vouch for A
|
||||
..addCert(peerB, social.publicKeyHex) // B vouches for you
|
||||
..addCert(peerA, peerB); // unrelated edge, must not show
|
||||
final cache = ProfileCache(InMemorySecretStore());
|
||||
await cache.setName(peerA, 'Alice');
|
||||
|
||||
await tester.pumpWidget(app(online(trust), cache: cache));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('You vouch for'), findsOneWidget);
|
||||
expect(find.text('They vouch for you'), findsOneWidget);
|
||||
expect(find.text('Alice'), findsOneWidget); // cached name wins
|
||||
expect(find.text(shortPubkey(peerB)), findsOneWidget); // fallback
|
||||
expect(find.byKey(Key('yourPeople.given.$peerA')), findsOneWidget);
|
||||
expect(find.byKey(Key('yourPeople.received.$peerB')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('empty states show gentle notes', (tester) async {
|
||||
await tester
|
||||
.pumpWidget(app(online(FakeTrustTransport(social.publicKeyHex))));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.textContaining("You don't vouch for anyone yet"),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('No one vouches for you yet'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('revoking removes the vouch after confirming', (tester) async {
|
||||
final trust = FakeTrustTransport(social.publicKeyHex)
|
||||
..addCert(social.publicKeyHex, peerA);
|
||||
|
||||
await tester.pumpWidget(app(online(trust)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Stop vouching'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Stop vouching for this person?'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Stop vouching').last); // dialog action
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(trust.certs, isEmpty);
|
||||
expect(
|
||||
find.textContaining("You don't vouch for anyone yet"),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -13,16 +13,26 @@ Una app de compartición local no sirve si eres la única persona en 50 km. Dos
|
|||
|
||||
**Primer campo de prueba: los grupos de semillas de Ğ1.** En la comunidad Ğ1 ya hay grupos de semillas, pero les falla la app y la red para intercambiar. Son el **piloto ideal**: comunidad motivada, afín, **ya en red y con confianza (WoT) y moneda (Ğ1) montadas** — solo les falta la herramienta. Para ese subconjunto, el arranque en frío está casi resuelto. Detalle en [g1-integration.md](g1-integration.md).
|
||||
|
||||
## 2. Red de confianza estilo Duniter (huecos 3 y 4)
|
||||
## 2. Confianza egocéntrica (huecos 3 y 4)
|
||||
|
||||
Modelo inspirado en **Duniter / Ğ1**: cualquiera participa desde el minuto uno, pero empieza como **"desconocido"**. Cuando **N personas** que ya son miembros te **certifican** (una firma: "doy fe de esta persona"), entras en el área de **"gente conocida"** (miembro de la red de confianza). Duniter usa ~5 certificaciones + una regla de distancia; el umbral es un **parámetro ajustable**.
|
||||
> **Revisión 2026-07-11.** La primera versión de esta sección proponía la regla *global* de membresía de Duniter (N certificaciones + distancia desde referentes fundadores curados). Se descartó: esa regla resuelve identidad sybil-proof para una renta básica — un problema que Tane no tiene — y exige curar referentes por comunidad/país, inviable en una app internacional (¿quién cura los fundadores de Japón, Brasil, Marruecos?). Un set de referentes malo es peor que ninguno. Ver `open-decisions.md`.
|
||||
|
||||
- **Certificar = acto presencial y consciente** ("te conocí, respondo por ti"). Encaja con §1: las ferias generan certificaciones.
|
||||
- **Resuelve el arranque de confianza (hueco 3):** al recién llegado no se le bloquea, solo se le marca como desconocido hasta acumular avales.
|
||||
- **Resuelve la moderación (hueco 4):** spammers y estafadores se quedan en "desconocido" y se filtran o despriorizan solos; la red de confianza pone en cuarentena **sin autoridad central**. Encima se suma la **reputación** (valoraciones tras un trato cerrado).
|
||||
- **UI para 10–80:** mostrar "conocido / desconocido" con lenguaje humano ("aún nadie de tu confianza responde por esta persona"), nunca jerga de grafos.
|
||||
**Modelo egocéntrico:** los avales ("doy fe de esta persona") son públicos, firmados y compartidos — todo el mundo ve el mismo grafo — pero **no hay veredicto global**: cada persona calcula la confianza **desde su propia posición**. Es el modelo que funcionó en la práctica en PGP (la validez se calcula desde TU clave) y el que usa el ecosistema Nostr ("seguido por gente que sigues").
|
||||
|
||||
*A decidir:* umbral de certificaciones (¿5?), regla de distancia, caducidad de certificaciones, y si un banco/colectivo puede certificar como entidad.
|
||||
- **Certificar = acto presencial y consciente** ("te conocí, respondo por ti"). Encaja con §1: las ferias generan avales. En la UI es un toque: "Conozco a esta persona", en el chat.
|
||||
- **Tu círculo:** tú, la gente que avalas, y la gente que ellos avalan (distancia ≤2). El chat lo muestra en humano: "En tu círculo" / "Avalada por N" / "Nadie la avala aún".
|
||||
- **Cold-start resuelto de raíz (hueco 3):** valor desde el primer aval, día uno, en cualquier país. Un colectivo arranca su propio racimo sin pedir permiso ni esperar referentes. Al recién llegado no se le bloquea, solo se le marca como desconocido.
|
||||
- **Moderación sin autoridad central (hueco 4):** un atacante puede crear mil claves que se avalen entre sí, pero ese racimo queda desconectado de *tu* vista — siguen siendo desconocidos para ti. Para colarse en tu círculo necesita un aval presencial de alguien de tu entorno (la escasez de "aristas de ataque" protege por observador, como en la investigación anti-sybil por grafo social). Los avales son públicos: avalar a un estafador tiene coste social. Caducan (365 días) y se renuevan, podando confianza rancia.
|
||||
- **Reputación encima:** valoraciones tipo Wallapop (§2.1).
|
||||
- **UI para 10–80:** "Tu gente" (a quién avalas / quién te avala), lenguaje humano, nunca jerga de grafos ni parámetros.
|
||||
|
||||
**Qué se pierde honestamente:** el badge global "miembro de la red" y la garantía una-persona-una-identidad — herencia del problema de Duniter (RBU), no del nuestro. **Puerta no quemada:** el primitivo de certificación sigue siendo estructuralmente compatible con Duniter (una certificación viva por par, con caducidad); una capa futura de "confianza por comunidad" (una red de semillas define sus propias raíces y su propia regla) o una importación opcional de la WoT de Ğ1 pueden construirse encima del mismo motor sin migración.
|
||||
|
||||
### 2.1 Reputación: valoraciones tipo Wallapop
|
||||
|
||||
Los avales no cubren la estafa desde dentro del círculo; las valoraciones sí. **V1 (implementada):** estrellas 1–5 + comentario corto sobre una persona, públicas y firmadas; **una valoración viva por par** (re-valorar edita, no acumula — un mismo autor no puede amontonar reseñas); retirable. Anclaje blando: solo puedes valorar a alguien con quien tienes conversación. Se muestran en el detalle de oferta y en el chat, **ponderadas por tu círculo** ("N de gente que conoces") — las reseñas de desconocidos no se destacan, así que inflarlas con claves falsas no sirve de nada. **Evolución prevista:** anclaje fuerte a un trato cerrado con el formulario bilateral firmado (ver `sharing-model.md` §6).
|
||||
|
||||
*A decidir:* si un banco/colectivo puede certificar como entidad (ver sharing-model §6).
|
||||
|
||||
## 3. Relays y descubrimiento: ¿la app como relay? (hueco 5)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,10 +21,11 @@ Estas fijan `schemaVersion = 1` y el arranque técnico:
|
|||
|
||||
- **Transporte de la capa social → Nostr (confirmado).** No se renuncia a Nostr: **Duniter no tiene mensajería**, así que Nostr es la vía para mensajes y ofertas. Se mantiene "una sola identidad" derivando la clave **secp256k1** (Nostr) de la semilla raíz Ğ1. Falta: confirmar madurez de los NIPs y la ruta de derivación. (Datapods de Duniter descartados: no están en servicio.) → [g1-integration.md](g1-integration.md)
|
||||
- **Estrategia de relays:** app-as-relay oportunista + proximidad física + relays comunitarios. → network-trust §3
|
||||
- **Parámetros de la red de confianza — RESUELTO/IMPLEMENTADO (2026-07-10).** Modelo **membresía Duniter completa**: regla pura `WebOfTrust.membersWith(seeds, WotParams)` con `WotParams` (sigQty/stepMax/sigValidity) por defecto Ğ1 (5/5/1año) **configurables** (`WotSettings`, keystore) — una red joven los afloja y aprieta al crecer. Cold-start honesto (sin inventar identidades): referentes "semilla" desde un asset empaquetado (vacío hasta curar fundadores reales) ∪ referentes que el usuario añade por npub/QR (`TrustReferents`). Caducidad ya la ponía el transporte (`certify` con `expiration`). UI: badge por `TrustTier` (miembro de la red > en tu círculo > avalado > desconocido) en el chat, y pantalla "Red de confianza" (gestión de raíces + parámetros) desde el perfil. Certificación por colectivo queda para sharing-model §6. → network-trust §2
|
||||
- **Confianza egocéntrica sustituye a la WoT global — DECIDIDO/IMPLEMENTADO (2026-07-11), SUPERSEDE la decisión del 2026-07-10.** La membresía Duniter completa (referentes curados + `WotParams` sigQty/stepMax, pantalla de raíces npub y steppers) se retiró de la app: resolvía identidad sybil-proof para una RBU — problema que Tane no tiene —, exigía curar referentes por comunidad/país (inviable internacionalmente; un set de referentes malo es peor que ninguno) y su pantalla violaba "palabras humanas, nunca jerga". **Queda solo la vista egocéntrica**: tú avalas ("Conozco a esta persona", en el chat) / tu círculo (distancia ≤2 desde ti, umbral 1) / avalado por N / desconocido. Cero bootstrap, cero parámetros de usuario; los avales caducan (365 días) y se renuevan. Un racimo sybil queda desconectado de la vista de cada observador — el filtro antispam se conserva sin veredicto global. **Se mantiene sin cambios** el primitivo (`Certification`, kind Nostr 30777, una cert viva por par) y el motor puro `WebOfTrust` en `commons_core` (usado para el círculo; capacidad latente para una futura "confianza por comunidad" o importación opcional de la WoT Ğ1 — sin migración de eventos). **UI:** badge por `TrustTier` (en tu círculo > avalado > desconocido) en el chat; pantalla "Tu gente" (a quién avalas —revocable— / quién te avala) desde el perfil, sustituyendo a "Red de confianza". Eliminados `TrustReferents`, `WotSettings`, `TrustNetworkScreen` y el asset de referentes. Certificación por colectivo sigue en sharing-model §6. → network-trust §2
|
||||
- **Valoraciones tipo Wallapop v1 — DECIDIDO/IMPLEMENTADO (2026-07-11).** Estrellas 1–5 + comentario corto, públicas y firmadas (`Rating`, kind Nostr **30778** direccionable, `d` = sujeto): **una valoración viva por par** — re-valorar edita, no acumula; retirable. **Anclaje blando v1:** solo puedes valorar a alguien con quien tienes conversación (el anclaje fuerte a trato cerrado firmado sigue abierto, ver "Reputación" abajo). Se muestran en el detalle de oferta y en el chat **ponderadas por tu círculo** ("N de gente que conoces", mismo cálculo egocéntrico) — inflar reseñas con claves desconocidas no destaca nada; sin valoraciones no se muestra nada (la ausencia nunca es un reproche). Quinto transporte (`RatingTransport`) sobre el canal compartido. → network-trust §2.1
|
||||
- **Mensajería:** alcance v1 (1:1 atada a oferta), apoyo en NIP-17. → network-trust §4
|
||||
- **Almacenamiento del historial de chat — RESUELTO/IMPLEMENTADO (2026-07-11).** El historial 1:1 se guarda en una **BD Drift/SQLCipher aparte** (`tane_chat.sqlite` / `ChatDatabase`), no en el keystore (era un blob JSON por conversación con read-modify-write O(n²), tope silencioso de 200 e índice manual). BD separada a propósito: el chat es **caché de red efímera por dispositivo**, no inventario — sin metadatos CRDT, fuera de la serie de migraciones del inventario, fuera del sync/backup. `append` = un insert indexado (dedup por clave única), historial sin tope. Pre-release: no se migran los blobs viejos del keystore. Mismo antipatrón (a escala trivial) en `OfferOutbox`/`TrustReferents`/`SocialSettings` → keystore aceptable ahí; solo el chat crece con el uso. → [chat-storage.md](chat-storage.md)
|
||||
- **Reputación** atada a un trato/oferta cerrada (evitar reseñas falsas). → sharing-model §6
|
||||
- **Almacenamiento del historial de chat — RESUELTO/IMPLEMENTADO (2026-07-11).** El historial 1:1 se guarda en una **BD Drift/SQLCipher aparte** (`tane_chat.sqlite` / `ChatDatabase`), no en el keystore (era un blob JSON por conversación con read-modify-write O(n²), tope silencioso de 200 e índice manual). BD separada a propósito: el chat es **caché de red efímera por dispositivo**, no inventario — sin metadatos CRDT, fuera de la serie de migraciones del inventario, fuera del sync/backup. `append` = un insert indexado (dedup por clave única), historial sin tope. Pre-release: no se migran los blobs viejos del keystore. Mismo antipatrón (a escala trivial) en `OfferOutbox`/`SocialSettings` → keystore aceptable ahí; solo el chat crece con el uso. → [chat-storage.md](chat-storage.md)
|
||||
- **Reputación: anclaje fuerte** a un trato/oferta cerrada (evitar reseñas falsas del todo) — la v1 con anclaje blando por conversación ya está (ver arriba); el anclaje fuerte espera al formulario bilateral firmado. → sharing-model §6
|
||||
- **Precio:** ¿monedas comunitarias / de tiempo además de dinero? → sharing-model §6
|
||||
- **Integración Ğ1 (moneda libre):** niveles 1–2 (precio en Ğ1 + enlace a cartera Ğecko/Cesium²/Ğ1nkgo) en la capa social; nivel 3 (reusar la WoT de Ğ1 como fuente de confianza) como estudio aparte. → [g1-integration.md](g1-integration.md)
|
||||
- **Ofertas de banco colectivo** (publica la entidad, no la persona). → sharing-model §6
|
||||
|
|
|
|||
|
|
@ -21,11 +21,14 @@ export 'src/social/nostr/nostr_connection.dart';
|
|||
export 'src/social/nostr/nostr_message_transport.dart';
|
||||
export 'src/social/nostr/nostr_offer_transport.dart';
|
||||
export 'src/social/nostr/nostr_profile_transport.dart';
|
||||
export 'src/social/nostr/nostr_rating_transport.dart';
|
||||
export 'src/social/nostr/nostr_trust_transport.dart';
|
||||
export 'src/social/nostr/relay_pool.dart';
|
||||
export 'src/social/offer.dart';
|
||||
export 'src/social/offer_transport.dart';
|
||||
export 'src/social/profile_transport.dart';
|
||||
export 'src/social/rating.dart';
|
||||
export 'src/social/rating_transport.dart';
|
||||
export 'src/social/sync_transport.dart';
|
||||
export 'src/social/trust_transport.dart';
|
||||
export 'src/social/web_of_trust.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
import 'package:nostr/nostr.dart';
|
||||
|
||||
import '../rating.dart';
|
||||
import '../rating_transport.dart';
|
||||
import 'nostr_channel.dart';
|
||||
|
||||
/// Ratings carried as Nostr events, on the shared [NostrConnection]. No settled
|
||||
/// NIP exists for person-to-person reviews, so this uses a custom addressable
|
||||
/// kind (next to the certification kind): one live rating per (rater, subject),
|
||||
/// so rating again replaces and retracting overwrites in place.
|
||||
class NostrRatingTransport implements RatingTransport {
|
||||
NostrRatingTransport(this._conn);
|
||||
|
||||
final NostrChannel _conn;
|
||||
|
||||
/// Custom Tanemaki rating kind (addressable range, next to trust's 30777).
|
||||
static const kindRating = 30778;
|
||||
|
||||
@override
|
||||
Future<void> rate({
|
||||
required String subjectPubkey,
|
||||
required int stars,
|
||||
String comment = '',
|
||||
}) {
|
||||
if (stars < Rating.minStars || stars > Rating.maxStars) {
|
||||
throw ArgumentError.value(
|
||||
stars, 'stars', 'must be ${Rating.minStars}..${Rating.maxStars}');
|
||||
}
|
||||
return _publish(subjectPubkey,
|
||||
stars: stars, comment: comment, retracted: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> retract({required String subjectPubkey}) =>
|
||||
_publish(subjectPubkey, stars: Rating.minStars, comment: '',
|
||||
retracted: true);
|
||||
|
||||
Future<void> _publish(
|
||||
String subject, {
|
||||
required int stars,
|
||||
required String comment,
|
||||
required bool retracted,
|
||||
}) async {
|
||||
final event = Event.from(
|
||||
kind: kindRating,
|
||||
content: comment,
|
||||
tags: [
|
||||
['p', subject],
|
||||
['d', subject],
|
||||
['rating', '$stars'],
|
||||
['status', retracted ? 'retracted' : 'valid'],
|
||||
],
|
||||
secretKey: _conn.privateKeyHex,
|
||||
);
|
||||
final r = await _conn.publish(event);
|
||||
if (!r.accepted) throw StateError('relay rejected rating: ${r.message}');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Rating>> ratingsOf(String subjectPubkey) async {
|
||||
final events = await _conn.reqOnce(
|
||||
Filter(kinds: const [kindRating], pTags: [subjectPubkey]),
|
||||
);
|
||||
// One rating per rater: keep the newest event should a relay return
|
||||
// several, then drop retractions.
|
||||
final newestByRater = <String, Event>{};
|
||||
for (final e in events) {
|
||||
final prev = newestByRater[e.pubkey];
|
||||
if (prev == null || _newer(e, prev)) newestByRater[e.pubkey] = e;
|
||||
}
|
||||
return newestByRater.values
|
||||
.map(_toRating)
|
||||
.whereType<Rating>()
|
||||
.where((r) => r.subject == subjectPubkey && r.isActive)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Rating?> myRatingOf(String subjectPubkey) async {
|
||||
final mine = await _conn.reqOnce(Filter(
|
||||
kinds: const [kindRating],
|
||||
authors: [_conn.publicKeyHex],
|
||||
pTags: [subjectPubkey],
|
||||
));
|
||||
Event? newest;
|
||||
for (final e in mine) {
|
||||
if (newest == null || _newer(e, newest)) newest = e;
|
||||
}
|
||||
final rating = newest == null ? null : _toRating(newest);
|
||||
return (rating != null && rating.isActive) ? rating : null;
|
||||
}
|
||||
|
||||
/// NIP-01 replacement order: newer created_at wins; on a tie the
|
||||
/// lexicographically smallest id does.
|
||||
static bool _newer(Event a, Event b) =>
|
||||
a.createdAt != b.createdAt
|
||||
? a.createdAt > b.createdAt
|
||||
: a.id.compareTo(b.id) < 0;
|
||||
|
||||
/// Null for malformed events (missing/out-of-range stars).
|
||||
Rating? _toRating(Event e) {
|
||||
String? tag(String name) {
|
||||
for (final t in e.tags) {
|
||||
if (t.isNotEmpty && t[0] == name && t.length > 1) return t[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final stars = int.tryParse(tag('rating') ?? '');
|
||||
if (stars == null || stars < Rating.minStars || stars > Rating.maxStars) {
|
||||
return null;
|
||||
}
|
||||
return Rating(
|
||||
rater: e.pubkey,
|
||||
subject: tag('p') ?? tag('d') ?? '',
|
||||
stars: stars,
|
||||
ratedAt: DateTime.fromMillisecondsSinceEpoch(e.createdAt * 1000),
|
||||
comment: e.content,
|
||||
retracted: tag('status') == 'retracted',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() => _conn.close();
|
||||
}
|
||||
44
packages/commons_core/lib/src/social/rating.dart
Normal file
44
packages/commons_core/lib/src/social/rating.dart
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// One "A rates B" review: stars plus an optional short comment. Public by
|
||||
/// design and signed by the rater. One live rating per (rater, subject) pair —
|
||||
/// re-rating replaces, so a single author can never pile up reviews. Generic
|
||||
/// core type — no seed specifics.
|
||||
class Rating extends Equatable {
|
||||
Rating({
|
||||
required this.rater,
|
||||
required this.subject,
|
||||
required this.stars,
|
||||
required this.ratedAt,
|
||||
this.comment = '',
|
||||
this.retracted = false,
|
||||
}) {
|
||||
if (stars < minStars || stars > maxStars) {
|
||||
throw ArgumentError.value(stars, 'stars', 'must be $minStars..$maxStars');
|
||||
}
|
||||
}
|
||||
|
||||
static const minStars = 1;
|
||||
static const maxStars = 5;
|
||||
|
||||
/// Rater public key (hex).
|
||||
final String rater;
|
||||
|
||||
/// Rated public key (hex).
|
||||
final String subject;
|
||||
|
||||
/// 1..5, Wallapop style.
|
||||
final int stars;
|
||||
|
||||
final DateTime ratedAt;
|
||||
final String comment;
|
||||
|
||||
/// A rater can take their rating back; a retracted rating doesn't count.
|
||||
final bool retracted;
|
||||
|
||||
bool get isActive => !retracted;
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[rater, subject, stars, ratedAt, comment, retracted];
|
||||
}
|
||||
25
packages/commons_core/lib/src/social/rating_transport.dart
Normal file
25
packages/commons_core/lib/src/social/rating_transport.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import 'rating.dart';
|
||||
|
||||
/// The reputation contract over the shared connection: rate / retract / read
|
||||
/// ratings. Ratings are public signed events (like certifications), so this
|
||||
/// transport publishes and discovers rather than encrypts. One live rating per
|
||||
/// (rater, subject) pair — rating again replaces the previous one.
|
||||
abstract interface class RatingTransport {
|
||||
/// Publishes (or replaces) this identity's rating of [subjectPubkey].
|
||||
Future<void> rate({
|
||||
required String subjectPubkey,
|
||||
required int stars,
|
||||
String comment = '',
|
||||
});
|
||||
|
||||
/// Takes back this identity's rating of [subjectPubkey].
|
||||
Future<void> retract({required String subjectPubkey});
|
||||
|
||||
/// Active ratings of [subjectPubkey], one per rater.
|
||||
Future<List<Rating>> ratingsOf(String subjectPubkey);
|
||||
|
||||
/// This identity's active rating of [subjectPubkey], or null.
|
||||
Future<Rating?> myRatingOf(String subjectPubkey);
|
||||
|
||||
Future<void> close();
|
||||
}
|
||||
|
|
@ -237,4 +237,75 @@ void main() {
|
|||
await trust.close();
|
||||
});
|
||||
});
|
||||
|
||||
group('ratings (custom kind)', () {
|
||||
test('published by raters, read back with stars and comments', () async {
|
||||
final alice = await idFor(1);
|
||||
final bob = await idFor(2);
|
||||
final carol = await idFor(3);
|
||||
|
||||
final aliceR = NostrRatingTransport(await connFor(alice));
|
||||
await aliceR.rate(
|
||||
subjectPubkey: carol.publicKeyHex,
|
||||
stars: 5,
|
||||
comment: 'Lovely seeds',
|
||||
);
|
||||
await aliceR.close();
|
||||
final bobR = NostrRatingTransport(await connFor(bob));
|
||||
await bobR.rate(subjectPubkey: carol.publicKeyHex, stars: 3);
|
||||
await bobR.close();
|
||||
|
||||
final reader = NostrRatingTransport(await connFor(await idFor(99)));
|
||||
final ratings = await reader.ratingsOf(carol.publicKeyHex);
|
||||
expect(ratings, hasLength(2));
|
||||
final byRater = {for (final r in ratings) r.rater: r};
|
||||
expect(byRater[alice.publicKeyHex]!.stars, 5);
|
||||
expect(byRater[alice.publicKeyHex]!.comment, 'Lovely seeds');
|
||||
expect(byRater[bob.publicKeyHex]!.stars, 3);
|
||||
await reader.close();
|
||||
});
|
||||
|
||||
test('re-rating replaces (one live rating per pair); retract removes',
|
||||
() async {
|
||||
final alice = await idFor(1);
|
||||
final carol = await idFor(3);
|
||||
|
||||
final aliceR = NostrRatingTransport(await connFor(alice));
|
||||
await aliceR.rate(subjectPubkey: carol.publicKeyHex, stars: 2);
|
||||
// createdAt has second granularity; cross the boundary so the
|
||||
// replacement is unambiguously newer.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1100));
|
||||
await aliceR.rate(
|
||||
subjectPubkey: carol.publicKeyHex,
|
||||
stars: 4,
|
||||
comment: 'Better this season',
|
||||
);
|
||||
|
||||
final mine = await aliceR.myRatingOf(carol.publicKeyHex);
|
||||
expect(mine!.stars, 4);
|
||||
final all = await aliceR.ratingsOf(carol.publicKeyHex);
|
||||
expect(all, hasLength(1));
|
||||
expect(all.single.stars, 4);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1100));
|
||||
await aliceR.retract(subjectPubkey: carol.publicKeyHex);
|
||||
expect(await aliceR.myRatingOf(carol.publicKeyHex), isNull);
|
||||
expect(await aliceR.ratingsOf(carol.publicKeyHex), isEmpty);
|
||||
await aliceR.close();
|
||||
});
|
||||
|
||||
test('rejects out-of-range stars before touching the relay', () async {
|
||||
final alice = await idFor(1);
|
||||
final aliceR = NostrRatingTransport(await connFor(alice));
|
||||
expect(
|
||||
() => aliceR.rate(subjectPubkey: 'ff' * 32, stars: 0),
|
||||
throwsArgumentError,
|
||||
);
|
||||
expect(
|
||||
() => aliceR.rate(subjectPubkey: 'ff' * 32, stars: 6),
|
||||
throwsArgumentError,
|
||||
);
|
||||
await aliceR.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
29
packages/commons_core/test/social/rating_test.dart
Normal file
29
packages/commons_core/test/social/rating_test.dart
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
Rating make(int stars, {bool retracted = false}) => Rating(
|
||||
rater: 'a',
|
||||
subject: 'b',
|
||||
stars: stars,
|
||||
ratedAt: DateTime(2026),
|
||||
retracted: retracted,
|
||||
);
|
||||
|
||||
test('accepts 1..5 stars', () {
|
||||
for (var s = Rating.minStars; s <= Rating.maxStars; s++) {
|
||||
expect(make(s).stars, s);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects out-of-range stars', () {
|
||||
expect(() => make(0), throwsArgumentError);
|
||||
expect(() => make(6), throwsArgumentError);
|
||||
expect(() => make(-1), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('a retracted rating is not active', () {
|
||||
expect(make(5).isActive, isTrue);
|
||||
expect(make(5, retracted: true).isActive, isFalse);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue