feat(block2): trust UI — vouch for a person in chat (web of trust)

- TrustCubit: reads how many vouch for the peer + whether YOU do; toggleVouch
  certifies/revokes over TrustTransport; never vouches for self; offline-safe.
  Kept simple — the full known-member rule (threshold+distance) and its
  bootstrap set are still-open policy (network-trust §2); a live count + your
  vouch is the honest first step.
- Chat screen now opens ONE session carrying both messaging AND trust (the
  shared-connection shape), and shows a slim vouch banner: 'Vouched for by N' +
  an 'I know this person' / 'You vouch for them' toggle.
- i18n en/es/pt (human words: 'I know this person', not 'certify').

Tests: 4 trust cubit + messaging + chat, 10 green; analyzer clean.
This commit is contained in:
vjrj 2026-07-10 11:04:07 +02:00
parent 4326e79419
commit 7cba4f7fcf
10 changed files with 367 additions and 17 deletions

View file

@ -366,5 +366,11 @@
"send": "Send",
"empty": "No messages yet — say hello",
"offline": "Set up sharing to send messages"
},
"trust": {
"none": "No one vouches for them yet",
"count": "Vouched for by {n}",
"vouch": "I know this person",
"vouched": "You vouch for them"
}
}

View file

@ -366,5 +366,11 @@
"send": "Enviar",
"empty": "Aún no hay mensajes — saluda",
"offline": "Configura el compartir para enviar mensajes"
},
"trust": {
"none": "Nadie los avala aún",
"count": "Avalada por {n}",
"vouch": "Conozco a esta persona",
"vouched": "Avalas a esta persona"
}
}

View file

@ -362,5 +362,11 @@
"send": "Enviar",
"empty": "Ainda não há mensagens — diz olá",
"offline": "Configura a partilha para enviar mensagens"
},
"trust": {
"none": "Ainda ninguém os avaliza",
"count": "Avalizada por {n}",
"vouch": "Conheço esta pessoa",
"vouched": "Avalizas esta pessoa"
}
}

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 3
/// Strings: 974 (324 per locale)
/// Strings: 986 (328 per locale)
///
/// Built on 2026-07-10 at 08:58 UTC
/// Built on 2026-07-10 at 09:03 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View file

@ -71,6 +71,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
late final Translations$unit$en unit = Translations$unit$en.internal(_root);
late final Translations$market$en market = Translations$market$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);
}
// Path: app
@ -1162,6 +1163,27 @@ class Translations$chat$en {
String get offline => 'Set up sharing to send messages';
}
// Path: trust
class Translations$trust$en {
Translations$trust$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'No one vouches for them yet'
String get none => 'No one vouches for them yet';
/// en: 'Vouched for by {n}'
String count({required Object n}) => 'Vouched for by ${n}';
/// en: 'I know this person'
String get vouch => 'I know this person';
/// en: 'You vouch for them'
String get vouched => 'You vouch for them';
}
// Path: intro.slides
class Translations$intro$slides$en {
Translations$intro$slides$en.internal(this._root);
@ -1945,6 +1967,10 @@ extension on Translations {
'chat.send' => 'Send',
'chat.empty' => 'No messages yet — say hello',
'chat.offline' => 'Set up sharing to send messages',
'trust.none' => 'No one vouches for them yet',
'trust.count' => ({required Object n}) => 'Vouched for by ${n}',
'trust.vouch' => 'I know this person',
'trust.vouched' => 'You vouch for them',
_ => null,
};
}

View file

@ -70,6 +70,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$unit$es unit = _Translations$unit$es._(_root);
@override late final _Translations$market$es market = _Translations$market$es._(_root);
@override late final _Translations$chat$es chat = _Translations$chat$es._(_root);
@override late final _Translations$trust$es trust = _Translations$trust$es._(_root);
}
// Path: app
@ -646,6 +647,19 @@ class _Translations$chat$es extends Translations$chat$en {
@override String get offline => 'Configura el compartir para enviar mensajes';
}
// Path: trust
class _Translations$trust$es extends Translations$trust$en {
_Translations$trust$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get none => 'Nadie los avala aún';
@override String count({required Object n}) => 'Avalada por ${n}';
@override String get vouch => 'Conozco a esta persona';
@override String get vouched => 'Avalas a esta persona';
}
// Path: intro.slides
class _Translations$intro$slides$es extends Translations$intro$slides$en {
_Translations$intro$slides$es._(TranslationsEs root) : this._root = root, super.internal(root);
@ -1313,6 +1327,10 @@ extension on TranslationsEs {
'chat.send' => 'Enviar',
'chat.empty' => 'Aún no hay mensajes — saluda',
'chat.offline' => 'Configura el compartir para enviar mensajes',
'trust.none' => 'Nadie los avala aún',
'trust.count' => ({required Object n}) => 'Avalada por ${n}',
'trust.vouch' => 'Conozco a esta persona',
'trust.vouched' => 'Avalas a esta persona',
_ => null,
};
}

View file

@ -70,6 +70,7 @@ class TranslationsPt extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$unit$pt unit = _Translations$unit$pt._(_root);
@override late final _Translations$market$pt market = _Translations$market$pt._(_root);
@override late final _Translations$chat$pt chat = _Translations$chat$pt._(_root);
@override late final _Translations$trust$pt trust = _Translations$trust$pt._(_root);
}
// Path: app
@ -642,6 +643,19 @@ class _Translations$chat$pt extends Translations$chat$en {
@override String get offline => 'Configura a partilha para enviar mensagens';
}
// Path: trust
class _Translations$trust$pt extends Translations$trust$en {
_Translations$trust$pt._(TranslationsPt root) : this._root = root, super.internal(root);
final TranslationsPt _root; // ignore: unused_field
// Translations
@override String get none => 'Ainda ninguém os avaliza';
@override String count({required Object n}) => 'Avalizada por ${n}';
@override String get vouch => 'Conheço esta pessoa';
@override String get vouched => 'Avalizas esta pessoa';
}
// Path: intro.slides
class _Translations$intro$slides$pt extends Translations$intro$slides$en {
_Translations$intro$slides$pt._(TranslationsPt root) : this._root = root, super.internal(root);
@ -1305,6 +1319,10 @@ extension on TranslationsPt {
'chat.send' => 'Enviar',
'chat.empty' => 'Ainda não há mensagens — diz olá',
'chat.offline' => 'Configura a partilha para enviar mensagens',
'trust.none' => 'Ainda ninguém os avaliza',
'trust.count' => ({required Object n}) => 'Avalizada por ${n}',
'trust.vouch' => 'Conheço esta pessoa',
'trust.vouched' => 'Avalizas esta pessoa',
_ => null,
};
}

View file

@ -0,0 +1,121 @@
import 'package:commons_core/commons_core.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../services/social_service.dart';
import '../services/social_settings.dart';
/// Trust standing of one peer: how many people vouch for them, and whether YOU
/// do. Kept simple on purpose the full "known member" rule (threshold +
/// distance) and its bootstrap set are a policy decision still open
/// (network-trust.md §2). Vouching + a live count is the useful, honest first
/// step.
class TrustState extends Equatable {
const TrustState({
this.certifierCount = 0,
this.iVouch = false,
this.loading = true,
this.busy = false,
});
final int certifierCount;
final bool iVouch;
final bool loading;
final bool busy;
TrustState copyWith({
int? certifierCount,
bool? iVouch,
bool? loading,
bool? busy,
}) =>
TrustState(
certifierCount: certifierCount ?? this.certifierCount,
iVouch: iVouch ?? this.iVouch,
loading: loading ?? this.loading,
busy: busy ?? this.busy,
);
@override
List<Object?> get props => [certifierCount, iVouch, loading, busy];
}
/// Reads and toggles this user's vouch for [peerPubkey] over a [TrustTransport].
class TrustCubit extends Cubit<TrustState> {
TrustCubit(
this._transport, {
required this.peerPubkey,
required this.selfPubkey,
Future<void> Function()? onDispose,
}) : _onDispose = onDispose,
super(const TrustState());
final TrustTransport? _transport;
final String peerPubkey;
final String selfPubkey;
final Future<void> Function()? _onDispose;
bool get isOnline => _transport != null;
/// Loads the current certifiers of the peer.
Future<void> load() async {
final transport = _transport;
if (transport == null) {
emit(state.copyWith(loading: false));
return;
}
emit(state.copyWith(loading: true));
final certifiers = await transport.certifiersOf(peerPubkey);
emit(state.copyWith(
certifierCount: certifiers.length,
iVouch: certifiers.contains(selfPubkey),
loading: false,
));
}
/// Adds or removes this user's vouch, then reloads. Never vouches for self.
Future<void> toggleVouch() async {
final transport = _transport;
if (transport == null || peerPubkey == selfPubkey) return;
emit(state.copyWith(busy: true));
if (state.iVouch) {
await transport.revoke(subjectPubkey: peerPubkey);
} else {
await transport.certify(subjectPubkey: peerPubkey);
}
await load();
emit(state.copyWith(busy: false));
}
@override
Future<void> close() async {
await _onDispose?.call();
return super.close();
}
}
/// Opens a [TrustCubit] wired to the social layer for [peerPubkey], or an
/// offline one.
Future<TrustCubit> createTrustCubit(
SocialService social,
SocialSettings settings, {
required String peerPubkey,
}) async {
final relays = await settings.relayUrls();
if (relays.isEmpty) {
return TrustCubit(null,
peerPubkey: peerPubkey, selfPubkey: social.publicKeyHex);
}
try {
final session = await social.openSession(relays);
return TrustCubit(
session.trust,
peerPubkey: peerPubkey,
selfPubkey: social.publicKeyHex,
onDispose: session.close,
);
} catch (_) {
return TrustCubit(null,
peerPubkey: peerPubkey, selfPubkey: social.publicKeyHex);
}
}

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -5,6 +7,7 @@ import '../i18n/strings.g.dart';
import '../services/social_service.dart';
import '../services/social_settings.dart';
import '../state/messages_cubit.dart';
import '../state/trust_cubit.dart';
import 'theme.dart';
/// A 1:1 encrypted chat with one peer (redesign screen 10). Private and
@ -27,7 +30,9 @@ class ChatScreen extends StatefulWidget {
}
class _ChatScreenState extends State<ChatScreen> {
MessagesCubit? _cubit;
SocialSession? _session;
MessagesCubit? _messages;
TrustCubit? _trust;
bool _loading = true;
final _input = TextEditingController();
@ -38,24 +43,38 @@ class _ChatScreenState extends State<ChatScreen> {
}
Future<void> _init() async {
final cubit = await createMessagesCubit(
widget.social,
widget.settings,
peerPubkey: widget.peerPubkey,
);
// One session for this chat carries both messaging and trust (the shared-
// connection shape). Offline (no relays / unreachable) null transports.
final relays = await widget.settings.relayUrls();
SocialSession? session;
if (relays.isNotEmpty) {
try {
session = await widget.social.openSession(relays);
} catch (_) {
session = null;
}
}
if (!mounted) {
await cubit.close();
await session?.close();
return;
}
cubit.start();
final self = widget.social.publicKeyHex;
final messages = MessagesCubit(session?.messages,
peerPubkey: widget.peerPubkey, selfPubkey: self)
..start();
final trust = TrustCubit(session?.trust,
peerPubkey: widget.peerPubkey, selfPubkey: self);
unawaited(trust.load());
setState(() {
_cubit = cubit;
_session = session;
_messages = messages;
_trust = trust;
_loading = false;
});
}
Future<void> _send() async {
final cubit = _cubit;
final cubit = _messages;
if (cubit == null || _input.text.trim().isEmpty) return;
final text = _input.text;
_input.clear();
@ -64,7 +83,9 @@ class _ChatScreenState extends State<ChatScreen> {
@override
void dispose() {
_cubit?.close();
_messages?.close();
_trust?.close();
_session?.close();
_input.dispose();
super.dispose();
}
@ -72,13 +93,17 @@ class _ChatScreenState extends State<ChatScreen> {
@override
Widget build(BuildContext context) {
final t = context.t;
final cubit = _cubit;
final messages = _messages;
final trust = _trust;
return Scaffold(
appBar: AppBar(title: Text(t.chat.title)),
body: _loading || cubit == null
body: _loading || messages == null || trust == null
? const Center(child: CircularProgressIndicator())
: BlocProvider.value(
value: cubit,
: MultiBlocProvider(
providers: [
BlocProvider.value(value: messages),
BlocProvider.value(value: trust),
],
child: _ChatBody(controller: _input, onSend: _send),
),
);
@ -109,6 +134,7 @@ class _ChatBody extends StatelessWidget {
}
return Column(
children: [
const _TrustBanner(),
Expanded(
child: BlocBuilder<MessagesCubit, ChatState>(
builder: (context, state) {
@ -135,6 +161,47 @@ class _ChatBody extends StatelessWidget {
}
}
/// A slim "web of trust" strip: how many vouch for this peer, and a toggle to
/// add/remove your own vouch ("I know this person").
class _TrustBanner extends StatelessWidget {
const _TrustBanner();
@override
Widget build(BuildContext context) {
final t = context.t;
return BlocBuilder<TrustCubit, TrustState>(
builder: (context, state) {
final cubit = context.read<TrustCubit>();
if (!cubit.isOnline || state.loading) return const SizedBox.shrink();
return Container(
width: double.infinity,
color: seedPrimaryContainer.withValues(alpha: 0.5),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
const Icon(Icons.verified_user_outlined, size: 18, color: seedGreen),
const SizedBox(width: 8),
Expanded(
child: Text(
state.certifierCount == 0
? t.trust.none
: t.trust.count(n: state.certifierCount),
style: const TextStyle(color: seedOnSurface, fontSize: 13),
),
),
TextButton(
key: const Key('chat.vouch'),
onPressed: state.busy ? null : cubit.toggleVouch,
child: Text(state.iVouch ? t.trust.vouched : t.trust.vouch),
),
],
),
);
},
);
}
}
class _Bubble extends StatelessWidget {
const _Bubble({required this.text, required this.mine});