On short screens (e.g. landscape) the fixed-height trust and rating strips plus the composer overflowed the body once the keyboard shrank it. Wrap the two secondary strips in HideWhenKeyboardOpen so they yield their vertical space while typing and reappear when the keyboard closes.
788 lines
26 KiB
Dart
788 lines
26 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 '../domain/chat_timeline.dart';
|
|
import '../domain/message_rules.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../services/message_store.dart';
|
|
import '../services/onboarding_store.dart';
|
|
import '../services/profile_cache.dart';
|
|
import '../services/profile_store.dart';
|
|
import '../services/social_connection.dart';
|
|
import '../services/social_service.dart';
|
|
import '../services/social_settings.dart';
|
|
import '../services/unread_service.dart';
|
|
import '../state/messages_cubit.dart';
|
|
import '../state/peer_rating_cubit.dart';
|
|
import '../state/trust_cubit.dart';
|
|
import 'market_gate.dart';
|
|
import 'peer_avatar.dart';
|
|
import 'rating_sheet.dart';
|
|
import 'report_sheet.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.onboarding,
|
|
this.settings,
|
|
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;
|
|
|
|
/// When set, the first message requires the one-time community-rules
|
|
/// acceptance (a chat can be reached from an incoming message without ever
|
|
/// entering the market). Null in tests → no gate.
|
|
final OnboardingStore? onboarding;
|
|
|
|
/// Social settings holding the local blocklist; falls back to the app-wide
|
|
/// instance so the block action also works when not injected. Null in
|
|
/// tests → the block menu is hidden.
|
|
final SocialSettings? settings;
|
|
|
|
@override
|
|
State<ChatScreen> createState() => _ChatScreenState();
|
|
}
|
|
|
|
class _ChatScreenState extends State<ChatScreen> {
|
|
MessagesCubit? _messages;
|
|
TrustCubit? _trust;
|
|
PeerRatingCubit? _rating;
|
|
bool _loading = true;
|
|
String? _peerName;
|
|
String? _selfName;
|
|
String? _peerPicture;
|
|
String? _selfPicture;
|
|
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 {
|
|
// 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);
|
|
final cachedPicture = await widget.profileCache?.picture(widget.peerPubkey);
|
|
// My own name + avatar, so my side shows me (not a bare glyph).
|
|
final selfStore = getIt.isRegistered<ProfileStore>()
|
|
? getIt<ProfileStore>()
|
|
: null;
|
|
final selfName = selfStore == null ? '' : await selfStore.name();
|
|
final selfAvatar = selfStore == null ? '' : await selfStore.avatar();
|
|
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,
|
|
);
|
|
unawaited(trust.load());
|
|
final rating = PeerRatingCubit(
|
|
session?.ratings,
|
|
peerPubkey: widget.peerPubkey,
|
|
selfPubkey: self,
|
|
trust: session?.trust,
|
|
);
|
|
unawaited(rating.load());
|
|
setState(() {
|
|
_messages = messages;
|
|
_trust = trust;
|
|
_rating = rating;
|
|
_peerName = cachedName;
|
|
_selfName = selfName.isEmpty ? null : selfName;
|
|
_peerPicture = cachedPicture;
|
|
_selfPicture = selfAvatar.isEmpty ? null : selfAvatar;
|
|
_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,
|
|
);
|
|
}
|
|
if (profile.picture.isNotEmpty) {
|
|
await widget.profileCache!.setPicture(
|
|
widget.peerPubkey,
|
|
profile.picture,
|
|
);
|
|
}
|
|
setState(() {
|
|
if (profile.name.isNotEmpty) _peerName = profile.name;
|
|
if (profile.picture.isNotEmpty) _peerPicture = profile.picture;
|
|
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)));
|
|
}
|
|
}
|
|
|
|
SocialSettings? get _settings =>
|
|
widget.settings ??
|
|
(getIt.isRegistered<SocialSettings>() ? getIt<SocialSettings>() : null);
|
|
|
|
/// Reports this peer (a standard report event to the community servers);
|
|
/// when they were also blocked from the sheet, leaves the chat too.
|
|
Future<void> _reportPeer() async {
|
|
final outcome = await showReportSheet(
|
|
context,
|
|
connection: widget.connection,
|
|
subjectPubkey: widget.peerPubkey,
|
|
settings: _settings,
|
|
);
|
|
if (outcome != null && outcome.blocked && mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
|
|
/// Blocks this peer after confirmation and leaves the chat; their offers,
|
|
/// conversation and future messages disappear (all locally).
|
|
Future<void> _blockPeer() async {
|
|
final settings = _settings;
|
|
if (settings == null) return;
|
|
final t = context.t;
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(t.block.confirmTitle),
|
|
content: Text(t.block.confirmBody),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: Text(t.common.cancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
child: Text(t.block.confirm),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed != true || !mounted) return;
|
|
await settings.block(widget.peerPubkey);
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(SnackBar(content: Text(t.block.blockedToast)));
|
|
Navigator.of(context).pop();
|
|
}
|
|
|
|
Future<void> _send() async {
|
|
final cubit = _messages;
|
|
if (cubit == null || _input.text.trim().isEmpty) return;
|
|
// Writing to someone is creating content: the community-rules acceptance
|
|
// must exist even when this chat was reached without entering the market.
|
|
final store = widget.onboarding;
|
|
if (store != null) {
|
|
final ok = await ensureMarketRulesAccepted(context, store);
|
|
if (!ok || !mounted) 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();
|
|
_rating?.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 rating = _rating;
|
|
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,
|
|
),
|
|
PopupMenuButton<String>(
|
|
key: const Key('chat.menu'),
|
|
onSelected: (value) {
|
|
if (value == 'report') _reportPeer();
|
|
if (value == 'block') _blockPeer();
|
|
},
|
|
itemBuilder: (context) => [
|
|
PopupMenuItem(
|
|
value: 'report',
|
|
child: Text(t.report.person),
|
|
),
|
|
if (_settings != null)
|
|
PopupMenuItem(
|
|
value: 'block',
|
|
child: Text(t.block.action),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
body: _loading || messages == null || trust == null || rating == null
|
|
? const Center(child: CircularProgressIndicator())
|
|
: MultiBlocProvider(
|
|
providers: [
|
|
BlocProvider.value(value: messages),
|
|
BlocProvider.value(value: trust),
|
|
BlocProvider.value(value: rating),
|
|
],
|
|
child: _ChatBody(
|
|
controller: _input,
|
|
onSend: _send,
|
|
peerName: _peerName,
|
|
selfName: _selfName,
|
|
peerPicture: _peerPicture,
|
|
selfPicture: _selfPicture,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ChatBody extends StatelessWidget {
|
|
const _ChatBody({
|
|
required this.controller,
|
|
required this.onSend,
|
|
this.peerName,
|
|
this.selfName,
|
|
this.peerPicture,
|
|
this.selfPicture,
|
|
});
|
|
|
|
final TextEditingController controller;
|
|
final Future<void> Function() onSend;
|
|
final String? peerName;
|
|
final String? selfName;
|
|
final String? peerPicture;
|
|
final String? selfPicture;
|
|
|
|
@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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
// Surface a failed send instead of losing it silently (relay down, etc.).
|
|
return BlocListener<MessagesCubit, ChatState>(
|
|
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: [
|
|
// Free the vertical space the trust/rating strips take while the
|
|
// keyboard is up: on short screens (e.g. landscape) their fixed height
|
|
// plus the composer would otherwise overflow the shrunken body. They
|
|
// reappear the moment the keyboard is dismissed.
|
|
const HideWhenKeyboardOpen(child: _TrustBanner()),
|
|
const HideWhenKeyboardOpen(child: _RatingStrip()),
|
|
Expanded(
|
|
child: ChatMessageList(
|
|
peerName: peerName,
|
|
selfName: selfName,
|
|
peerPicture: peerPicture,
|
|
selfPicture: selfPicture,
|
|
),
|
|
),
|
|
_Composer(controller: controller, onSend: onSend),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Hides [child] while the on-screen keyboard is open. Used for the chat's
|
|
/// secondary strips (trust, rating) so their fixed height doesn't push the
|
|
/// composer off a short screen when the keyboard steals vertical space.
|
|
class HideWhenKeyboardOpen extends StatelessWidget {
|
|
const HideWhenKeyboardOpen({required this.child, super.key});
|
|
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) =>
|
|
MediaQuery.viewInsetsOf(context).bottom > 0
|
|
? const SizedBox.shrink()
|
|
: child;
|
|
}
|
|
|
|
/// 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,
|
|
this.peerPicture,
|
|
this.selfPicture,
|
|
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;
|
|
|
|
/// The peer's / my published avatar (null → the coloured-initial disc).
|
|
final String? peerPicture;
|
|
final String? selfPicture;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.t;
|
|
final cubit = context.read<MessagesCubit>();
|
|
return BlocBuilder<MessagesCubit, ChatState>(
|
|
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,
|
|
peerPicture: peerPicture,
|
|
selfPicture: selfPicture,
|
|
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<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 (circle > vouched >
|
|
// unknown).
|
|
final (IconData icon, String label, bool strong) = switch (state.tier) {
|
|
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 slim reputation strip under the trust banner: everyone's stars for this
|
|
/// peer (highlighting how many come from people you know) and, once you've
|
|
/// actually talked with them, a button to leave or edit your own rating.
|
|
class _RatingStrip extends StatelessWidget {
|
|
const _RatingStrip();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.t;
|
|
return BlocBuilder<PeerRatingCubit, PeerRatingState>(
|
|
builder: (context, state) {
|
|
final cubit = context.read<PeerRatingCubit>();
|
|
if (!cubit.isOnline || state.loading) return const SizedBox.shrink();
|
|
// Soft anchor: you can only rate someone you've talked with.
|
|
final canRate = context.select<MessagesCubit, bool>(
|
|
(m) => m.state.messages.isNotEmpty,
|
|
);
|
|
if (!state.hasRatings && !canRate) return const SizedBox.shrink();
|
|
return Container(
|
|
width: double.infinity,
|
|
color: seedPrimaryContainer.withValues(alpha: 0.3),
|
|
padding: const EdgeInsetsDirectional.fromSTEB(16, 4, 8, 4),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: state.hasRatings
|
|
? RatingStars(
|
|
average: state.average,
|
|
count: state.count,
|
|
circleCount: state.circleCount,
|
|
)
|
|
: const SizedBox.shrink(),
|
|
),
|
|
if (canRate)
|
|
TextButton(
|
|
key: const Key('chat.rate'),
|
|
onPressed: state.busy
|
|
? null
|
|
: () => showRatingSheet(context, cubit),
|
|
child:
|
|
Text(state.iRated ? t.ratings.edit : t.ratings.rate),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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.peerPicture,
|
|
this.selfPicture,
|
|
this.selfPubkey,
|
|
});
|
|
|
|
final PrivateMessage message;
|
|
final bool mine;
|
|
final String? peerName;
|
|
final String? selfName;
|
|
final String? peerPicture;
|
|
final String? selfPicture;
|
|
|
|
/// 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 photo/illustration when set, else their
|
|
// initial (or a glyph) on a colour derived from their pubkey.
|
|
final avatar = mine
|
|
? PeerAvatar(
|
|
pubkey: selfPubkey ?? message.fromPubkey,
|
|
name: selfName,
|
|
picture: selfPicture,
|
|
)
|
|
: PeerAvatar(
|
|
pubkey: message.fromPubkey,
|
|
name: peerName,
|
|
picture: peerPicture,
|
|
);
|
|
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<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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|