tane/apps/app_seeds/lib/ui/chat_screen.dart
vjrj d2b98af36d Merge branch 'claude/awesome-golick-007e7d': message unread badges + OS notifications
# Conflicts:
#	apps/app_seeds/lib/di/injector.dart
#	apps/app_seeds/lib/i18n/ast.i18n.json
#	apps/app_seeds/lib/i18n/en.i18n.json
#	apps/app_seeds/lib/i18n/es.i18n.json
#	apps/app_seeds/lib/i18n/pt.i18n.json
#	apps/app_seeds/lib/i18n/strings.g.dart
#	apps/app_seeds/lib/i18n/strings_ast.g.dart
#	apps/app_seeds/lib/i18n/strings_en.g.dart
#	apps/app_seeds/lib/i18n/strings_es.g.dart
#	apps/app_seeds/lib/i18n/strings_pt.g.dart
#	apps/app_seeds/lib/ui/chat_screen.dart
2026-07-10 21:15:59 +02:00

407 lines
13 KiB
Dart

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 '../i18n/strings.g.dart';
import '../services/message_store.dart';
import '../services/profile_cache.dart';
import '../services/social_service.dart';
import '../services/social_settings.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 '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,
this.messageStore,
this.profileCache,
this.trustReferents,
this.wotSettings,
super.key,
});
final SocialService social;
final SocialSettings settings;
final String peerPubkey;
/// Optional persistence for chat history (keystore-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<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
SocialSession? _session;
MessagesCubit? _messages;
TrustCubit? _trust;
bool _loading = true;
String? _peerName;
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<UnreadService>() ? getIt<UnreadService>() : 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<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;
}
}
final cachedName = await widget.profileCache?.name(widget.peerPubkey);
final referents = await widget.trustReferents?.all() ?? const <String>{};
final wotParams =
await widget.wotSettings?.params() ?? WotParams.duniter;
if (!mounted) {
await session?.close();
return;
}
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(() {
_session = session;
_messages = messages;
_trust = trust;
_peerName = cachedName;
_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<void> _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<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() {
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();
_session?.close();
_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),
),
);
}
}
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();
// 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),
),
],
),
);
},
);
}
}
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,
),
],
),
),
);
}
}