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
456cc8154d
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
|
## Phasing — build order
|
||||||
|
|
||||||
**Block 1: Inventory.** Shippable alone, useful with zero network. Delivered (beta).
|
**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
|
## 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_connection.dart';
|
||||||
import 'services/social_service.dart';
|
import 'services/social_service.dart';
|
||||||
import 'services/social_settings.dart';
|
import 'services/social_settings.dart';
|
||||||
import 'services/trust_referents.dart';
|
|
||||||
import 'services/wot_settings.dart';
|
|
||||||
import 'state/inventory_cubit.dart';
|
import 'state/inventory_cubit.dart';
|
||||||
import 'state/variety_detail_cubit.dart';
|
import 'state/variety_detail_cubit.dart';
|
||||||
import 'ui/about_screen.dart';
|
import 'ui/about_screen.dart';
|
||||||
|
|
@ -41,8 +39,8 @@ import 'ui/offline_banner.dart';
|
||||||
import 'ui/profile_screen.dart';
|
import 'ui/profile_screen.dart';
|
||||||
import 'ui/settings_screen.dart';
|
import 'ui/settings_screen.dart';
|
||||||
import 'ui/theme.dart';
|
import 'ui/theme.dart';
|
||||||
import 'ui/trust_network_screen.dart';
|
|
||||||
import 'ui/variety_detail_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:
|
/// Root widget. Provides the repositories to the tree and wires go_router:
|
||||||
/// `/` is the home menu, `/inventory` the list, `/variety/:id` the item detail.
|
/// `/` is the home menu, `/inventory` the list, `/variety/:id` the item detail.
|
||||||
|
|
@ -61,8 +59,6 @@ class TaneApp extends StatelessWidget {
|
||||||
this.profileStore,
|
this.profileStore,
|
||||||
this.profileCache,
|
this.profileCache,
|
||||||
this.socialAccounts,
|
this.socialAccounts,
|
||||||
this.trustReferents,
|
|
||||||
this.wotSettings,
|
|
||||||
this.inbox,
|
this.inbox,
|
||||||
this.notifications,
|
this.notifications,
|
||||||
this.showIntro = false,
|
this.showIntro = false,
|
||||||
|
|
@ -81,8 +77,6 @@ class TaneApp extends StatelessWidget {
|
||||||
profileStore,
|
profileStore,
|
||||||
profileCache,
|
profileCache,
|
||||||
socialAccounts,
|
socialAccounts,
|
||||||
trustReferents,
|
|
||||||
wotSettings,
|
|
||||||
inbox,
|
inbox,
|
||||||
) {
|
) {
|
||||||
// A tapped message notification opens that peer's chat. Wired here because
|
// 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.
|
/// Optional store of the active social identity, for the profile switcher.
|
||||||
final SocialAccountStore? socialAccounts;
|
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.
|
/// App-wide inbox listener; drives the messages list's live refresh.
|
||||||
final InboxService? inbox;
|
final InboxService? inbox;
|
||||||
|
|
||||||
|
|
@ -151,8 +141,6 @@ class TaneApp extends StatelessWidget {
|
||||||
ProfileStore? profileStore,
|
ProfileStore? profileStore,
|
||||||
ProfileCache? profileCache,
|
ProfileCache? profileCache,
|
||||||
SocialAccountStore? socialAccounts,
|
SocialAccountStore? socialAccounts,
|
||||||
TrustReferents? trustReferents,
|
|
||||||
WotSettings? wotSettings,
|
|
||||||
InboxService? inbox,
|
InboxService? inbox,
|
||||||
) {
|
) {
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
|
|
@ -184,6 +172,7 @@ class TaneApp extends StatelessWidget {
|
||||||
connection: connection,
|
connection: connection,
|
||||||
mine: offer.authorPubkeyHex == social.publicKeyHex,
|
mine: offer.authorPubkeyHex == social.publicKeyHex,
|
||||||
profileCache: profileCache,
|
profileCache: profileCache,
|
||||||
|
selfPubkey: social.publicKeyHex,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -208,16 +197,16 @@ class TaneApp extends StatelessWidget {
|
||||||
connection: connection,
|
connection: connection,
|
||||||
profileStore: profileStore,
|
profileStore: profileStore,
|
||||||
accounts: socialAccounts,
|
accounts: socialAccounts,
|
||||||
trustNetworkEnabled:
|
yourPeopleEnabled: true,
|
||||||
trustReferents != null && wotSettings != null,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (trustReferents != null && wotSettings != null)
|
if (social != null && connection != null)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/trust',
|
path: '/your-people',
|
||||||
builder: (context, state) => TrustNetworkScreen(
|
builder: (context, state) => YourPeopleScreen(
|
||||||
referents: trustReferents,
|
social: social,
|
||||||
wotSettings: wotSettings,
|
connection: connection,
|
||||||
|
profileCache: profileCache,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (social != null && connection != null)
|
if (social != null && connection != null)
|
||||||
|
|
@ -229,8 +218,6 @@ class TaneApp extends StatelessWidget {
|
||||||
peerPubkey: state.pathParameters['pubkey']!,
|
peerPubkey: state.pathParameters['pubkey']!,
|
||||||
messageStore: messageStore,
|
messageStore: messageStore,
|
||||||
profileCache: profileCache,
|
profileCache: profileCache,
|
||||||
trustReferents: trustReferents,
|
|
||||||
wotSettings: wotSettings,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,6 @@ import 'services/social_connection.dart';
|
||||||
import 'services/social_service.dart';
|
import 'services/social_service.dart';
|
||||||
import 'services/social_settings.dart';
|
import 'services/social_settings.dart';
|
||||||
import 'services/sync_service.dart';
|
import 'services/sync_service.dart';
|
||||||
import 'services/trust_referents.dart';
|
|
||||||
import 'services/wot_settings.dart';
|
|
||||||
import 'ui/theme.dart';
|
import 'ui/theme.dart';
|
||||||
|
|
||||||
/// Boots the app WITHOUT blocking the first frame: paints a splash immediately,
|
/// Boots the app WITHOUT blocking the first frame: paints a splash immediately,
|
||||||
|
|
@ -88,8 +86,6 @@ class _BootstrapState extends State<Bootstrap> {
|
||||||
profileStore: getIt<ProfileStore>(),
|
profileStore: getIt<ProfileStore>(),
|
||||||
profileCache: getIt<ProfileCache>(),
|
profileCache: getIt<ProfileCache>(),
|
||||||
socialAccounts: getIt<SocialAccountStore>(),
|
socialAccounts: getIt<SocialAccountStore>(),
|
||||||
trustReferents: getIt<TrustReferents>(),
|
|
||||||
wotSettings: getIt<WotSettings>(),
|
|
||||||
inbox: inbox,
|
inbox: inbox,
|
||||||
notifications: notifications,
|
notifications: notifications,
|
||||||
showIntro: !await onboarding.introSeen(),
|
showIntro: !await onboarding.introSeen(),
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,7 @@ import '../services/social_connection.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_service.dart';
|
||||||
import '../services/social_settings.dart';
|
import '../services/social_settings.dart';
|
||||||
import '../services/sync_service.dart';
|
import '../services/sync_service.dart';
|
||||||
import '../services/trust_referents.dart';
|
|
||||||
import '../services/unread_service.dart';
|
import '../services/unread_service.dart';
|
||||||
import '../services/wot_settings.dart';
|
|
||||||
|
|
||||||
/// The app's service locator. Kept to the composition root — widgets get their
|
/// 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
|
/// 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<LocaleStore>(LocaleStore(secretStore))
|
||||||
..registerSingleton<SocialSettings>(SocialSettings(secretStore))
|
..registerSingleton<SocialSettings>(SocialSettings(secretStore))
|
||||||
..registerSingleton<SocialAccountStore>(accounts)
|
..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))
|
..registerSingleton<OfferOutbox>(OfferOutbox(secretStore))
|
||||||
// Per-identity stores are namespaced by the active account's scope.
|
// Per-identity stores are namespaced by the active account's scope.
|
||||||
..registerSingleton<MessageStore>(
|
..registerSingleton<MessageStore>(
|
||||||
|
|
|
||||||
|
|
@ -568,29 +568,26 @@
|
||||||
"count": "Avalada por {n}",
|
"count": "Avalada por {n}",
|
||||||
"vouch": "Conozo a esta persona",
|
"vouch": "Conozo a esta persona",
|
||||||
"vouched": "Avales a esta persona",
|
"vouched": "Avales a esta persona",
|
||||||
"circle": "Nel to círculu",
|
"circle": "Nel to círculu"
|
||||||
"member": "Miembru de confianza de la rede"
|
|
||||||
},
|
},
|
||||||
"wot": {
|
"yourPeople": {
|
||||||
"title": "Rede de confianza",
|
"title": "La to xente",
|
||||||
"open": "Rede de confianza",
|
"help": "Persones que conoces y avales, y persones que t'avalen.",
|
||||||
"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.",
|
"youVouchFor": "Tu avales a",
|
||||||
"roots": "Raíces de confianza",
|
"vouchesForYou": "Aválente",
|
||||||
"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.",
|
"youVouchForEmpty": "Entá nun avales a naide. Cuando conozas a daquién, abri la vuestra conversación y calca \"Conozo a esta persona\".",
|
||||||
"noRoots": "Entá nun hai raíces de confianza — nun se pue calcular la pertenencia hasta semar la rede.",
|
"vouchesForYouEmpty": "Naide t'avala entá",
|
||||||
"addRoot": "Amestar una raíz de confianza",
|
"revoke": "Dexar d'avalar",
|
||||||
"rootHint": "Apega un códigu d'identidá (npub…)",
|
"revokeConfirm": "¿Dexar d'avalar a esta persona?",
|
||||||
"add": "Amestar",
|
"offline": "Ensin conexón — vuelvi tentalo cuando teas en llinia"
|
||||||
"rootAdded": "Raíz de confianza amestada",
|
},
|
||||||
"rootInvalid": "Esi nun ye un códigu d'identidá válidu",
|
"ratings": {
|
||||||
"remove": "Quitar",
|
"rate": "Valorar a esta persona",
|
||||||
"params": "Parámetros avanzaos",
|
"edit": "Editar la to valoración",
|
||||||
"paramsHelp": "Regles Duniter. Aflóxales pa una rede nueva; apriétales según crez.",
|
"commentHint": "¿Qué tal foi? (opcional)",
|
||||||
"sigQty": "Avales pa ser miembru",
|
"fromYourCircle": "{n} de xente que conoces",
|
||||||
"stepMax": "Distancia máxima a una raíz",
|
"retract": "Quitar la to valoración",
|
||||||
"validityDays": "Validez del aval (díes)",
|
"saved": "Valoración guardada"
|
||||||
"reset": "Reafitar a valores Duniter",
|
|
||||||
"saved": "Guardáu"
|
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"newMessageFrom": "Mensaxe nuevu de {name}"
|
"newMessageFrom": "Mensaxe nuevu de {name}"
|
||||||
|
|
|
||||||
|
|
@ -571,29 +571,26 @@
|
||||||
"count": "Vouched for by {n}",
|
"count": "Vouched for by {n}",
|
||||||
"vouch": "I know this person",
|
"vouch": "I know this person",
|
||||||
"vouched": "You vouch for them",
|
"vouched": "You vouch for them",
|
||||||
"circle": "In your circle",
|
"circle": "In your circle"
|
||||||
"member": "Trusted member of the network"
|
|
||||||
},
|
},
|
||||||
"wot": {
|
"yourPeople": {
|
||||||
"title": "Network of trust",
|
"title": "Your people",
|
||||||
"open": "Network of trust",
|
"help": "People you've met and vouch for, and people who vouch for you.",
|
||||||
"help": "How trusted membership is decided: people vouch for each other, and trust spreads outward from a set of founding roots.",
|
"youVouchFor": "You vouch for",
|
||||||
"roots": "Trust roots",
|
"vouchesForYou": "They vouch for you",
|
||||||
"rootsHelp": "The founding identities the network is anchored to. Add people you trust (paste their identity code); membership is measured outward from them.",
|
"youVouchForEmpty": "You don't vouch for anyone yet. When you meet someone, open your chat with them and tap \"I know this person\".",
|
||||||
"noRoots": "No trust roots yet — membership can't be worked out until the network is seeded.",
|
"vouchesForYouEmpty": "No one vouches for you yet",
|
||||||
"addRoot": "Add a trust root",
|
"revoke": "Stop vouching",
|
||||||
"rootHint": "Paste an identity code (npub…)",
|
"revokeConfirm": "Stop vouching for this person?",
|
||||||
"add": "Add",
|
"offline": "You're offline — try again when you're connected"
|
||||||
"rootAdded": "Trust root added",
|
},
|
||||||
"rootInvalid": "That's not a valid identity code",
|
"ratings": {
|
||||||
"remove": "Remove",
|
"rate": "Rate this person",
|
||||||
"params": "Advanced parameters",
|
"edit": "Edit your rating",
|
||||||
"paramsHelp": "Duniter rules. Loosen them for a young network; tighten as it grows.",
|
"commentHint": "How did it go? (optional)",
|
||||||
"sigQty": "Vouches to become a member",
|
"fromYourCircle": "{n} from people you know",
|
||||||
"stepMax": "Max distance from a root",
|
"retract": "Remove your rating",
|
||||||
"validityDays": "Vouch validity (days)",
|
"saved": "Rating saved"
|
||||||
"reset": "Reset to Duniter defaults",
|
|
||||||
"saved": "Saved"
|
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"newMessageFrom": "New message from {name}"
|
"newMessageFrom": "New message from {name}"
|
||||||
|
|
|
||||||
|
|
@ -570,29 +570,26 @@
|
||||||
"count": "Avalada por {n}",
|
"count": "Avalada por {n}",
|
||||||
"vouch": "Conozco a esta persona",
|
"vouch": "Conozco a esta persona",
|
||||||
"vouched": "Avalas a esta persona",
|
"vouched": "Avalas a esta persona",
|
||||||
"circle": "En tu círculo",
|
"circle": "En tu círculo"
|
||||||
"member": "Miembro de confianza de la red"
|
|
||||||
},
|
},
|
||||||
"wot": {
|
"yourPeople": {
|
||||||
"title": "Red de confianza",
|
"title": "Tu gente",
|
||||||
"open": "Red de confianza",
|
"help": "Personas que conoces y avalas, y personas que te avalan.",
|
||||||
"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.",
|
"youVouchFor": "Tú avalas a",
|
||||||
"roots": "Raíces de confianza",
|
"vouchesForYou": "Te avalan",
|
||||||
"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.",
|
"youVouchForEmpty": "Aún no avalas a nadie. Cuando conozcas a alguien, abre vuestro chat y toca \"Conozco a esta persona\".",
|
||||||
"noRoots": "Aún no hay raíces de confianza — no se puede calcular la membresía hasta sembrar la red.",
|
"vouchesForYouEmpty": "Nadie te avala todavía",
|
||||||
"addRoot": "Añadir una raíz de confianza",
|
"revoke": "Dejar de avalar",
|
||||||
"rootHint": "Pega un código de identidad (npub…)",
|
"revokeConfirm": "¿Dejar de avalar a esta persona?",
|
||||||
"add": "Añadir",
|
"offline": "Sin conexión — inténtalo cuando estés en línea"
|
||||||
"rootAdded": "Raíz de confianza añadida",
|
},
|
||||||
"rootInvalid": "Ese no es un código de identidad válido",
|
"ratings": {
|
||||||
"remove": "Quitar",
|
"rate": "Valorar a esta persona",
|
||||||
"params": "Parámetros avanzados",
|
"edit": "Editar tu valoración",
|
||||||
"paramsHelp": "Reglas Duniter. Aflójalas para una red joven; apriétalas según crece.",
|
"commentHint": "¿Qué tal fue? (opcional)",
|
||||||
"sigQty": "Avales para ser miembro",
|
"fromYourCircle": "{n} de gente que conoces",
|
||||||
"stepMax": "Distancia máxima a una raíz",
|
"retract": "Quitar tu valoración",
|
||||||
"validityDays": "Validez del aval (días)",
|
"saved": "Valoración guardada"
|
||||||
"reset": "Restablecer a valores Duniter",
|
|
||||||
"saved": "Guardado"
|
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"newMessageFrom": "Nuevo mensaje de {name}"
|
"newMessageFrom": "Nuevo mensaje de {name}"
|
||||||
|
|
|
||||||
|
|
@ -567,29 +567,26 @@
|
||||||
"count": "Avalizada por {n}",
|
"count": "Avalizada por {n}",
|
||||||
"vouch": "Conheço esta pessoa",
|
"vouch": "Conheço esta pessoa",
|
||||||
"vouched": "Avalizas esta pessoa",
|
"vouched": "Avalizas esta pessoa",
|
||||||
"circle": "No teu círculo",
|
"circle": "No teu círculo"
|
||||||
"member": "Membro de confiança da rede"
|
|
||||||
},
|
},
|
||||||
"wot": {
|
"yourPeople": {
|
||||||
"title": "Rede de confiança",
|
"title": "A tua gente",
|
||||||
"open": "Rede de confiança",
|
"help": "Pessoas que conheces e avalizas, e pessoas que te avalizam.",
|
||||||
"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.",
|
"youVouchFor": "Tu avalizas",
|
||||||
"roots": "Raízes de confiança",
|
"vouchesForYou": "Avalizam-te",
|
||||||
"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.",
|
"youVouchForEmpty": "Ainda não avalizas ninguém. Quando conheceres alguém, abre a vossa conversa e toca em \"Conheço esta pessoa\".",
|
||||||
"noRoots": "Ainda não há raízes de confiança — não se pode calcular a filiação até semear a rede.",
|
"vouchesForYouEmpty": "Ainda ninguém te avaliza",
|
||||||
"addRoot": "Adicionar uma raiz de confiança",
|
"revoke": "Deixar de avalizar",
|
||||||
"rootHint": "Cola um código de identidade (npub…)",
|
"revokeConfirm": "Deixar de avalizar esta pessoa?",
|
||||||
"add": "Adicionar",
|
"offline": "Sem ligação — tenta novamente quando estiveres em linha"
|
||||||
"rootAdded": "Raiz de confiança adicionada",
|
},
|
||||||
"rootInvalid": "Esse não é um código de identidade válido",
|
"ratings": {
|
||||||
"remove": "Remover",
|
"rate": "Avaliar esta pessoa",
|
||||||
"params": "Parâmetros avançados",
|
"edit": "Editar a tua avaliação",
|
||||||
"paramsHelp": "Regras Duniter. Afrouxa-as para uma rede jovem; aperta-as à medida que cresce.",
|
"commentHint": "Como correu? (opcional)",
|
||||||
"sigQty": "Avais para ser membro",
|
"fromYourCircle": "{n} de pessoas que conheces",
|
||||||
"stepMax": "Distância máxima a uma raiz",
|
"retract": "Remover a tua avaliação",
|
||||||
"validityDays": "Validade do aval (dias)",
|
"saved": "Avaliação guardada"
|
||||||
"reset": "Repor para valores Duniter",
|
|
||||||
"saved": "Guardado"
|
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"newMessageFrom": "Nova mensagem de {name}"
|
"newMessageFrom": "Nova mensagem de {name}"
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 4
|
/// 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
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// 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$chatList$ast chatList = _Translations$chatList$ast._(_root);
|
||||||
@override late final _Translations$chat$ast chat = _Translations$chat$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$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$notifications$ast notifications = _Translations$notifications$ast._(_root);
|
||||||
@override late final _Translations$plantare$ast plantare = _Translations$plantare$ast._(_root);
|
@override late final _Translations$plantare$ast plantare = _Translations$plantare$ast._(_root);
|
||||||
@override late final _Translations$handover$ast handover = _Translations$handover$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 vouch => 'Conozo a esta persona';
|
||||||
@override String get vouched => 'Avales a esta persona';
|
@override String get vouched => 'Avales a esta persona';
|
||||||
@override String get circle => 'Nel to círculu';
|
@override String get circle => 'Nel to círculu';
|
||||||
@override String get member => 'Miembru de confianza de la rede';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: wot
|
// Path: yourPeople
|
||||||
class _Translations$wot$ast extends Translations$wot$en {
|
class _Translations$yourPeople$ast extends Translations$yourPeople$en {
|
||||||
_Translations$wot$ast._(TranslationsAst root) : this._root = root, super.internal(root);
|
_Translations$yourPeople$ast._(TranslationsAst root) : this._root = root, super.internal(root);
|
||||||
|
|
||||||
final TranslationsAst _root; // ignore: unused_field
|
final TranslationsAst _root; // ignore: unused_field
|
||||||
|
|
||||||
// Translations
|
// Translations
|
||||||
@override String get title => 'Rede de confianza';
|
@override String get title => 'La to xente';
|
||||||
@override String get open => 'Rede de confianza';
|
@override String get help => 'Persones que conoces y avales, y persones que t\'avalen.';
|
||||||
@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 youVouchFor => 'Tu avales a';
|
||||||
@override String get roots => 'Raíces de confianza';
|
@override String get vouchesForYou => 'Aválente';
|
||||||
@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 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 noRoots => 'Entá nun hai raíces de confianza — nun se pue calcular la pertenencia hasta semar la rede.';
|
@override String get vouchesForYouEmpty => 'Naide t\'avala entá';
|
||||||
@override String get addRoot => 'Amestar una raíz de confianza';
|
@override String get revoke => 'Dexar d\'avalar';
|
||||||
@override String get rootHint => 'Apega un códigu d\'identidá (npub…)';
|
@override String get revokeConfirm => '¿Dexar d\'avalar a esta persona?';
|
||||||
@override String get add => 'Amestar';
|
@override String get offline => 'Ensin conexón — vuelvi tentalo cuando teas en llinia';
|
||||||
@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';
|
// Path: ratings
|
||||||
@override String get params => 'Parámetros avanzaos';
|
class _Translations$ratings$ast extends Translations$ratings$en {
|
||||||
@override String get paramsHelp => 'Regles Duniter. Aflóxales pa una rede nueva; apriétales según crez.';
|
_Translations$ratings$ast._(TranslationsAst root) : this._root = root, super.internal(root);
|
||||||
@override String get sigQty => 'Avales pa ser miembru';
|
|
||||||
@override String get stepMax => 'Distancia máxima a una raíz';
|
final TranslationsAst _root; // ignore: unused_field
|
||||||
@override String get validityDays => 'Validez del aval (díes)';
|
|
||||||
@override String get reset => 'Reafitar a valores Duniter';
|
// Translations
|
||||||
@override String get saved => 'Guardáu';
|
@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
|
// Path: notifications
|
||||||
|
|
@ -1718,26 +1723,21 @@ extension on TranslationsAst {
|
||||||
'trust.vouch' => 'Conozo a esta persona',
|
'trust.vouch' => 'Conozo a esta persona',
|
||||||
'trust.vouched' => 'Avales a esta persona',
|
'trust.vouched' => 'Avales a esta persona',
|
||||||
'trust.circle' => 'Nel to círculu',
|
'trust.circle' => 'Nel to círculu',
|
||||||
'trust.member' => 'Miembru de confianza de la rede',
|
'yourPeople.title' => 'La to xente',
|
||||||
'wot.title' => 'Rede de confianza',
|
'yourPeople.help' => 'Persones que conoces y avales, y persones que t\'avalen.',
|
||||||
'wot.open' => 'Rede de confianza',
|
'yourPeople.youVouchFor' => 'Tu avales a',
|
||||||
'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.',
|
'yourPeople.vouchesForYou' => 'Aválente',
|
||||||
'wot.roots' => 'Raíces de confianza',
|
'yourPeople.youVouchForEmpty' => 'Entá nun avales a naide. Cuando conozas a daquién, abri la vuestra conversación y calca "Conozo a esta persona".',
|
||||||
'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.',
|
'yourPeople.vouchesForYouEmpty' => 'Naide t\'avala entá',
|
||||||
'wot.noRoots' => 'Entá nun hai raíces de confianza — nun se pue calcular la pertenencia hasta semar la rede.',
|
'yourPeople.revoke' => 'Dexar d\'avalar',
|
||||||
'wot.addRoot' => 'Amestar una raíz de confianza',
|
'yourPeople.revokeConfirm' => '¿Dexar d\'avalar a esta persona?',
|
||||||
'wot.rootHint' => 'Apega un códigu d\'identidá (npub…)',
|
'yourPeople.offline' => 'Ensin conexón — vuelvi tentalo cuando teas en llinia',
|
||||||
'wot.add' => 'Amestar',
|
'ratings.rate' => 'Valorar a esta persona',
|
||||||
'wot.rootAdded' => 'Raíz de confianza amestada',
|
'ratings.edit' => 'Editar la to valoración',
|
||||||
'wot.rootInvalid' => 'Esi nun ye un códigu d\'identidá válidu',
|
'ratings.commentHint' => '¿Qué tal foi? (opcional)',
|
||||||
'wot.remove' => 'Quitar',
|
'ratings.fromYourCircle' => ({required Object n}) => '${n} de xente que conoces',
|
||||||
'wot.params' => 'Parámetros avanzaos',
|
'ratings.retract' => 'Quitar la to valoración',
|
||||||
'wot.paramsHelp' => 'Regles Duniter. Aflóxales pa una rede nueva; apriétales según crez.',
|
'ratings.saved' => 'Valoración guardada',
|
||||||
'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',
|
|
||||||
'notifications.newMessageFrom' => ({required Object name}) => 'Mensaxe nuevu de ${name}',
|
'notifications.newMessageFrom' => ({required Object name}) => 'Mensaxe nuevu de ${name}',
|
||||||
'plantare.title' => 'Plantares',
|
'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.',
|
'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$chatList$en chatList = Translations$chatList$en.internal(_root);
|
||||||
late final Translations$chat$en chat = Translations$chat$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$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$notifications$en notifications = Translations$notifications$en.internal(_root);
|
||||||
late final Translations$plantare$en plantare = Translations$plantare$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);
|
late final Translations$handover$en handover = Translations$handover$en.internal(_root);
|
||||||
|
|
@ -1565,75 +1566,69 @@ class Translations$trust$en {
|
||||||
|
|
||||||
/// en: 'In your circle'
|
/// en: 'In your circle'
|
||||||
String get circle => '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
|
// Path: yourPeople
|
||||||
class Translations$wot$en {
|
class Translations$yourPeople$en {
|
||||||
Translations$wot$en.internal(this._root);
|
Translations$yourPeople$en.internal(this._root);
|
||||||
|
|
||||||
final Translations _root; // ignore: unused_field
|
final Translations _root; // ignore: unused_field
|
||||||
|
|
||||||
// Translations
|
// Translations
|
||||||
|
|
||||||
/// en: 'Network of trust'
|
/// en: 'Your people'
|
||||||
String get title => 'Network of trust';
|
String get title => 'Your people';
|
||||||
|
|
||||||
/// en: 'Network of trust'
|
/// en: 'People you've met and vouch for, and people who vouch for you.'
|
||||||
String get open => 'Network of trust';
|
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.'
|
/// en: 'You vouch for'
|
||||||
String get help => 'How trusted membership is decided: people vouch for each other, and trust spreads outward from a set of founding roots.';
|
String get youVouchFor => 'You vouch for';
|
||||||
|
|
||||||
/// en: 'Trust roots'
|
/// en: 'They vouch for you'
|
||||||
String get roots => 'Trust roots';
|
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.'
|
/// 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 rootsHelp => 'The founding identities the network is anchored to. Add people you trust (paste their identity code); membership is measured outward from them.';
|
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.'
|
/// en: 'No one vouches for you yet'
|
||||||
String get noRoots => 'No trust roots yet — membership can\'t be worked out until the network is seeded.';
|
String get vouchesForYouEmpty => 'No one vouches for you yet';
|
||||||
|
|
||||||
/// en: 'Add a trust root'
|
/// en: 'Stop vouching'
|
||||||
String get addRoot => 'Add a trust root';
|
String get revoke => 'Stop vouching';
|
||||||
|
|
||||||
/// en: 'Paste an identity code (npub…)'
|
/// en: 'Stop vouching for this person?'
|
||||||
String get rootHint => 'Paste an identity code (npub…)';
|
String get revokeConfirm => 'Stop vouching for this person?';
|
||||||
|
|
||||||
/// en: 'Add'
|
/// en: 'You're offline — try again when you're connected'
|
||||||
String get add => 'Add';
|
String get offline => 'You\'re offline — try again when you\'re connected';
|
||||||
|
}
|
||||||
|
|
||||||
/// en: 'Trust root added'
|
// Path: ratings
|
||||||
String get rootAdded => 'Trust root added';
|
class Translations$ratings$en {
|
||||||
|
Translations$ratings$en.internal(this._root);
|
||||||
|
|
||||||
/// en: 'That's not a valid identity code'
|
final Translations _root; // ignore: unused_field
|
||||||
String get rootInvalid => 'That\'s not a valid identity code';
|
|
||||||
|
|
||||||
/// en: 'Remove'
|
// Translations
|
||||||
String get remove => 'Remove';
|
|
||||||
|
|
||||||
/// en: 'Advanced parameters'
|
/// en: 'Rate this person'
|
||||||
String get params => 'Advanced parameters';
|
String get rate => 'Rate this person';
|
||||||
|
|
||||||
/// en: 'Duniter rules. Loosen them for a young network; tighten as it grows.'
|
/// en: 'Edit your rating'
|
||||||
String get paramsHelp => 'Duniter rules. Loosen them for a young network; tighten as it grows.';
|
String get edit => 'Edit your rating';
|
||||||
|
|
||||||
/// en: 'Vouches to become a member'
|
/// en: 'How did it go? (optional)'
|
||||||
String get sigQty => 'Vouches to become a member';
|
String get commentHint => 'How did it go? (optional)';
|
||||||
|
|
||||||
/// en: 'Max distance from a root'
|
/// en: '{n} from people you know'
|
||||||
String get stepMax => 'Max distance from a root';
|
String fromYourCircle({required Object n}) => '${n} from people you know';
|
||||||
|
|
||||||
/// en: 'Vouch validity (days)'
|
/// en: 'Remove your rating'
|
||||||
String get validityDays => 'Vouch validity (days)';
|
String get retract => 'Remove your rating';
|
||||||
|
|
||||||
/// en: 'Reset to Duniter defaults'
|
/// en: 'Rating saved'
|
||||||
String get reset => 'Reset to Duniter defaults';
|
String get saved => 'Rating saved';
|
||||||
|
|
||||||
/// en: 'Saved'
|
|
||||||
String get saved => 'Saved';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: notifications
|
// Path: notifications
|
||||||
|
|
@ -2718,26 +2713,21 @@ extension on Translations {
|
||||||
'trust.vouch' => 'I know this person',
|
'trust.vouch' => 'I know this person',
|
||||||
'trust.vouched' => 'You vouch for them',
|
'trust.vouched' => 'You vouch for them',
|
||||||
'trust.circle' => 'In your circle',
|
'trust.circle' => 'In your circle',
|
||||||
'trust.member' => 'Trusted member of the network',
|
'yourPeople.title' => 'Your people',
|
||||||
'wot.title' => 'Network of trust',
|
'yourPeople.help' => 'People you\'ve met and vouch for, and people who vouch for you.',
|
||||||
'wot.open' => 'Network of trust',
|
'yourPeople.youVouchFor' => 'You vouch for',
|
||||||
'wot.help' => 'How trusted membership is decided: people vouch for each other, and trust spreads outward from a set of founding roots.',
|
'yourPeople.vouchesForYou' => 'They vouch for you',
|
||||||
'wot.roots' => 'Trust roots',
|
'yourPeople.youVouchForEmpty' => 'You don\'t vouch for anyone yet. When you meet someone, open your chat with them and tap "I know this person".',
|
||||||
'wot.rootsHelp' => 'The founding identities the network is anchored to. Add people you trust (paste their identity code); membership is measured outward from them.',
|
'yourPeople.vouchesForYouEmpty' => 'No one vouches for you yet',
|
||||||
'wot.noRoots' => 'No trust roots yet — membership can\'t be worked out until the network is seeded.',
|
'yourPeople.revoke' => 'Stop vouching',
|
||||||
'wot.addRoot' => 'Add a trust root',
|
'yourPeople.revokeConfirm' => 'Stop vouching for this person?',
|
||||||
'wot.rootHint' => 'Paste an identity code (npub…)',
|
'yourPeople.offline' => 'You\'re offline — try again when you\'re connected',
|
||||||
'wot.add' => 'Add',
|
'ratings.rate' => 'Rate this person',
|
||||||
'wot.rootAdded' => 'Trust root added',
|
'ratings.edit' => 'Edit your rating',
|
||||||
'wot.rootInvalid' => 'That\'s not a valid identity code',
|
'ratings.commentHint' => 'How did it go? (optional)',
|
||||||
'wot.remove' => 'Remove',
|
'ratings.fromYourCircle' => ({required Object n}) => '${n} from people you know',
|
||||||
'wot.params' => 'Advanced parameters',
|
'ratings.retract' => 'Remove your rating',
|
||||||
'wot.paramsHelp' => 'Duniter rules. Loosen them for a young network; tighten as it grows.',
|
'ratings.saved' => 'Rating saved',
|
||||||
'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',
|
|
||||||
'notifications.newMessageFrom' => ({required Object name}) => 'New message from ${name}',
|
'notifications.newMessageFrom' => ({required Object name}) => 'New message from ${name}',
|
||||||
'plantare.title' => 'Plantares',
|
'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.',
|
'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$chatList$es chatList = _Translations$chatList$es._(_root);
|
||||||
@override late final _Translations$chat$es chat = _Translations$chat$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$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$notifications$es notifications = _Translations$notifications$es._(_root);
|
||||||
@override late final _Translations$plantare$es plantare = _Translations$plantare$es._(_root);
|
@override late final _Translations$plantare$es plantare = _Translations$plantare$es._(_root);
|
||||||
@override late final _Translations$handover$es handover = _Translations$handover$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 vouch => 'Conozco a esta persona';
|
||||||
@override String get vouched => 'Avalas a esta persona';
|
@override String get vouched => 'Avalas a esta persona';
|
||||||
@override String get circle => 'En tu círculo';
|
@override String get circle => 'En tu círculo';
|
||||||
@override String get member => 'Miembro de confianza de la red';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: wot
|
// Path: yourPeople
|
||||||
class _Translations$wot$es extends Translations$wot$en {
|
class _Translations$yourPeople$es extends Translations$yourPeople$en {
|
||||||
_Translations$wot$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
_Translations$yourPeople$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||||
|
|
||||||
final TranslationsEs _root; // ignore: unused_field
|
final TranslationsEs _root; // ignore: unused_field
|
||||||
|
|
||||||
// Translations
|
// Translations
|
||||||
@override String get title => 'Red de confianza';
|
@override String get title => 'Tu gente';
|
||||||
@override String get open => 'Red de confianza';
|
@override String get help => 'Personas que conoces y avalas, y personas que te avalan.';
|
||||||
@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 youVouchFor => 'Tú avalas a';
|
||||||
@override String get roots => 'Raíces de confianza';
|
@override String get vouchesForYou => 'Te avalan';
|
||||||
@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 youVouchForEmpty => 'Aún no avalas a nadie. Cuando conozcas a alguien, abre vuestro chat y toca "Conozco a esta persona".';
|
||||||
@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 vouchesForYouEmpty => 'Nadie te avala todavía';
|
||||||
@override String get addRoot => 'Añadir una raíz de confianza';
|
@override String get revoke => 'Dejar de avalar';
|
||||||
@override String get rootHint => 'Pega un código de identidad (npub…)';
|
@override String get revokeConfirm => '¿Dejar de avalar a esta persona?';
|
||||||
@override String get add => 'Añadir';
|
@override String get offline => 'Sin conexión — inténtalo cuando estés en línea';
|
||||||
@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';
|
// Path: ratings
|
||||||
@override String get params => 'Parámetros avanzados';
|
class _Translations$ratings$es extends Translations$ratings$en {
|
||||||
@override String get paramsHelp => 'Reglas Duniter. Aflójalas para una red joven; apriétalas según crece.';
|
_Translations$ratings$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||||
@override String get sigQty => 'Avales para ser miembro';
|
|
||||||
@override String get stepMax => 'Distancia máxima a una raíz';
|
final TranslationsEs _root; // ignore: unused_field
|
||||||
@override String get validityDays => 'Validez del aval (días)';
|
|
||||||
@override String get reset => 'Restablecer a valores Duniter';
|
// Translations
|
||||||
@override String get saved => 'Guardado';
|
@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
|
// Path: notifications
|
||||||
|
|
@ -1722,26 +1727,21 @@ extension on TranslationsEs {
|
||||||
'trust.vouch' => 'Conozco a esta persona',
|
'trust.vouch' => 'Conozco a esta persona',
|
||||||
'trust.vouched' => 'Avalas a esta persona',
|
'trust.vouched' => 'Avalas a esta persona',
|
||||||
'trust.circle' => 'En tu círculo',
|
'trust.circle' => 'En tu círculo',
|
||||||
'trust.member' => 'Miembro de confianza de la red',
|
'yourPeople.title' => 'Tu gente',
|
||||||
'wot.title' => 'Red de confianza',
|
'yourPeople.help' => 'Personas que conoces y avalas, y personas que te avalan.',
|
||||||
'wot.open' => 'Red de confianza',
|
'yourPeople.youVouchFor' => 'Tú avalas a',
|
||||||
'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.',
|
'yourPeople.vouchesForYou' => 'Te avalan',
|
||||||
'wot.roots' => 'Raíces de confianza',
|
'yourPeople.youVouchForEmpty' => 'Aún no avalas a nadie. Cuando conozcas a alguien, abre vuestro chat y toca "Conozco a esta persona".',
|
||||||
'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.',
|
'yourPeople.vouchesForYouEmpty' => 'Nadie te avala todavía',
|
||||||
'wot.noRoots' => 'Aún no hay raíces de confianza — no se puede calcular la membresía hasta sembrar la red.',
|
'yourPeople.revoke' => 'Dejar de avalar',
|
||||||
'wot.addRoot' => 'Añadir una raíz de confianza',
|
'yourPeople.revokeConfirm' => '¿Dejar de avalar a esta persona?',
|
||||||
'wot.rootHint' => 'Pega un código de identidad (npub…)',
|
'yourPeople.offline' => 'Sin conexión — inténtalo cuando estés en línea',
|
||||||
'wot.add' => 'Añadir',
|
'ratings.rate' => 'Valorar a esta persona',
|
||||||
'wot.rootAdded' => 'Raíz de confianza añadida',
|
'ratings.edit' => 'Editar tu valoración',
|
||||||
'wot.rootInvalid' => 'Ese no es un código de identidad válido',
|
'ratings.commentHint' => '¿Qué tal fue? (opcional)',
|
||||||
'wot.remove' => 'Quitar',
|
'ratings.fromYourCircle' => ({required Object n}) => '${n} de gente que conoces',
|
||||||
'wot.params' => 'Parámetros avanzados',
|
'ratings.retract' => 'Quitar tu valoración',
|
||||||
'wot.paramsHelp' => 'Reglas Duniter. Aflójalas para una red joven; apriétalas según crece.',
|
'ratings.saved' => 'Valoración guardada',
|
||||||
'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',
|
|
||||||
'notifications.newMessageFrom' => ({required Object name}) => 'Nuevo mensaje de ${name}',
|
'notifications.newMessageFrom' => ({required Object name}) => 'Nuevo mensaje de ${name}',
|
||||||
'plantare.title' => 'Plantares',
|
'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.',
|
'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$chatList$pt chatList = _Translations$chatList$pt._(_root);
|
||||||
@override late final _Translations$chat$pt chat = _Translations$chat$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$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$notifications$pt notifications = _Translations$notifications$pt._(_root);
|
||||||
@override late final _Translations$plantare$pt plantare = _Translations$plantare$pt._(_root);
|
@override late final _Translations$plantare$pt plantare = _Translations$plantare$pt._(_root);
|
||||||
@override late final _Translations$handover$pt handover = _Translations$handover$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 vouch => 'Conheço esta pessoa';
|
||||||
@override String get vouched => 'Avalizas esta pessoa';
|
@override String get vouched => 'Avalizas esta pessoa';
|
||||||
@override String get circle => 'No teu círculo';
|
@override String get circle => 'No teu círculo';
|
||||||
@override String get member => 'Membro de confiança da rede';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: wot
|
// Path: yourPeople
|
||||||
class _Translations$wot$pt extends Translations$wot$en {
|
class _Translations$yourPeople$pt extends Translations$yourPeople$en {
|
||||||
_Translations$wot$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
_Translations$yourPeople$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
||||||
|
|
||||||
final TranslationsPt _root; // ignore: unused_field
|
final TranslationsPt _root; // ignore: unused_field
|
||||||
|
|
||||||
// Translations
|
// Translations
|
||||||
@override String get title => 'Rede de confiança';
|
@override String get title => 'A tua gente';
|
||||||
@override String get open => 'Rede de confiança';
|
@override String get help => 'Pessoas que conheces e avalizas, e pessoas que te avalizam.';
|
||||||
@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 youVouchFor => 'Tu avalizas';
|
||||||
@override String get roots => 'Raízes de confiança';
|
@override String get vouchesForYou => 'Avalizam-te';
|
||||||
@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 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 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 vouchesForYouEmpty => 'Ainda ninguém te avaliza';
|
||||||
@override String get addRoot => 'Adicionar uma raiz de confiança';
|
@override String get revoke => 'Deixar de avalizar';
|
||||||
@override String get rootHint => 'Cola um código de identidade (npub…)';
|
@override String get revokeConfirm => 'Deixar de avalizar esta pessoa?';
|
||||||
@override String get add => 'Adicionar';
|
@override String get offline => 'Sem ligação — tenta novamente quando estiveres em linha';
|
||||||
@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';
|
// Path: ratings
|
||||||
@override String get params => 'Parâmetros avançados';
|
class _Translations$ratings$pt extends Translations$ratings$en {
|
||||||
@override String get paramsHelp => 'Regras Duniter. Afrouxa-as para uma rede jovem; aperta-as à medida que cresce.';
|
_Translations$ratings$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
||||||
@override String get sigQty => 'Avais para ser membro';
|
|
||||||
@override String get stepMax => 'Distância máxima a uma raiz';
|
final TranslationsPt _root; // ignore: unused_field
|
||||||
@override String get validityDays => 'Validade do aval (dias)';
|
|
||||||
@override String get reset => 'Repor para valores Duniter';
|
// Translations
|
||||||
@override String get saved => 'Guardado';
|
@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
|
// Path: notifications
|
||||||
|
|
@ -1716,26 +1721,21 @@ extension on TranslationsPt {
|
||||||
'trust.vouch' => 'Conheço esta pessoa',
|
'trust.vouch' => 'Conheço esta pessoa',
|
||||||
'trust.vouched' => 'Avalizas esta pessoa',
|
'trust.vouched' => 'Avalizas esta pessoa',
|
||||||
'trust.circle' => 'No teu círculo',
|
'trust.circle' => 'No teu círculo',
|
||||||
'trust.member' => 'Membro de confiança da rede',
|
'yourPeople.title' => 'A tua gente',
|
||||||
'wot.title' => 'Rede de confiança',
|
'yourPeople.help' => 'Pessoas que conheces e avalizas, e pessoas que te avalizam.',
|
||||||
'wot.open' => 'Rede de confiança',
|
'yourPeople.youVouchFor' => 'Tu avalizas',
|
||||||
'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.',
|
'yourPeople.vouchesForYou' => 'Avalizam-te',
|
||||||
'wot.roots' => 'Raízes de confiança',
|
'yourPeople.youVouchForEmpty' => 'Ainda não avalizas ninguém. Quando conheceres alguém, abre a vossa conversa e toca em "Conheço esta pessoa".',
|
||||||
'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.',
|
'yourPeople.vouchesForYouEmpty' => 'Ainda ninguém te avaliza',
|
||||||
'wot.noRoots' => 'Ainda não há raízes de confiança — não se pode calcular a filiação até semear a rede.',
|
'yourPeople.revoke' => 'Deixar de avalizar',
|
||||||
'wot.addRoot' => 'Adicionar uma raiz de confiança',
|
'yourPeople.revokeConfirm' => 'Deixar de avalizar esta pessoa?',
|
||||||
'wot.rootHint' => 'Cola um código de identidade (npub…)',
|
'yourPeople.offline' => 'Sem ligação — tenta novamente quando estiveres em linha',
|
||||||
'wot.add' => 'Adicionar',
|
'ratings.rate' => 'Avaliar esta pessoa',
|
||||||
'wot.rootAdded' => 'Raiz de confiança adicionada',
|
'ratings.edit' => 'Editar a tua avaliação',
|
||||||
'wot.rootInvalid' => 'Esse não é um código de identidade válido',
|
'ratings.commentHint' => 'Como correu? (opcional)',
|
||||||
'wot.remove' => 'Remover',
|
'ratings.fromYourCircle' => ({required Object n}) => '${n} de pessoas que conheces',
|
||||||
'wot.params' => 'Parâmetros avançados',
|
'ratings.retract' => 'Remover a tua avaliação',
|
||||||
'wot.paramsHelp' => 'Regras Duniter. Afrouxa-as para uma rede jovem; aperta-as à medida que cresce.',
|
'ratings.saved' => 'Avaliação guardada',
|
||||||
'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',
|
|
||||||
'notifications.newMessageFrom' => ({required Object name}) => 'Nova mensagem de ${name}',
|
'notifications.newMessageFrom' => ({required Object name}) => 'Nova mensagem de ${name}',
|
||||||
'plantare.title' => 'Plantares',
|
'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.',
|
'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 relay channel with the transports on top — the
|
||||||
/// "one channel, three interfaces" shape, at the app layer.
|
/// "one channel, N interfaces" shape, at the app layer.
|
||||||
class SocialSession {
|
class SocialSession {
|
||||||
SocialSession(this._channel, {String deviceId = ''})
|
SocialSession(this._channel, {String deviceId = ''})
|
||||||
: offers = NostrOfferTransport(_channel),
|
: offers = NostrOfferTransport(_channel),
|
||||||
messages = NostrMessageTransport(_channel),
|
messages = NostrMessageTransport(_channel),
|
||||||
trust = NostrTrustTransport(_channel),
|
trust = NostrTrustTransport(_channel),
|
||||||
|
ratings = NostrRatingTransport(_channel),
|
||||||
profile = NostrProfileTransport(_channel),
|
profile = NostrProfileTransport(_channel),
|
||||||
sync = NostrSyncTransport(
|
sync = NostrSyncTransport(
|
||||||
_channel,
|
_channel,
|
||||||
|
|
@ -108,6 +109,7 @@ class SocialSession {
|
||||||
final OfferTransport offers;
|
final OfferTransport offers;
|
||||||
final MessageTransport messages;
|
final MessageTransport messages;
|
||||||
final TrustTransport trust;
|
final TrustTransport trust;
|
||||||
|
final RatingTransport ratings;
|
||||||
final ProfileTransport profile;
|
final ProfileTransport profile;
|
||||||
|
|
||||||
/// Device-to-device inventory sync for THIS identity's own devices.
|
/// 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:equatable/equatable.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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 {
|
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).
|
/// In your personal circle (you vouch, or a friend-of-a-friend does).
|
||||||
inYourCircle,
|
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,
|
vouched,
|
||||||
|
|
||||||
/// No certifications seen.
|
/// No certifications seen.
|
||||||
unknown,
|
unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trust standing of one peer: the full Duniter membership verdict, your own
|
/// Trust standing of one peer, seen from this user's position: your own
|
||||||
/// circle, and the raw certifier count — computed from the public certification
|
/// vouch, your circle (friend-of-a-friend), and the raw certifier count —
|
||||||
/// graph with the active [WotParams] and bootstrap referents.
|
/// computed from the public certification graph.
|
||||||
class TrustState extends Equatable {
|
class TrustState extends Equatable {
|
||||||
const TrustState({
|
const TrustState({
|
||||||
this.certifierCount = 0,
|
this.certifierCount = 0,
|
||||||
this.iVouch = false,
|
this.iVouch = false,
|
||||||
this.knownToYou = false,
|
this.knownToYou = false,
|
||||||
this.isNetworkMember = false,
|
|
||||||
this.networkBootstrapped = false,
|
|
||||||
this.loading = true,
|
this.loading = true,
|
||||||
this.busy = false,
|
this.busy = false,
|
||||||
});
|
});
|
||||||
|
|
@ -42,21 +38,11 @@ class TrustState extends Equatable {
|
||||||
/// friend-of-a-friend, vouch). Spam-resistant and works from day one.
|
/// friend-of-a-friend, vouch). Spam-resistant and works from day one.
|
||||||
final bool knownToYou;
|
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 loading;
|
||||||
final bool busy;
|
final bool busy;
|
||||||
|
|
||||||
/// The strongest applicable signal, for the badge.
|
/// The strongest applicable signal, for the badge.
|
||||||
TrustTier get tier {
|
TrustTier get tier {
|
||||||
if (isNetworkMember) return TrustTier.networkMember;
|
|
||||||
if (knownToYou) return TrustTier.inYourCircle;
|
if (knownToYou) return TrustTier.inYourCircle;
|
||||||
if (certifierCount > 0) return TrustTier.vouched;
|
if (certifierCount > 0) return TrustTier.vouched;
|
||||||
return TrustTier.unknown;
|
return TrustTier.unknown;
|
||||||
|
|
@ -66,8 +52,6 @@ class TrustState extends Equatable {
|
||||||
int? certifierCount,
|
int? certifierCount,
|
||||||
bool? iVouch,
|
bool? iVouch,
|
||||||
bool? knownToYou,
|
bool? knownToYou,
|
||||||
bool? isNetworkMember,
|
|
||||||
bool? networkBootstrapped,
|
|
||||||
bool? loading,
|
bool? loading,
|
||||||
bool? busy,
|
bool? busy,
|
||||||
}) =>
|
}) =>
|
||||||
|
|
@ -75,8 +59,6 @@ class TrustState extends Equatable {
|
||||||
certifierCount: certifierCount ?? this.certifierCount,
|
certifierCount: certifierCount ?? this.certifierCount,
|
||||||
iVouch: iVouch ?? this.iVouch,
|
iVouch: iVouch ?? this.iVouch,
|
||||||
knownToYou: knownToYou ?? this.knownToYou,
|
knownToYou: knownToYou ?? this.knownToYou,
|
||||||
isNetworkMember: isNetworkMember ?? this.isNetworkMember,
|
|
||||||
networkBootstrapped: networkBootstrapped ?? this.networkBootstrapped,
|
|
||||||
loading: loading ?? this.loading,
|
loading: loading ?? this.loading,
|
||||||
busy: busy ?? this.busy,
|
busy: busy ?? this.busy,
|
||||||
);
|
);
|
||||||
|
|
@ -86,23 +68,19 @@ class TrustState extends Equatable {
|
||||||
certifierCount,
|
certifierCount,
|
||||||
iVouch,
|
iVouch,
|
||||||
knownToYou,
|
knownToYou,
|
||||||
isNetworkMember,
|
|
||||||
networkBootstrapped,
|
|
||||||
loading,
|
loading,
|
||||||
busy,
|
busy,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads and toggles this user's vouch for [peerPubkey] over a [TrustTransport],
|
/// Reads and toggles this user's vouch for [peerPubkey] over a
|
||||||
/// and computes the full Duniter membership verdict against the bootstrap
|
/// [TrustTransport], and computes the peer's standing from this user's own
|
||||||
/// [referents] and active [params].
|
/// position in the vouch graph (ego-centric — no referents, no parameters).
|
||||||
class TrustCubit extends Cubit<TrustState> {
|
class TrustCubit extends Cubit<TrustState> {
|
||||||
TrustCubit(
|
TrustCubit(
|
||||||
this._transport, {
|
this._transport, {
|
||||||
required this.peerPubkey,
|
required this.peerPubkey,
|
||||||
required this.selfPubkey,
|
required this.selfPubkey,
|
||||||
this.referents = const {},
|
|
||||||
this.params = WotParams.duniter,
|
|
||||||
Future<void> Function()? onDispose,
|
Future<void> Function()? onDispose,
|
||||||
}) : _onDispose = onDispose,
|
}) : _onDispose = onDispose,
|
||||||
super(const TrustState());
|
super(const TrustState());
|
||||||
|
|
@ -110,26 +88,20 @@ class TrustCubit extends Cubit<TrustState> {
|
||||||
final TrustTransport? _transport;
|
final TrustTransport? _transport;
|
||||||
final String peerPubkey;
|
final String peerPubkey;
|
||||||
final String selfPubkey;
|
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;
|
final Future<void> Function()? _onDispose;
|
||||||
|
|
||||||
bool get isOnline => _transport != null;
|
bool get isOnline => _transport != null;
|
||||||
|
|
||||||
/// Personal "circle" rule: one vouch from your side, out to a friend-of-a-
|
/// Personal "circle" rule: one vouch from your side, out to a friend-of-a-
|
||||||
/// friend. Loose by design — "people near you", separate from formal
|
/// friend. Loose by design — "people near you".
|
||||||
/// membership.
|
|
||||||
static const _circleThreshold = 1;
|
static const _circleThreshold = 1;
|
||||||
static const _circleDistance = 2;
|
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,
|
/// Loads the certification graph and computes: the peer's certifier count,
|
||||||
/// whether you vouch, whether they're in your circle, and whether they pass
|
/// whether you vouch, and whether they're in your circle.
|
||||||
/// the full Duniter membership rule against the bootstrap referents.
|
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
final transport = _transport;
|
final transport = _transport;
|
||||||
if (transport == null) {
|
if (transport == null) {
|
||||||
|
|
@ -147,23 +119,17 @@ class TrustCubit extends Cubit<TrustState> {
|
||||||
threshold: _circleThreshold,
|
threshold: _circleThreshold,
|
||||||
maxDistance: _circleDistance,
|
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(
|
emit(state.copyWith(
|
||||||
certifierCount: certifiers.length,
|
certifierCount: certifiers.length,
|
||||||
iVouch: certifiers.contains(selfPubkey),
|
iVouch: certifiers.contains(selfPubkey),
|
||||||
knownToYou: circle.contains(peerPubkey),
|
knownToYou: circle.contains(peerPubkey),
|
||||||
isNetworkMember: members.contains(peerPubkey),
|
|
||||||
networkBootstrapped: referents.isNotEmpty,
|
|
||||||
loading: false,
|
loading: false,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adds or removes this user's vouch, then reloads. Never vouches for self.
|
/// 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
|
/// The certification is issued with a validity so it expires and must be
|
||||||
/// must be renewed (Duniter rule).
|
/// renewed.
|
||||||
Future<void> toggleVouch() async {
|
Future<void> toggleVouch() async {
|
||||||
final transport = _transport;
|
final transport = _transport;
|
||||||
if (transport == null || peerPubkey == selfPubkey) return;
|
if (transport == null || peerPubkey == selfPubkey) return;
|
||||||
|
|
@ -173,7 +139,7 @@ class TrustCubit extends Cubit<TrustState> {
|
||||||
} else {
|
} else {
|
||||||
await transport.certify(
|
await transport.certify(
|
||||||
subjectPubkey: peerPubkey,
|
subjectPubkey: peerPubkey,
|
||||||
validity: params.sigValidity,
|
validity: _vouchValidity,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await load();
|
await load();
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,12 @@ import '../services/profile_cache.dart';
|
||||||
import '../services/profile_store.dart';
|
import '../services/profile_store.dart';
|
||||||
import '../services/social_connection.dart';
|
import '../services/social_connection.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_service.dart';
|
||||||
import '../services/trust_referents.dart';
|
|
||||||
import '../services/unread_service.dart';
|
import '../services/unread_service.dart';
|
||||||
import '../services/wot_settings.dart';
|
|
||||||
import '../state/messages_cubit.dart';
|
import '../state/messages_cubit.dart';
|
||||||
|
import '../state/peer_rating_cubit.dart';
|
||||||
import '../state/trust_cubit.dart';
|
import '../state/trust_cubit.dart';
|
||||||
import 'peer_avatar.dart';
|
import 'peer_avatar.dart';
|
||||||
|
import 'rating_sheet.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
/// A 1:1 encrypted chat with one peer (redesign screen 10). Private and
|
/// A 1:1 encrypted chat with one peer (redesign screen 10). Private and
|
||||||
|
|
@ -33,8 +33,6 @@ class ChatScreen extends StatefulWidget {
|
||||||
required this.peerPubkey,
|
required this.peerPubkey,
|
||||||
this.messageStore,
|
this.messageStore,
|
||||||
this.profileCache,
|
this.profileCache,
|
||||||
this.trustReferents,
|
|
||||||
this.wotSettings,
|
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -51,10 +49,6 @@ class ChatScreen extends StatefulWidget {
|
||||||
/// Optional cache of peer display names; null in tests.
|
/// Optional cache of peer display names; null in tests.
|
||||||
final ProfileCache? profileCache;
|
final ProfileCache? profileCache;
|
||||||
|
|
||||||
/// Web-of-trust bootstrap referents + parameters, for the membership verdict.
|
|
||||||
final TrustReferents? trustReferents;
|
|
||||||
final WotSettings? wotSettings;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatScreen> createState() => _ChatScreenState();
|
State<ChatScreen> createState() => _ChatScreenState();
|
||||||
}
|
}
|
||||||
|
|
@ -62,6 +56,7 @@ class ChatScreen extends StatefulWidget {
|
||||||
class _ChatScreenState extends State<ChatScreen> {
|
class _ChatScreenState extends State<ChatScreen> {
|
||||||
MessagesCubit? _messages;
|
MessagesCubit? _messages;
|
||||||
TrustCubit? _trust;
|
TrustCubit? _trust;
|
||||||
|
PeerRatingCubit? _rating;
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
String? _peerName;
|
String? _peerName;
|
||||||
String? _selfName;
|
String? _selfName;
|
||||||
|
|
@ -95,8 +90,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
final selfName = getIt.isRegistered<ProfileStore>()
|
final selfName = getIt.isRegistered<ProfileStore>()
|
||||||
? await getIt<ProfileStore>().name()
|
? 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
|
if (!mounted) return; // shared session is owned by the connection, not us
|
||||||
final self = widget.social.publicKeyHex;
|
final self = widget.social.publicKeyHex;
|
||||||
final messages = MessagesCubit(
|
final messages = MessagesCubit(
|
||||||
|
|
@ -109,13 +102,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
session?.trust,
|
session?.trust,
|
||||||
peerPubkey: widget.peerPubkey,
|
peerPubkey: widget.peerPubkey,
|
||||||
selfPubkey: self,
|
selfPubkey: self,
|
||||||
referents: referents,
|
|
||||||
params: wotParams,
|
|
||||||
);
|
);
|
||||||
unawaited(trust.load());
|
unawaited(trust.load());
|
||||||
|
final rating = PeerRatingCubit(
|
||||||
|
session?.ratings,
|
||||||
|
peerPubkey: widget.peerPubkey,
|
||||||
|
selfPubkey: self,
|
||||||
|
trust: session?.trust,
|
||||||
|
);
|
||||||
|
unawaited(rating.load());
|
||||||
setState(() {
|
setState(() {
|
||||||
_messages = messages;
|
_messages = messages;
|
||||||
_trust = trust;
|
_trust = trust;
|
||||||
|
_rating = rating;
|
||||||
_peerName = cachedName;
|
_peerName = cachedName;
|
||||||
_selfName = selfName.isEmpty ? null : selfName;
|
_selfName = selfName.isEmpty ? null : selfName;
|
||||||
_loading = false;
|
_loading = false;
|
||||||
|
|
@ -193,6 +192,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
unawaited(_unread?.markRead(widget.peerPubkey));
|
unawaited(_unread?.markRead(widget.peerPubkey));
|
||||||
_messages?.close();
|
_messages?.close();
|
||||||
_trust?.close();
|
_trust?.close();
|
||||||
|
_rating?.close();
|
||||||
// The session belongs to the shared connection — don't close it here.
|
// The session belongs to the shared connection — don't close it here.
|
||||||
_input.dispose();
|
_input.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|
@ -202,6 +202,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final messages = _messages;
|
final messages = _messages;
|
||||||
final trust = _trust;
|
final trust = _trust;
|
||||||
|
final rating = _rating;
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
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())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: MultiBlocProvider(
|
: MultiBlocProvider(
|
||||||
providers: [
|
providers: [
|
||||||
BlocProvider.value(value: messages),
|
BlocProvider.value(value: messages),
|
||||||
BlocProvider.value(value: trust),
|
BlocProvider.value(value: trust),
|
||||||
|
BlocProvider.value(value: rating),
|
||||||
],
|
],
|
||||||
child: _ChatBody(
|
child: _ChatBody(
|
||||||
controller: _input,
|
controller: _input,
|
||||||
|
|
@ -274,6 +276,7 @@ class _ChatBody extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const _TrustBanner(),
|
const _TrustBanner(),
|
||||||
|
const _RatingStrip(),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ChatMessageList(peerName: peerName, selfName: selfName),
|
child: ChatMessageList(peerName: peerName, selfName: selfName),
|
||||||
),
|
),
|
||||||
|
|
@ -348,10 +351,9 @@ class _TrustBanner extends StatelessWidget {
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final cubit = context.read<TrustCubit>();
|
final cubit = context.read<TrustCubit>();
|
||||||
if (!cubit.isOnline || state.loading) return const SizedBox.shrink();
|
if (!cubit.isOnline || state.loading) return const SizedBox.shrink();
|
||||||
// Strongest applicable signal decides the badge (member > circle >
|
// Strongest applicable signal decides the badge (circle > vouched >
|
||||||
// vouched > unknown).
|
// unknown).
|
||||||
final (IconData icon, String label, bool strong) = switch (state.tier) {
|
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.inYourCircle => (Icons.verified_user, t.trust.circle, true),
|
||||||
TrustTier.vouched => (
|
TrustTier.vouched => (
|
||||||
Icons.people_outline,
|
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
|
/// A centered pill introducing a new day ("Today", "Yesterday", or a
|
||||||
/// locale-formatted date). Localized via [chatDayLabel].
|
/// locale-formatted date). Localized via [chatDayLabel].
|
||||||
class _DaySeparator extends StatelessWidget {
|
class _DaySeparator extends StatelessWidget {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,10 @@ import 'package:go_router/go_router.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../services/profile_cache.dart';
|
import '../services/profile_cache.dart';
|
||||||
import '../services/social_connection.dart';
|
import '../services/social_connection.dart';
|
||||||
|
import '../services/social_service.dart';
|
||||||
|
import '../state/peer_rating_cubit.dart';
|
||||||
import 'market_widgets.dart';
|
import 'market_widgets.dart';
|
||||||
|
import 'rating_sheet.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
/// Read-only "product page" for one discovered [Offer] (the Wallapop-style
|
/// Read-only "product page" for one discovered [Offer] (the Wallapop-style
|
||||||
|
|
@ -22,6 +25,7 @@ class MarketOfferDetailScreen extends StatefulWidget {
|
||||||
required this.connection,
|
required this.connection,
|
||||||
this.mine = false,
|
this.mine = false,
|
||||||
this.profileCache,
|
this.profileCache,
|
||||||
|
this.selfPubkey,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -36,6 +40,10 @@ class MarketOfferDetailScreen extends StatefulWidget {
|
||||||
/// Optional cache of peers' published display names; null in tests.
|
/// Optional cache of peers' published display names; null in tests.
|
||||||
final ProfileCache? profileCache;
|
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
|
@override
|
||||||
State<MarketOfferDetailScreen> createState() =>
|
State<MarketOfferDetailScreen> createState() =>
|
||||||
_MarketOfferDetailScreenState();
|
_MarketOfferDetailScreenState();
|
||||||
|
|
@ -46,6 +54,7 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
||||||
String? _sellerAbout;
|
String? _sellerAbout;
|
||||||
String? _sellerG1;
|
String? _sellerG1;
|
||||||
bool _profileLoading = true;
|
bool _profileLoading = true;
|
||||||
|
PeerRatingState? _sellerRating;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -65,6 +74,7 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
||||||
setState(() => _profileLoading = false);
|
setState(() => _profileLoading = false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
unawaited(_loadSellerRating(session));
|
||||||
try {
|
try {
|
||||||
final profile = await session.profile.fetch(widget.offer.authorPubkeyHex);
|
final profile = await session.profile.fetch(widget.offer.authorPubkeyHex);
|
||||||
if (profile != null && profile.name.isNotEmpty) {
|
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.
|
// No dispose of the session — it belongs to the shared connection.
|
||||||
|
|
||||||
Future<void> _copyId() async {
|
Future<void> _copyId() async {
|
||||||
|
|
@ -167,6 +196,7 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
||||||
title: t.market.sharedBy,
|
title: t.market.sharedBy,
|
||||||
name: _sellerName ?? shortPubkey(o.authorPubkeyHex),
|
name: _sellerName ?? shortPubkey(o.authorPubkeyHex),
|
||||||
about: _sellerAbout,
|
about: _sellerAbout,
|
||||||
|
rating: _sellerRating,
|
||||||
loading: _profileLoading,
|
loading: _profileLoading,
|
||||||
hasProfile:
|
hasProfile:
|
||||||
_sellerName != null || _sellerAbout != null || _sellerG1 != null,
|
_sellerName != null || _sellerAbout != null || _sellerG1 != null,
|
||||||
|
|
@ -227,11 +257,16 @@ class _SellerSection extends StatelessWidget {
|
||||||
required this.noProfileLabel,
|
required this.noProfileLabel,
|
||||||
required this.onCopyId,
|
required this.onCopyId,
|
||||||
required this.copyIdTooltip,
|
required this.copyIdTooltip,
|
||||||
|
this.rating,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
final String name;
|
final String name;
|
||||||
final String? about;
|
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 loading;
|
||||||
final bool hasProfile;
|
final bool hasProfile;
|
||||||
final String noProfileLabel;
|
final String noProfileLabel;
|
||||||
|
|
@ -261,13 +296,26 @@ class _SellerSection extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(
|
||||||
name,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: const TextStyle(
|
children: [
|
||||||
fontSize: 16,
|
Text(
|
||||||
fontWeight: FontWeight.w500,
|
name,
|
||||||
color: seedOnSurface,
|
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(
|
IconButton(
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ class ProfileScreen extends StatefulWidget {
|
||||||
required this.connection,
|
required this.connection,
|
||||||
required this.profileStore,
|
required this.profileStore,
|
||||||
required this.accounts,
|
required this.accounts,
|
||||||
this.trustNetworkEnabled = false,
|
this.yourPeopleEnabled = false,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -33,8 +33,8 @@ class ProfileScreen extends StatefulWidget {
|
||||||
final ProfileStore profileStore;
|
final ProfileStore profileStore;
|
||||||
final SocialAccountStore accounts;
|
final SocialAccountStore accounts;
|
||||||
|
|
||||||
/// Whether to offer the "Network of trust" entry (needs the WoT services).
|
/// Whether to offer the "Your people" entry (needs the social layer).
|
||||||
final bool trustNetworkEnabled;
|
final bool yourPeopleEnabled;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ProfileScreen> createState() => _ProfileScreenState();
|
State<ProfileScreen> createState() => _ProfileScreenState();
|
||||||
|
|
@ -210,16 +210,17 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||||
onSwitch: (a) => _switchTo(a),
|
onSwitch: (a) => _switchTo(a),
|
||||||
onNew: _newIdentity,
|
onNew: _newIdentity,
|
||||||
),
|
),
|
||||||
if (widget.trustNetworkEnabled) ...[
|
if (widget.yourPeopleEnabled) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
key: const Key('profile.trustNetwork'),
|
key: const Key('profile.yourPeople'),
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: const Icon(Icons.hub_outlined, color: seedGreen),
|
leading:
|
||||||
title: Text(t.wot.open),
|
const Icon(Icons.people_alt_outlined, color: seedGreen),
|
||||||
|
title: Text(t.yourPeople.title),
|
||||||
trailing: const Icon(Icons.chevron_right, color: seedMuted),
|
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
|
- assets/catalog/species.json
|
||||||
# Curated seed-saving guidance (pollination, isolation, difficulty…).
|
# Curated seed-saving guidance (pollination, isolation, difficulty…).
|
||||||
- assets/catalog/seed_saving.json
|
- 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
|
- assets/logo.png
|
||||||
# Bundled so the Linux runner can use it as the GTK window icon.
|
# Bundled so the Linux runner can use it as the GTK window icon.
|
||||||
- assets/icon.png
|
- 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);
|
final inCircle = TrustCubit(transport, peerPubkey: peer, selfPubkey: me);
|
||||||
await inCircle.load();
|
await inCircle.load();
|
||||||
expect(inCircle.state.knownToYou, isTrue);
|
expect(inCircle.state.knownToYou, isTrue);
|
||||||
|
expect(inCircle.state.tier, TrustTier.inYourCircle);
|
||||||
await inCircle.close();
|
await inCircle.close();
|
||||||
|
|
||||||
final outside = TrustCubit(transport, peerPubkey: 'other', selfPubkey: me);
|
final outside = TrustCubit(transport, peerPubkey: 'other', selfPubkey: me);
|
||||||
|
|
@ -91,39 +92,6 @@ void main() {
|
||||||
await outside.close();
|
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 {
|
test('tier falls back to vouched, then unknown', () async {
|
||||||
final vouched = TrustCubit(
|
final vouched = TrustCubit(
|
||||||
FakeTrustTransport(me)..addCert('someone', peer),
|
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).
|
**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.
|
**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").
|
||||||
- **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.
|
|
||||||
|
|
||||||
*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)
|
## 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)
|
- **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
|
- **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
|
- **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)
|
- **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** atada a un trato/oferta cerrada (evitar reseñas falsas). → sharing-model §6
|
- **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
|
- **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)
|
- **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
|
- **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_message_transport.dart';
|
||||||
export 'src/social/nostr/nostr_offer_transport.dart';
|
export 'src/social/nostr/nostr_offer_transport.dart';
|
||||||
export 'src/social/nostr/nostr_profile_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/nostr_trust_transport.dart';
|
||||||
export 'src/social/nostr/relay_pool.dart';
|
export 'src/social/nostr/relay_pool.dart';
|
||||||
export 'src/social/offer.dart';
|
export 'src/social/offer.dart';
|
||||||
export 'src/social/offer_transport.dart';
|
export 'src/social/offer_transport.dart';
|
||||||
export 'src/social/profile_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/sync_transport.dart';
|
||||||
export 'src/social/trust_transport.dart';
|
export 'src/social/trust_transport.dart';
|
||||||
export 'src/social/web_of_trust.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();
|
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