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

@ -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});