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'; import 'package:url_launcher/url_launcher.dart'; import '../di/injector.dart'; import '../domain/chat_timeline.dart'; import '../domain/message_rules.dart'; import '../i18n/strings.g.dart'; import '../services/message_store.dart'; import '../services/profile_cache.dart'; import '../services/profile_store.dart'; import '../services/social_connection.dart'; import '../services/social_service.dart'; import '../services/trust_referents.dart'; import '../services/unread_service.dart'; import '../services/wot_settings.dart'; import '../state/messages_cubit.dart'; import '../state/trust_cubit.dart'; import 'peer_avatar.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.connection, required this.peerPubkey, this.messageStore, this.profileCache, this.trustReferents, this.wotSettings, super.key, }); final SocialService social; /// The shared relay connection (one per identity), carrying messaging + trust. final SocialConnection connection; final String peerPubkey; /// Optional persistence for chat history (encrypted [ChatDatabase]-backed); /// null in tests. final MessageStore? messageStore; /// 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 createState() => _ChatScreenState(); } class _ChatScreenState extends State { MessagesCubit? _messages; TrustCubit? _trust; bool _loading = true; String? _peerName; String? _selfName; String? _peerG1; final _input = TextEditingController(); /// App-wide unread tracker (absent in tests / inventory-only builds). While /// this chat is open it is the "active peer", so incoming messages for it are /// read on arrival and never badge or notify. UnreadService? _unread; @override void initState() { super.initState(); _unread = getIt.isRegistered() ? getIt() : null; // Set synchronously (before any await) so a message landing during _init is // already suppressed. _unread?.activePeer = widget.peerPubkey; unawaited(_unread?.markRead(widget.peerPubkey)); _init(); } Future _init() async { // The shared connection carries both messaging and trust. Offline (not // connected / unreachable) → null transports and the screen degrades. final session = await widget.connection.session(); final cachedName = await widget.profileCache?.name(widget.peerPubkey); // My own display name, so my avatar shows my initial (not a bare glyph). final selfName = getIt.isRegistered() ? await getIt().name() : ''; final referents = await widget.trustReferents?.all() ?? const {}; final wotParams = await widget.wotSettings?.params() ?? WotParams.duniter; if (!mounted) return; // shared session is owned by the connection, not us final self = widget.social.publicKeyHex; final messages = MessagesCubit( session?.messages, peerPubkey: widget.peerPubkey, selfPubkey: self, store: widget.messageStore, )..start(); final trust = TrustCubit( session?.trust, peerPubkey: widget.peerPubkey, selfPubkey: self, referents: referents, params: wotParams, ); unawaited(trust.load()); setState(() { _messages = messages; _trust = trust; _peerName = cachedName; _selfName = selfName.isEmpty ? null : selfName; _loading = false; }); // Freshen the peer's name from their published profile. final live = session; if (live != null && widget.profileCache != null) { unawaited(() async { try { final profile = await live.profile.fetch(widget.peerPubkey); if (profile != null && mounted) { if (profile.name.isNotEmpty) { await widget.profileCache!.setName( widget.peerPubkey, profile.name, ); } setState(() { if (profile.name.isNotEmpty) _peerName = profile.name; if (profile.g1.isNotEmpty) _peerG1 = profile.g1; }); } } catch (_) { // best effort } }()); } } /// Opens the peer's Ğ1 wallet so you can pay them (Ğ1 is separate from the /// Nostr key — this uses the address they published in their profile). Falls /// back to copying the address if no wallet handles the link. /// /// NOTE: confirm the deep-link scheme for the target wallet (Ğ1nkgo / Ğecko / /// Cesium) — this uses the Cesium web wallet as a universal fallback. Future _payG1() async { final g1 = _peerG1; if (g1 == null) return; final messenger = ScaffoldMessenger.of(context); final t = context.t; final uri = Uri.parse('https://demo.cesium.app/#/app/wallet/$g1/'); var launched = false; try { launched = await launchUrl(uri, mode: LaunchMode.externalApplication); } catch (_) { launched = false; } if (!launched && mounted) { await Clipboard.setData(ClipboardData(text: g1)); messenger.showSnackBar(SnackBar(content: Text(t.chat.g1Copied))); } } Future _send() async { final cubit = _messages; if (cubit == null || _input.text.trim().isEmpty) return; final text = _input.text; // Links aren't allowed — warn and keep the text so it can be edited. if (containsUrl(text)) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(SnackBar(content: Text(context.t.chat.noLinks))); return; } _input.clear(); await cubit.send(text); } @override void dispose() { if (_unread?.activePeer == widget.peerPubkey) _unread!.activePeer = null; // Mark read once more on the way out, so anything that arrived while the // chat was open (and was auto-read) stays cleared. unawaited(_unread?.markRead(widget.peerPubkey)); _messages?.close(); _trust?.close(); // The session belongs to the shared connection — don't close it here. _input.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final messages = _messages; final trust = _trust; final t = context.t; return Scaffold( appBar: AppBar( title: Text(_peerName ?? shortPubkey(widget.peerPubkey)), actions: [ if (_peerG1 != null) IconButton( key: const Key('chat.payG1'), icon: const Icon(Icons.payments_outlined), tooltip: t.chat.payG1, onPressed: _payG1, ), ], ), 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, peerName: _peerName, selfName: _selfName, ), ), ); } } class _ChatBody extends StatelessWidget { const _ChatBody({ required this.controller, required this.onSend, this.peerName, this.selfName, }); final TextEditingController controller; final Future Function() onSend; final String? peerName; final String? selfName; @override Widget build(BuildContext context) { final t = context.t; final cubit = context.read(); 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), ), ), ); } // Surface a failed send instead of losing it silently (relay down, etc.). return BlocListener( listenWhen: (prev, cur) => cur.error != null && prev.error != cur.error, listener: (context, state) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(SnackBar(content: Text(t.chat.sendError))); }, child: Column( children: [ const _TrustBanner(), Expanded( child: ChatMessageList(peerName: peerName, selfName: selfName), ), _Composer(controller: controller, onSend: onSend), ], ), ); } } /// The scrolling list of chat bubbles. Anchored at the bottom (chat /// convention): the newest message is always in view, and new arrivals show up /// in place instead of landing below the fold. Reads the [MessagesCubit] from /// context. class ChatMessageList extends StatelessWidget { const ChatMessageList({this.peerName, this.selfName, super.key}); /// The peer's display name, for their avatar's initial (null → a glyph). final String? peerName; /// My own display name, for my avatar's initial (null → a glyph). final String? selfName; @override Widget build(BuildContext context) { final t = context.t; final cubit = context.read(); return BlocBuilder( builder: (context, state) { if (state.messages.isEmpty) { return Center( child: Text(t.chat.empty, style: const TextStyle(color: seedMuted)), ); } // Group into bubbles + day separators (oldest-first), then render // bottom-anchored: with `reverse: true` index 0 is the bottom-most // (newest), so map it to the tail. Scrolling up to read history stays // put when new messages come in. final items = chatTimeline(state.messages); return ListView.builder( reverse: true, padding: const EdgeInsets.all(12), itemCount: items.length, itemBuilder: (context, i) { final item = items[items.length - 1 - i]; return switch (item) { ChatDaySeparator(:final day) => _DaySeparator(day: day), ChatMessageRow(:final message) => _MessageRow( message: message, mine: cubit.isMine(message), peerName: peerName, selfName: selfName, selfPubkey: cubit.selfPubkey, ), }; }, ); }, ); } } /// 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( builder: (context, state) { final cubit = context.read(); if (!cubit.isOnline || state.loading) return const SizedBox.shrink(); // 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(icon, size: 18, color: strong ? seedGreen : seedMuted), const SizedBox(width: 8), Expanded( child: Text( label, style: TextStyle( color: strong ? seedGreen : seedOnSurface, fontSize: 13, fontWeight: strong ? FontWeight.w600 : FontWeight.w400, ), ), ), TextButton( key: const Key('chat.vouch'), onPressed: state.busy ? null : cubit.toggleVouch, child: Text(state.iVouch ? t.trust.vouched : t.trust.vouch), ), ], ), ); }, ); } } /// A centered pill introducing a new day ("Today", "Yesterday", or a /// locale-formatted date). Localized via [chatDayLabel]. class _DaySeparator extends StatelessWidget { const _DaySeparator({required this.day}); final DateTime day; @override Widget build(BuildContext context) { final t = context.t; // Localizations locale, not the raw app locale: `ast` has no intl date // symbols and is mapped to Spanish for the framework (see app.dart). final localeCode = Localizations.localeOf(context).languageCode; final label = chatDayLabel( day, now: DateTime.now(), today: t.chat.today, yesterday: t.chat.yesterday, localeCode: localeCode, ); return Center( key: const Key('chat.daySeparator'), child: Container( margin: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( color: seedMuted.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), ), child: Text( label, style: const TextStyle( color: seedMuted, fontSize: 12, fontWeight: FontWeight.w500, ), ), ), ); } } /// One message line. Each sender gets their own coloured avatar on their side — /// mine on the trailing edge, the peer's on the leading edge — so at a glance /// the two sides are two people, not one column of look-alike bubbles. class _MessageRow extends StatelessWidget { const _MessageRow({ required this.message, required this.mine, this.peerName, this.selfName, this.selfPubkey, }); final PrivateMessage message; final bool mine; final String? peerName; final String? selfName; /// My own pubkey, so my messages carry my (distinctly coloured) avatar. final String? selfPubkey; @override Widget build(BuildContext context) { // Each avatar shows its owner's initial (falling back to a glyph when no // name is known) on a colour derived from their pubkey. final avatar = mine ? PeerAvatar(pubkey: selfPubkey ?? message.fromPubkey, name: selfName) : PeerAvatar(pubkey: message.fromPubkey, name: peerName); final bubble = Flexible( child: _Bubble(message: message, mine: mine), ); return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: mine ? MainAxisAlignment.end : MainAxisAlignment.start, children: mine ? [bubble, const SizedBox(width: 8), avatar] : [avatar, const SizedBox(width: 8), bubble], ), ); } } class _Bubble extends StatelessWidget { const _Bubble({required this.message, required this.mine}); final PrivateMessage message; final bool mine; @override Widget build(BuildContext context) { final localeCode = Localizations.localeOf(context).languageCode; final time = chatBubbleTime(message.at, localeCode); return Container( margin: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.75, ), // Soft, light bubbles both ways (dark text): mine a tonal green, the // peer's white with a hairline. Sides + avatar colours carry the // "who", so the bubbles themselves stay light and easy on the eye. decoration: BoxDecoration( color: mine ? seedPrimaryContainer : Colors.white, borderRadius: BorderRadius.circular(16), border: mine ? null : Border.all(color: seedOutline), ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Long text can be selected/copied — useful for addresses, links. SelectableText( message.text, style: const TextStyle(color: seedOnSurface, fontSize: 15), ), const SizedBox(height: 2), // A subtle timestamp, trailing edge (RTL-aware). Align( alignment: AlignmentDirectional.centerEnd, child: Text( time, style: const TextStyle(color: seedMuted, fontSize: 11), ), ), ], ), ); } } class _Composer extends StatelessWidget { const _Composer({required this.controller, required this.onSend}); final TextEditingController controller; final Future 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, ), ], ), ), ); } }