tane/apps/app_seeds/lib/ui/chat_screen.dart
vjrj 7cba4f7fcf 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.
2026-07-10 11:04:07 +02:00

283 lines
8.2 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
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
/// metadata-hiding (NIP-17) under the hood; local-first, so it degrades to a
/// "set up sharing" note when there's no relay.
class ChatScreen extends StatefulWidget {
const ChatScreen({
required this.social,
required this.settings,
required this.peerPubkey,
super.key,
});
final SocialService social;
final SocialSettings settings;
final String peerPubkey;
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
SocialSession? _session;
MessagesCubit? _messages;
TrustCubit? _trust;
bool _loading = true;
final _input = TextEditingController();
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
// 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 session?.close();
return;
}
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(() {
_session = session;
_messages = messages;
_trust = trust;
_loading = false;
});
}
Future<void> _send() async {
final cubit = _messages;
if (cubit == null || _input.text.trim().isEmpty) return;
final text = _input.text;
_input.clear();
await cubit.send(text);
}
@override
void dispose() {
_messages?.close();
_trust?.close();
_session?.close();
_input.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final t = context.t;
final messages = _messages;
final trust = _trust;
return Scaffold(
appBar: AppBar(title: Text(t.chat.title)),
body: _loading || messages == null || trust == null
? const Center(child: CircularProgressIndicator())
: MultiBlocProvider(
providers: [
BlocProvider.value(value: messages),
BlocProvider.value(value: trust),
],
child: _ChatBody(controller: _input, onSend: _send),
),
);
}
}
class _ChatBody extends StatelessWidget {
const _ChatBody({required this.controller, required this.onSend});
final TextEditingController controller;
final Future<void> Function() onSend;
@override
Widget build(BuildContext context) {
final t = context.t;
final cubit = context.read<MessagesCubit>();
if (!cubit.isOnline) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text(
t.chat.offline,
textAlign: TextAlign.center,
style: const TextStyle(color: seedMuted, fontSize: 15),
),
),
);
}
return Column(
children: [
const _TrustBanner(),
Expanded(
child: BlocBuilder<MessagesCubit, ChatState>(
builder: (context, state) {
if (state.messages.isEmpty) {
return Center(
child: Text(t.chat.empty,
style: const TextStyle(color: seedMuted)),
);
}
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: state.messages.length,
itemBuilder: (context, i) {
final m = state.messages[i];
return _Bubble(text: m.text, mine: cubit.isMine(m));
},
);
},
),
),
_Composer(controller: controller, onSend: onSend),
],
);
}
}
/// 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});
final String text;
final bool mine;
@override
Widget build(BuildContext context) {
return Align(
alignment: mine ? AlignmentDirectional.centerEnd : AlignmentDirectional.centerStart,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
decoration: BoxDecoration(
color: mine ? seedGreen : seedPrimaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Text(
text,
style: TextStyle(
color: mine ? Colors.white : seedOnSurface,
fontSize: 15,
),
),
),
);
}
}
class _Composer extends StatelessWidget {
const _Composer({required this.controller, required this.onSend});
final TextEditingController controller;
final Future<void> Function() onSend;
@override
Widget build(BuildContext context) {
final t = context.t;
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 12),
child: Row(
children: [
Expanded(
child: TextField(
key: const Key('chat.input'),
controller: controller,
textInputAction: TextInputAction.send,
onSubmitted: (_) => onSend(),
decoration: InputDecoration(
hintText: t.chat.hint,
filled: true,
fillColor: seedField,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
),
),
),
const SizedBox(width: 8),
IconButton.filled(
key: const Key('chat.send'),
icon: const Icon(Icons.send),
tooltip: t.chat.send,
onPressed: onSend,
),
],
),
),
);
}
}