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