feat(trust): full Duniter web-of-trust membership (params + referents)

Slice 4 of Block 2. The pure rule was already parameterised; this adds the
policy, cold-start and UI around it.

- commons_core: WotParams (sigQty/stepMax/sigValidity, Duniter defaults) +
  WebOfTrust.membersWith; npubToHex helper (NIP-19 decode) for adding roots.
- WotSettings (keystore): the active parameters, configurable — a young
  network loosens them and tightens as it grows, as Ğ1 did. Defaults Duniter.
- TrustReferents: the bootstrap 'seeds' membership is measured from — a
  bundled asset (empty until real founders are curated, no invented keys)
  unioned with referents the user adds by npub/QR. Honest cold-start.
- TrustCubit: computes the full membership verdict against referents+params
  alongside the personal circle, and exposes a TrustTier (networkMember >
  inYourCircle > vouched > unknown). Certifications issued with the active
  validity (they expire and renew, Duniter rule).
- UI: chat trust badge by tier; a 'Network of trust' screen (manage roots +
  advanced params) reached from the profile. i18n en/es/pt/ast.

Tests: WotParams/membersWith, npubToHex, TrustReferents, WotSettings, and
TrustCubit tiers/membership. Resolves the WoT-parameters decision
(open-decisions §B). Trust net stays empty/undetermined until seeded — by
design; users bootstrap their own roots.
This commit is contained in:
vjrj 2026-07-10 21:01:11 +02:00
parent a96049dd36
commit 4cf53f259f
29 changed files with 1111 additions and 39 deletions

View file

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -10,6 +11,8 @@ import '../services/message_store.dart';
import '../services/profile_cache.dart';
import '../services/social_service.dart';
import '../services/social_settings.dart';
import '../services/trust_referents.dart';
import '../services/wot_settings.dart';
import '../state/messages_cubit.dart';
import '../state/trust_cubit.dart';
import 'theme.dart';
@ -24,6 +27,8 @@ class ChatScreen extends StatefulWidget {
required this.peerPubkey,
this.messageStore,
this.profileCache,
this.trustReferents,
this.wotSettings,
super.key,
});
@ -37,6 +42,10 @@ class ChatScreen extends StatefulWidget {
/// Optional cache of peer display names; null in tests.
final ProfileCache? profileCache;
/// Web-of-trust bootstrap referents + parameters, for the membership verdict.
final TrustReferents? trustReferents;
final WotSettings? wotSettings;
@override
State<ChatScreen> createState() => _ChatScreenState();
}
@ -69,6 +78,9 @@ class _ChatScreenState extends State<ChatScreen> {
}
}
final cachedName = await widget.profileCache?.name(widget.peerPubkey);
final referents = await widget.trustReferents?.all() ?? const <String>{};
final wotParams =
await widget.wotSettings?.params() ?? WotParams.duniter;
if (!mounted) {
await session?.close();
return;
@ -80,8 +92,13 @@ class _ChatScreenState extends State<ChatScreen> {
selfPubkey: self,
store: widget.messageStore,
)..start();
final trust = TrustCubit(session?.trust,
peerPubkey: widget.peerPubkey, selfPubkey: self);
final trust = TrustCubit(
session?.trust,
peerPubkey: widget.peerPubkey,
selfPubkey: self,
referents: referents,
params: wotParams,
);
unawaited(trust.load());
setState(() {
_session = session;
@ -249,30 +266,33 @@ class _TrustBanner extends StatelessWidget {
builder: (context, state) {
final cubit = context.read<TrustCubit>();
if (!cubit.isOnline || state.loading) return const SizedBox.shrink();
final known = state.knownToYou;
// Strongest applicable signal decides the badge (member > circle >
// vouched > unknown).
final (IconData icon, String label, bool strong) = switch (state.tier) {
TrustTier.networkMember => (Icons.verified, t.trust.member, true),
TrustTier.inYourCircle => (Icons.verified_user, t.trust.circle, true),
TrustTier.vouched => (
Icons.people_outline,
t.trust.count(n: state.certifierCount),
false,
),
TrustTier.unknown => (Icons.person_outline, t.trust.none, false),
};
return Container(
width: double.infinity,
color: seedPrimaryContainer.withValues(alpha: 0.5),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(
known ? Icons.verified : Icons.verified_user_outlined,
size: 18,
color: seedGreen,
),
Icon(icon, size: 18, color: strong ? seedGreen : seedMuted),
const SizedBox(width: 8),
Expanded(
child: Text(
known
? t.trust.circle
: state.certifierCount == 0
? t.trust.none
: t.trust.count(n: state.certifierCount),
label,
style: TextStyle(
color: known ? seedGreen : seedOnSurface,
color: strong ? seedGreen : seedOnSurface,
fontSize: 13,
fontWeight: known ? FontWeight.w600 : FontWeight.w400,
fontWeight: strong ? FontWeight.w600 : FontWeight.w400,
),
),
),

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import '../di/injector.dart' show switchSocialAccount;
import '../i18n/strings.g.dart';
@ -21,6 +22,7 @@ class ProfileScreen extends StatefulWidget {
required this.settings,
required this.profileStore,
required this.accounts,
this.trustNetworkEnabled = false,
super.key,
});
@ -29,6 +31,9 @@ class ProfileScreen extends StatefulWidget {
final ProfileStore profileStore;
final SocialAccountStore accounts;
/// Whether to offer the "Network of trust" entry (needs the WoT services).
final bool trustNetworkEnabled;
@override
State<ProfileScreen> createState() => _ProfileScreenState();
}
@ -207,6 +212,18 @@ class _ProfileScreenState extends State<ProfileScreen> {
onSwitch: (a) => _switchTo(a),
onNew: _newIdentity,
),
if (widget.trustNetworkEnabled) ...[
const SizedBox(height: 12),
const Divider(),
ListTile(
key: const Key('profile.trustNetwork'),
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.hub_outlined, color: seedGreen),
title: Text(t.wot.open),
trailing: const Icon(Icons.chevron_right, color: seedMuted),
onTap: () => context.push('/trust'),
),
],
],
),
);

View file

@ -0,0 +1,228 @@
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),
),
],
),
);
}
}