mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-14 22:55:12 +10:00
1718 lines
62 KiB
Dart
1718 lines
62 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/scheduler.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../utils/platform_info.dart';
|
|
|
|
import '../connector/meshcore_connector.dart';
|
|
import '../connector/meshcore_protocol.dart';
|
|
import '../helpers/cyr2lat.dart';
|
|
import '../helpers/reaction_helper.dart';
|
|
import '../widgets/message_status_icon.dart';
|
|
import '../widgets/empty_state.dart';
|
|
import '../helpers/chat_scroll_controller.dart';
|
|
import '../helpers/gif_helper.dart';
|
|
import '../models/channel_message.dart';
|
|
import '../models/contact.dart';
|
|
import '../l10n/contact_localization.dart';
|
|
import '../models/message.dart';
|
|
import '../models/translation_support.dart';
|
|
import '../services/app_settings_service.dart';
|
|
import '../services/chat_text_scale_service.dart';
|
|
import '../services/path_history_service.dart';
|
|
import '../services/translation_service.dart';
|
|
import '../widgets/chat_zoom_wrapper.dart';
|
|
import '../widgets/byte_count_input.dart';
|
|
import 'channel_message_path_screen.dart';
|
|
import 'map_screen.dart';
|
|
import '../widgets/emoji_picker.dart';
|
|
import '../widgets/gif_message.dart';
|
|
import '../widgets/jump_to_bottom_button.dart';
|
|
import '../widgets/gif_picker.dart';
|
|
import '../widgets/message_translation_button.dart';
|
|
import '../widgets/routing_sheet.dart';
|
|
import '../widgets/radio_stats_entry.dart';
|
|
import '../widgets/sync_progress_overlay.dart';
|
|
import '../widgets/translated_message_content.dart';
|
|
import '../l10n/l10n.dart';
|
|
import '../helpers/snack_bar_builder.dart';
|
|
import '../widgets/unread_divider.dart';
|
|
import '../theme/mesh_theme.dart';
|
|
import '../widgets/mesh_ui.dart';
|
|
import 'telemetry_screen.dart';
|
|
|
|
class ChatScreen extends StatefulWidget {
|
|
final Contact contact;
|
|
final int initialUnreadCount;
|
|
|
|
const ChatScreen({
|
|
super.key,
|
|
required this.contact,
|
|
this.initialUnreadCount = 0,
|
|
});
|
|
|
|
@override
|
|
State<ChatScreen> createState() => _ChatScreenState();
|
|
}
|
|
|
|
class _ChatScreenState extends State<ChatScreen> {
|
|
final _textController = TextEditingController();
|
|
final _scrollController = ChatScrollController();
|
|
final _textFieldFocusNode = FocusNode();
|
|
final GlobalKey _unreadScrollKey = GlobalKey();
|
|
bool _isLoadingOlder = false;
|
|
MeshCoreConnector? _connector;
|
|
Message? _pendingUnreadScrollTarget;
|
|
String? _unreadDividerMessageId;
|
|
DateTime? _lastTextSendAt;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
|
|
_scrollController.onScrollNearTop = _loadOlderMessages;
|
|
_scrollController.showJumpToBottom.addListener(_clearDividerAtBottom);
|
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
final connector = context.read<MeshCoreConnector>();
|
|
final settings = context.read<AppSettingsService>().settings;
|
|
final keyHex = widget.contact.publicKeyHex;
|
|
final unread = widget.initialUnreadCount;
|
|
final messages = connector.getMessages(widget.contact);
|
|
Message? anchor;
|
|
if (unread > 0) {
|
|
anchor = _findOldestUnreadAnchor(messages, unread);
|
|
}
|
|
setState(() {
|
|
if (anchor != null) _unreadDividerMessageId = anchor.messageId;
|
|
if (anchor != null && settings.jumpToOldestUnread) {
|
|
_pendingUnreadScrollTarget = anchor;
|
|
}
|
|
});
|
|
connector.setActiveContact(keyHex);
|
|
_connector = connector;
|
|
if (anchor != null && settings.jumpToOldestUnread) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
_scrollController.jumpToEstimatedOffset(
|
|
unreadCount: unread,
|
|
totalMessages: messages.length,
|
|
onJumped: () async {
|
|
if (!mounted) return;
|
|
final ctx = _unreadScrollKey.currentContext;
|
|
if (ctx != null) {
|
|
await Scrollable.ensureVisible(
|
|
ctx,
|
|
duration: const Duration(milliseconds: 350),
|
|
alignment: 0.15,
|
|
);
|
|
}
|
|
if (mounted) {
|
|
setState(() => _pendingUnreadScrollTarget = null);
|
|
}
|
|
},
|
|
);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
Message? _findOldestUnreadAnchor(List<Message> messages, int unreadCount) {
|
|
if (unreadCount <= 0 || messages.isEmpty) return null;
|
|
var n = 0;
|
|
Message? oldest;
|
|
for (final m in messages.reversed) {
|
|
if (m.isOutgoing || m.isCli) continue;
|
|
n++;
|
|
oldest = m;
|
|
if (n >= unreadCount) break;
|
|
}
|
|
return oldest;
|
|
}
|
|
|
|
void _clearDividerAtBottom() {
|
|
if (!_scrollController.showJumpToBottom.value &&
|
|
_unreadDividerMessageId != null) {
|
|
setState(() => _unreadDividerMessageId = null);
|
|
}
|
|
}
|
|
|
|
void _onTextFieldFocusChange() {
|
|
if (_textFieldFocusNode.hasFocus && mounted) {
|
|
_scrollController.handleKeyboardOpen();
|
|
}
|
|
}
|
|
|
|
Future<void> _loadOlderMessages() async {
|
|
if (_isLoadingOlder) return;
|
|
setState(() => _isLoadingOlder = true);
|
|
|
|
final connector = context.read<MeshCoreConnector>();
|
|
await connector.loadOlderMessages(widget.contact.publicKeyHex);
|
|
|
|
if (mounted) {
|
|
setState(() => _isLoadingOlder = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_connector?.setActiveContact(null);
|
|
_scrollController.showJumpToBottom.removeListener(_clearDividerAtBottom);
|
|
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
|
|
_textFieldFocusNode.dispose();
|
|
_textController.dispose();
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Consumer2<PathHistoryService, MeshCoreConnector>(
|
|
builder: (context, pathService, connector, _) {
|
|
final contact = _resolveContact(connector);
|
|
final unreadCount = connector.getUnreadCountForContactKey(
|
|
widget.contact.publicKeyHex,
|
|
);
|
|
final unreadLabel = context.l10n.chat_unread(unreadCount);
|
|
final pathLabel = _currentPathLabel(contact);
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
contact.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () =>
|
|
ContactRoutingSheet.show(context, contact: contact),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: Text(
|
|
'$pathLabel • $unreadLabel',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.normal,
|
|
decoration: TextDecoration.underline,
|
|
decorationStyle: TextDecorationStyle.dotted,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
centerTitle: false,
|
|
bottom: const SyncProgressAppBarBottom(),
|
|
actions: [
|
|
const RadioStatsIconButton(),
|
|
Consumer<MeshCoreConnector>(
|
|
builder: (context, connector, _) {
|
|
final contact = _resolveContact(connector);
|
|
|
|
return PopupMenuButton<String>(
|
|
icon: const Icon(Icons.more_vert),
|
|
onSelected: (value) {
|
|
switch (value) {
|
|
case 'routing':
|
|
ContactRoutingSheet.show(context, contact: contact);
|
|
case 'info':
|
|
_showContactInfo(context);
|
|
case 'settings':
|
|
_showContactSettings(context);
|
|
case 'telemetry':
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) =>
|
|
TelemetryScreen(contact: widget.contact),
|
|
),
|
|
);
|
|
case 'clearChat':
|
|
_confirmClearChat(context, connector);
|
|
}
|
|
},
|
|
itemBuilder: (context) => [
|
|
PopupMenuItem(
|
|
value: 'routing',
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.route, size: 20),
|
|
const SizedBox(width: 12),
|
|
Text(context.l10n.routing_title),
|
|
],
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
value: 'info',
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.info_outline, size: 20),
|
|
const SizedBox(width: 12),
|
|
Text(context.l10n.contact_info),
|
|
],
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
value: 'telemetry',
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.bar_chart, size: 20),
|
|
const SizedBox(width: 12),
|
|
Text(context.l10n.contact_telemetry),
|
|
],
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
value: 'settings',
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.settings, size: 20),
|
|
const SizedBox(width: 12),
|
|
Text(context.l10n.contact_settings),
|
|
],
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
value: 'clearChat',
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.delete,
|
|
size: 20,
|
|
color: Theme.of(context).colorScheme.error,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
context.l10n.contact_clearChat,
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.error,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
body: Consumer<MeshCoreConnector>(
|
|
builder: (context, connector, child) {
|
|
final messages = connector.getMessages(widget.contact);
|
|
return Column(
|
|
children: [
|
|
Expanded(
|
|
child: Stack(
|
|
children: [
|
|
messages.isEmpty
|
|
? _buildEmptyState()
|
|
: _buildMessageList(messages, connector),
|
|
JumpToBottomButton(scrollController: _scrollController),
|
|
],
|
|
),
|
|
),
|
|
_buildInputBar(connector),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildEmptyState() {
|
|
return EmptyState(
|
|
icon: Icons.chat_bubble_outline,
|
|
title: context.l10n.chat_noMessages,
|
|
subtitle: context.l10n.chat_sendMessageTo(
|
|
_resolveContact(context.read<MeshCoreConnector>()).name,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMessageList(
|
|
List<Message> messages,
|
|
MeshCoreConnector connector,
|
|
) {
|
|
// Reverse messages so newest appear at bottom with reverse: true
|
|
final reversedMessages = messages.reversed.toList();
|
|
final itemCount = reversedMessages.length + (_isLoadingOlder ? 1 : 0);
|
|
|
|
// Auto-scroll to bottom if user is already at bottom
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
if (_pendingUnreadScrollTarget != null) return;
|
|
_scrollController.scrollToBottomIfAtBottom();
|
|
});
|
|
|
|
return ChatZoomWrapper(
|
|
child: ListView.builder(
|
|
reverse: true, // List grows from bottom up
|
|
controller: _scrollController,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
|
|
itemCount: itemCount,
|
|
itemBuilder: (context, index) {
|
|
// Loading indicator now appears at end (bottom) of reversed list
|
|
if (_isLoadingOlder && index == itemCount - 1) {
|
|
return const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 16),
|
|
child: Center(
|
|
child: SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
final messageIndex = index;
|
|
Contact contact = _resolveContact(connector);
|
|
final bool isRoom = contact.type == advTypeRoom;
|
|
final message = reversedMessages[messageIndex];
|
|
String fourByteHex = '';
|
|
Contact? roomAuthor;
|
|
if (isRoom) {
|
|
// Room-server messages carry the original author's 4-byte prefix
|
|
// separately from message.text; use it only for resolving the name.
|
|
roomAuthor = _resolveContactFrom4Bytes(
|
|
connector,
|
|
message.fourByteRoomContactKey.isEmpty
|
|
? Uint8List.fromList([0, 0, 0, 0])
|
|
: message.fourByteRoomContactKey,
|
|
);
|
|
fourByteHex = message.fourByteRoomContactKey
|
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
|
.join()
|
|
.toUpperCase();
|
|
// Only adopt the author identity when we actually know them; never
|
|
// fall back to the room server's own name as the sender.
|
|
if (roomAuthor != null) contact = roomAuthor;
|
|
}
|
|
|
|
return Builder(
|
|
builder: (context) {
|
|
final textScale = context.select<ChatTextScaleService, double>(
|
|
(service) => service.scale,
|
|
);
|
|
final bubble = _MessageBubble(
|
|
message: message,
|
|
senderName: isRoom
|
|
? (roomAuthor != null
|
|
? "${roomAuthor.name} [$fourByteHex]"
|
|
: "[$fourByteHex]")
|
|
: contact.name,
|
|
sourceId: widget.contact.publicKeyHex,
|
|
textScale: textScale,
|
|
onLongPress: () => _showMessageActions(message, contact),
|
|
onRetryReaction: (msg, emoji) =>
|
|
_sendReaction(msg, contact, emoji),
|
|
);
|
|
final isUnreadAnchor =
|
|
_unreadDividerMessageId != null &&
|
|
message.messageId == _unreadDividerMessageId;
|
|
final child = isUnreadAnchor
|
|
? Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [const UnreadDivider(), bubble],
|
|
)
|
|
: bubble;
|
|
if (identical(message, _pendingUnreadScrollTarget)) {
|
|
return KeyedSubtree(key: _unreadScrollKey, child: child);
|
|
}
|
|
return child;
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _markAsUnread(Message message) {
|
|
final connector = context.read<MeshCoreConnector>();
|
|
final messages = connector.getMessages(widget.contact);
|
|
var count = 0;
|
|
var found = false;
|
|
for (final m in messages) {
|
|
if (m.messageId == message.messageId) found = true;
|
|
if (found && !m.isOutgoing && !m.isCli) count++;
|
|
}
|
|
connector.setContactUnreadCount(widget.contact.publicKeyHex, count);
|
|
}
|
|
|
|
Widget _buildInputBar(MeshCoreConnector connector) {
|
|
final maxBytes = maxContactMessageBytes();
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final settings = context.watch<AppSettingsService>().settings;
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: scheme.surface,
|
|
border: Border(top: BorderSide(color: scheme.outlineVariant, width: 1)),
|
|
),
|
|
child: SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.gif_box),
|
|
onPressed: () => _showGifPicker(context),
|
|
tooltip: context.l10n.chat_sendGif,
|
|
),
|
|
if (settings.translationEnabled)
|
|
MessageTranslationButton(
|
|
enabled: settings.composerTranslationEnabled,
|
|
languageCode: settings.translationTargetLanguageCode,
|
|
onPressed: _showTranslationOptions,
|
|
),
|
|
Expanded(
|
|
child: ValueListenableBuilder<TextEditingValue>(
|
|
valueListenable: _textController,
|
|
builder: (context, value, child) {
|
|
final gifId = GifHelper.parseGif(value.text);
|
|
if (gifId != null) {
|
|
return Focus(
|
|
autofocus: true,
|
|
onKeyEvent: (node, event) {
|
|
if (event is KeyDownEvent &&
|
|
(event.logicalKey == LogicalKeyboardKey.enter ||
|
|
event.logicalKey ==
|
|
LogicalKeyboardKey.numpadEnter)) {
|
|
_sendMessage(connector);
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
},
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: GifMessage(
|
|
url:
|
|
'https://media.giphy.com/media/$gifId/giphy.gif',
|
|
backgroundColor:
|
|
scheme.surfaceContainerHighest,
|
|
fallbackTextColor: scheme.onSurface
|
|
.withValues(alpha: 0.6),
|
|
maxSize: 160,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () {
|
|
_textController.clear();
|
|
_textFieldFocusNode.requestFocus();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
return ByteCountedTextField(
|
|
maxBytes: maxBytes,
|
|
controller: _textController,
|
|
focusNode: _textFieldFocusNode,
|
|
hintText: context.l10n.chat_typeMessage,
|
|
onSubmitted: (_) => _sendMessage(connector),
|
|
encoder:
|
|
(connector.isContactSmazEnabled(
|
|
widget.contact.publicKeyHex,
|
|
) ||
|
|
connector.isContactCyr2LatEnabled(
|
|
widget.contact.publicKeyHex,
|
|
))
|
|
? (text) => connector.prepareContactOutboundText(
|
|
widget.contact,
|
|
text,
|
|
)
|
|
: null,
|
|
decoration: InputDecoration(
|
|
hintText: context.l10n.chat_typeMessage,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
|
borderSide: BorderSide(color: scheme.outlineVariant),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
|
borderSide: BorderSide(color: scheme.outlineVariant),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
|
borderSide: BorderSide(
|
|
color: scheme.primary,
|
|
width: 1.5,
|
|
),
|
|
),
|
|
filled: true,
|
|
fillColor: scheme.surfaceContainerLow,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 18,
|
|
vertical: 12,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
ValueListenableBuilder<TextEditingValue>(
|
|
valueListenable: _textController,
|
|
builder: (context, value, _) {
|
|
final hasText = value.text.trim().isNotEmpty;
|
|
return AnimatedContainer(
|
|
duration: const Duration(milliseconds: 180),
|
|
curve: Curves.easeInOut,
|
|
child: IconButton.filled(
|
|
icon: const Icon(Icons.send, size: 20),
|
|
tooltip: context.l10n.chat_sendMessageTo(
|
|
_resolveContact(connector).name,
|
|
),
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: hasText
|
|
? scheme.primary
|
|
: scheme.surfaceContainerHighest,
|
|
foregroundColor: hasText
|
|
? scheme.onPrimary
|
|
: scheme.onSurfaceVariant,
|
|
minimumSize: const Size(40, 40),
|
|
shape: const CircleBorder(),
|
|
),
|
|
onPressed: hasText
|
|
? () {
|
|
HapticFeedback.lightImpact();
|
|
_sendMessage(connector);
|
|
}
|
|
: null,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showGifPicker(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (context) => GifPicker(
|
|
onGifSelected: (gifId) {
|
|
_textController.text = GifHelper.encodeGif(gifId);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _showTranslationOptions() async {
|
|
final settingsService = context.read<AppSettingsService>();
|
|
final settings = settingsService.settings;
|
|
await showMessageTranslationSheet(
|
|
context: context,
|
|
enabled: settings.composerTranslationEnabled,
|
|
selectedLanguageCode: settings.translationTargetLanguageCode,
|
|
onEnabledChanged: settingsService.setComposerTranslationEnabled,
|
|
onLanguageSelected: settingsService.setTranslationTargetLanguageCode,
|
|
);
|
|
}
|
|
|
|
Future<void> _sendMessage(MeshCoreConnector connector) async {
|
|
final text = _textController.text.trim();
|
|
if (text.isEmpty) return;
|
|
|
|
final now = DateTime.now();
|
|
if (_lastTextSendAt != null &&
|
|
now.difference(_lastTextSendAt!) < const Duration(seconds: 1)) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(context.l10n.chat_sendCooldown),
|
|
);
|
|
return;
|
|
}
|
|
_lastTextSendAt = now;
|
|
|
|
final settings = context.read<AppSettingsService>().settings;
|
|
final translationService = context.read<TranslationService>();
|
|
var outgoingText = text;
|
|
String? originalText;
|
|
String? translatedLanguageCode;
|
|
String? translationModelId;
|
|
if (settings.translationEnabled) {
|
|
final targetLanguageCode = translationService.resolvedTargetLanguageCode(
|
|
Localizations.localeOf(context).languageCode,
|
|
);
|
|
if (translationService.shouldTranslateOutgoing(
|
|
text: text,
|
|
targetLanguageCode: targetLanguageCode,
|
|
)) {
|
|
final result = await translationService.translateOutgoingText(
|
|
text: text,
|
|
targetLanguageCode: targetLanguageCode,
|
|
);
|
|
if (!mounted) return;
|
|
if (result != null &&
|
|
result.status == MessageTranslationStatus.completed &&
|
|
result.translatedText.isNotEmpty) {
|
|
outgoingText = result.translatedText;
|
|
originalText = text;
|
|
translatedLanguageCode = result.targetLanguageCode;
|
|
translationModelId = result.modelId;
|
|
}
|
|
}
|
|
}
|
|
final maxBytes = maxContactMessageBytes();
|
|
final outboundText = connector.prepareContactOutboundText(
|
|
_resolveContact(connector),
|
|
outgoingText,
|
|
);
|
|
if (utf8.encode(outboundText).length > maxBytes) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(context.l10n.chat_messageTooLong(maxBytes)),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// This is only for cyr2lat compression - to see the message being sent in the same format as the other person will receive
|
|
try {
|
|
if (connector.isContactCyr2LatEnabled(
|
|
_resolveContact(connector).publicKeyHex,
|
|
)) {
|
|
outgoingText = Cyr2Lat.encode(outgoingText);
|
|
}
|
|
} catch (_) {
|
|
// TODO maybe log
|
|
}
|
|
// end transform
|
|
|
|
_textController.clear();
|
|
_textFieldFocusNode.requestFocus();
|
|
connector.sendMessage(
|
|
_resolveContact(connector),
|
|
outgoingText,
|
|
originalText: originalText,
|
|
translatedLanguageCode: translatedLanguageCode,
|
|
translationModelId: translationModelId,
|
|
);
|
|
}
|
|
|
|
Future<void> _confirmClearChat(
|
|
BuildContext context,
|
|
MeshCoreConnector connector,
|
|
) async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(context.l10n.contact_clearChat),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, false),
|
|
child: Text(context.l10n.common_cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, true),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: Theme.of(context).colorScheme.error,
|
|
),
|
|
child: Text(context.l10n.common_delete),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed == true) {
|
|
connector.clearMessagesForContact(widget.contact);
|
|
}
|
|
}
|
|
|
|
int _resolveContactIndex = -1;
|
|
|
|
Contact _resolveContact(MeshCoreConnector connector) {
|
|
if (_resolveContactIndex >= 0 &&
|
|
_resolveContactIndex < connector.contacts.length &&
|
|
connector.contacts[_resolveContactIndex].publicKeyHex ==
|
|
widget.contact.publicKeyHex) {
|
|
return connector.contacts[_resolveContactIndex];
|
|
}
|
|
_resolveContactIndex = connector.contacts.indexWhere(
|
|
(c) => c.publicKeyHex == widget.contact.publicKeyHex,
|
|
);
|
|
if (_resolveContactIndex == -1) {
|
|
return widget.contact;
|
|
}
|
|
return connector.contacts[_resolveContactIndex];
|
|
}
|
|
|
|
Contact? _resolveContactFrom4Bytes(
|
|
MeshCoreConnector connector,
|
|
Uint8List key4Bytes,
|
|
) {
|
|
// Match against saved contacts first, then nodes only seen via discovery —
|
|
// a room poster you haven't saved may still be in the discovered list.
|
|
return connector.allContactsUnfiltered.cast<Contact?>().firstWhere(
|
|
(c) =>
|
|
c != null &&
|
|
listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)),
|
|
orElse: () => null,
|
|
);
|
|
}
|
|
|
|
String _currentPathLabel(Contact contact) {
|
|
// Check if user has set a path override
|
|
if (contact.pathOverride != null) {
|
|
if (contact.pathOverride! < 0) return context.l10n.chat_floodForced;
|
|
if (contact.pathOverride == 0) return context.l10n.chat_directForced;
|
|
return context.l10n.chat_hopsForced(contact.pathOverride!);
|
|
}
|
|
|
|
// Use device's path
|
|
if (contact.pathLength < 0) return context.l10n.chat_floodAuto;
|
|
if (contact.pathLength == 0) return context.l10n.chat_direct;
|
|
return context.l10n.chat_hopsCount(contact.pathLength);
|
|
}
|
|
|
|
void _showContactInfo(BuildContext context) {
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
final contact = _resolveContact(connector);
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: SelectableText(contact.name),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildInfoRow(
|
|
context.l10n.chat_type,
|
|
contact.typeLabel(context.l10n),
|
|
),
|
|
_buildInfoRow(
|
|
context.l10n.chat_path,
|
|
contact.pathLabel(context.l10n),
|
|
),
|
|
_buildInfoRow(
|
|
context.l10n.contact_lastSeen,
|
|
_formatContactLastMessage(contact.lastMessageAt),
|
|
),
|
|
if (contact.hasLocation)
|
|
_buildInfoRow(
|
|
context.l10n.chat_location,
|
|
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
|
|
),
|
|
_buildInfoRow(context.l10n.chat_publicKey, contact.publicKeyHex),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(context.l10n.common_close),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showContactSettings(BuildContext context) {
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
final appSettingsService = Provider.of<AppSettingsService>(
|
|
context,
|
|
listen: false,
|
|
);
|
|
connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex);
|
|
connector.ensureContactCyr2LatSettingLoaded(widget.contact.publicKeyHex);
|
|
final contact = widget.contact;
|
|
bool smazEnabled = connector.isContactSmazEnabled(contact.publicKeyHex);
|
|
bool cyr2latEnabled = connector.isContactCyr2LatEnabled(
|
|
contact.publicKeyHex,
|
|
);
|
|
String? selectedCyr2LatProfileId = connector.getContactCyr2LatProfileId(
|
|
contact.publicKeyHex,
|
|
);
|
|
bool teleBaseEnabled = contact.teleBaseEnabled;
|
|
bool teleLocEnabled = contact.teleLocEnabled;
|
|
bool teleEnvEnabled = contact.teleEnvEnabled;
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => StatefulBuilder(
|
|
builder: (context, setDialogState) => AlertDialog(
|
|
title: Text(context.l10n.contact_settings),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (contact.hasLocation) ...[
|
|
_buildInfoRow(
|
|
context.l10n.chat_location,
|
|
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
|
|
),
|
|
const Divider(height: 8),
|
|
],
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(context.l10n.channels_smazCompression),
|
|
subtitle: Text(context.l10n.chat_compressOutgoingMessages),
|
|
value: smazEnabled,
|
|
onChanged: (value) {
|
|
connector.setContactSmazEnabled(
|
|
contact.publicKeyHex,
|
|
value,
|
|
);
|
|
connector.setContactCyr2LatEnabled(
|
|
contact.publicKeyHex,
|
|
false,
|
|
);
|
|
setDialogState(() {
|
|
smazEnabled = value;
|
|
if (smazEnabled) {
|
|
cyr2latEnabled = false;
|
|
}
|
|
});
|
|
},
|
|
),
|
|
const Divider(height: 8),
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(context.l10n.channels_cyr2latCompression),
|
|
subtitle: Text(context.l10n.channels_cyr2latCompressionDscr),
|
|
value: cyr2latEnabled,
|
|
onChanged: (value) {
|
|
connector.setContactCyr2LatEnabled(
|
|
contact.publicKeyHex,
|
|
value,
|
|
);
|
|
connector.setContactSmazEnabled(
|
|
contact.publicKeyHex,
|
|
false,
|
|
);
|
|
setDialogState(() {
|
|
cyr2latEnabled = value;
|
|
if (cyr2latEnabled) {
|
|
smazEnabled = false;
|
|
}
|
|
});
|
|
},
|
|
),
|
|
if (cyr2latEnabled) ...[
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(0, 8, 0, 8),
|
|
child: DropdownButtonFormField<String>(
|
|
initialValue: selectedCyr2LatProfileId,
|
|
decoration: InputDecoration(
|
|
labelText:
|
|
context.l10n.channels_cyr2latSettingsSubheading,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
items: appSettingsService.settings.cyr2latProfiles.map((
|
|
profile,
|
|
) {
|
|
return DropdownMenuItem(
|
|
value: profile.id,
|
|
child: Text(profile.name),
|
|
);
|
|
}).toList(),
|
|
onChanged: (value) {
|
|
connector.setContactCyr2LatProfileId(
|
|
contact.publicKeyHex,
|
|
value,
|
|
);
|
|
setDialogState(() {
|
|
selectedCyr2LatProfileId = value;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
],
|
|
const Divider(height: 8),
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(context.l10n.contact_teleBase),
|
|
subtitle: Text(context.l10n.contact_teleBaseSubtitle),
|
|
value: teleBaseEnabled,
|
|
onChanged: (value) {
|
|
setDialogState(() => teleBaseEnabled = value);
|
|
},
|
|
),
|
|
const Divider(height: 8),
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(context.l10n.contact_teleLoc),
|
|
subtitle: Text(context.l10n.contact_teleLocSubtitle),
|
|
value: teleLocEnabled,
|
|
onChanged: (value) {
|
|
setDialogState(() => teleLocEnabled = value);
|
|
},
|
|
),
|
|
const Divider(height: 8),
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(context.l10n.contact_teleEnv),
|
|
subtitle: Text(context.l10n.contact_teleEnvSubtitle),
|
|
value: teleEnvEnabled,
|
|
onChanged: (value) {
|
|
setDialogState(() => teleEnvEnabled = value);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
connector.setContactFlags(
|
|
contact,
|
|
teleBase: teleBaseEnabled,
|
|
teleLoc: teleLocEnabled,
|
|
teleEnv: teleEnvEnabled,
|
|
);
|
|
Navigator.pop(context);
|
|
},
|
|
child: Text(context.l10n.common_close),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildInfoRow(String label, String value) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 80,
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
Expanded(child: SelectableText(value)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatContactLastMessage(DateTime timestamp) {
|
|
final diff = DateTime.now().difference(timestamp);
|
|
if (diff.isNegative || diff.inMinutes < 5) {
|
|
return context.l10n.contacts_lastSeenNow;
|
|
}
|
|
if (diff.inMinutes < 60) {
|
|
return context.l10n.contacts_lastSeenMinsAgo(diff.inMinutes);
|
|
}
|
|
if (diff.inHours < 24) {
|
|
final hours = diff.inHours;
|
|
return hours == 1
|
|
? context.l10n.contacts_lastSeenHourAgo
|
|
: context.l10n.contacts_lastSeenHoursAgo(hours);
|
|
}
|
|
final days = diff.inDays;
|
|
return days == 1
|
|
? context.l10n.contacts_lastSeenDayAgo
|
|
: context.l10n.contacts_lastSeenDaysAgo(days);
|
|
}
|
|
|
|
void _openChat(BuildContext context, Contact contact) {
|
|
final connector = context.read<MeshCoreConnector>();
|
|
final unread = connector.getUnreadCountForContactKey(contact.publicKeyHex);
|
|
connector.markContactRead(contact.publicKeyHex);
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) =>
|
|
ChatScreen(contact: contact, initialUnreadCount: unread),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openMessagePath(Message message, Contact contact) {
|
|
final connector = context.read<MeshCoreConnector>();
|
|
final fourByteHex = message.fourByteRoomContactKey
|
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
|
.join()
|
|
.toUpperCase();
|
|
final String senderName;
|
|
if (message.isOutgoing) {
|
|
senderName = connector.selfName ?? context.l10n.chat_me;
|
|
} else if (_resolveContact(connector).type == advTypeRoom) {
|
|
// An unresolved author leaves `contact` as the room server itself; show
|
|
// only the prefix rather than mislabeling the post with the room's name.
|
|
senderName = contact.type == advTypeRoom
|
|
? "[$fourByteHex]"
|
|
: "${contact.name} [$fourByteHex]";
|
|
} else {
|
|
senderName = _resolveContact(connector).name;
|
|
}
|
|
final pathMessage = ChannelMessage(
|
|
senderKey: null,
|
|
senderName: senderName,
|
|
text: message.text,
|
|
timestamp: message.timestamp,
|
|
isOutgoing: message.isOutgoing,
|
|
status: ChannelMessageStatus.sent,
|
|
repeatCount: 0,
|
|
pathLength: message.pathLength,
|
|
pathBytes: message.pathBytes,
|
|
);
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => ChannelMessagePathScreen(message: pathMessage),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showMessageActions(Message message, Contact contact) {
|
|
final translationService = context.read<TranslationService>();
|
|
final canTranslateMessage =
|
|
translationService.canTranslateIncoming(
|
|
text: message.text,
|
|
isCli: message.isCli,
|
|
isOutgoing: message.isOutgoing,
|
|
) &&
|
|
(message.translatedText?.trim().isEmpty ?? true);
|
|
|
|
showMeshSheet(
|
|
context,
|
|
builder: (sheetContext) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
BottomSheetHeader(
|
|
title: message.text.length > 40
|
|
? '${message.text.substring(0, 40)}…'
|
|
: message.text,
|
|
),
|
|
// Can't react to your own messages
|
|
if (!message.isOutgoing)
|
|
ListTile(
|
|
leading: const Icon(Icons.add_reaction_outlined),
|
|
title: Text(context.l10n.chat_addReaction),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
_showEmojiPicker(message, contact);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.route),
|
|
title: Text(context.l10n.chat_path),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
_openMessagePath(message, contact);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.copy),
|
|
title: Text(context.l10n.common_copy),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
_copyMessageText(message.text);
|
|
},
|
|
),
|
|
if (canTranslateMessage)
|
|
ListTile(
|
|
leading: const Icon(Icons.translate),
|
|
title: Text(context.l10n.translation_translateMessage),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
unawaited(
|
|
context.read<MeshCoreConnector>().translateContactMessage(
|
|
widget.contact.publicKeyHex,
|
|
message,
|
|
manualTranslation: true,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
if (!message.isOutgoing)
|
|
ListTile(
|
|
leading: const Icon(Icons.mark_chat_unread_outlined),
|
|
title: Text(context.l10n.chat_markAsUnread),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
_markAsUnread(message);
|
|
},
|
|
),
|
|
if (message.isOutgoing && message.status == MessageStatus.failed)
|
|
ListTile(
|
|
leading: const Icon(Icons.refresh),
|
|
title: Text(context.l10n.common_retry),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
_retryMessage(message);
|
|
},
|
|
),
|
|
if (_resolveContact(context.read<MeshCoreConnector>()).type ==
|
|
advTypeRoom)
|
|
ListTile(
|
|
leading: const Icon(Icons.chat),
|
|
title: Text(context.l10n.contacts_openChat),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
_openChat(context, contact);
|
|
},
|
|
),
|
|
const Divider(height: 1),
|
|
ListTile(
|
|
leading: Icon(
|
|
Icons.delete_outline,
|
|
color: Theme.of(context).colorScheme.error,
|
|
),
|
|
title: Text(
|
|
context.l10n.common_delete,
|
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
|
),
|
|
onTap: () async {
|
|
Navigator.pop(sheetContext);
|
|
await _deleteMessage(message);
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _copyMessageText(String text) {
|
|
Clipboard.setData(ClipboardData(text: text));
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(context.l10n.chat_messageCopied),
|
|
);
|
|
}
|
|
|
|
Future<void> _deleteMessage(Message message) async {
|
|
await context.read<MeshCoreConnector>().deleteMessage(message);
|
|
if (!mounted) return;
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(context.l10n.chat_messageDeleted),
|
|
);
|
|
}
|
|
|
|
void _retryMessage(Message message) {
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
connector.resendMessage(_resolveContact(connector), message);
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(context.l10n.chat_retryingMessage),
|
|
);
|
|
}
|
|
|
|
void _showEmojiPicker(Message message, Contact senderContact) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (context) => EmojiPicker(
|
|
onEmojiSelected: (emoji) {
|
|
_sendReaction(message, senderContact, emoji);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _sendReaction(Message message, Contact senderContact, String emoji) {
|
|
final connector = context.read<MeshCoreConnector>();
|
|
final emojiIndex = ReactionHelper.emojiToIndex(emoji);
|
|
if (emojiIndex == null) return; // Unknown emoji, skip
|
|
final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000;
|
|
|
|
// For room servers, include sender name (like channels) since multiple users
|
|
// For 1:1 chats, sender is implicit (null)
|
|
final liveContact = _resolveContact(connector);
|
|
final senderName = liveContact.type == advTypeRoom
|
|
? senderContact.name
|
|
: null;
|
|
final hash = ReactionHelper.computeReactionHash(
|
|
timestampSecs,
|
|
senderName,
|
|
message.text,
|
|
);
|
|
final reactionText = ReactionHelper.encodeReaction(hash, emojiIndex);
|
|
connector.sendMessage(_resolveContact(connector), reactionText);
|
|
}
|
|
}
|
|
|
|
class _MessageBubble extends StatelessWidget {
|
|
final Message message;
|
|
final String senderName;
|
|
final VoidCallback? onLongPress;
|
|
final void Function(Message message, String emoji)? onRetryReaction;
|
|
final double textScale;
|
|
final String sourceId;
|
|
|
|
const _MessageBubble({
|
|
required this.message,
|
|
required this.senderName,
|
|
required this.sourceId,
|
|
required this.textScale,
|
|
this.onLongPress,
|
|
this.onRetryReaction,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final settingsService = context.watch<AppSettingsService>();
|
|
final enableTracing = settingsService.settings.enableMessageTracing;
|
|
final isOutgoing = message.isOutgoing;
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final gifId = GifHelper.parseGif(message.text);
|
|
final poi = parseMarkerText(message.text);
|
|
final isFailed = message.status == MessageStatus.failed;
|
|
|
|
// Bubble colors — outgoing uses MeshPalette.me / meBorder / meInk.
|
|
final bubbleColor = isFailed
|
|
? scheme.errorContainer
|
|
: isOutgoing
|
|
? MeshPalette.me
|
|
: scheme.surfaceContainerLow;
|
|
final bubbleBorder = isFailed
|
|
? scheme.error
|
|
: isOutgoing
|
|
? MeshPalette.meBorder
|
|
: scheme.outlineVariant;
|
|
final textColor = isFailed
|
|
? scheme.onErrorContainer
|
|
: isOutgoing
|
|
? MeshPalette.meInk
|
|
: scheme.onSurface;
|
|
final metaColor = textColor.withValues(alpha: 0.65);
|
|
const bodyFontSize = 14.0;
|
|
|
|
// Asymmetric radius: outgoing — top-left large, others also large; outgoing bottom-right tight.
|
|
final borderRadius = isOutgoing
|
|
? const BorderRadius.only(
|
|
topLeft: Radius.circular(MeshRadii.lg),
|
|
topRight: Radius.circular(MeshRadii.lg),
|
|
bottomLeft: Radius.circular(MeshRadii.lg),
|
|
bottomRight: Radius.circular(MeshRadii.xs),
|
|
)
|
|
: const BorderRadius.only(
|
|
topLeft: Radius.circular(MeshRadii.xs),
|
|
topRight: Radius.circular(MeshRadii.lg),
|
|
bottomLeft: Radius.circular(MeshRadii.lg),
|
|
bottomRight: Radius.circular(MeshRadii.lg),
|
|
);
|
|
|
|
// Do not strip room-server author bytes here: the parser stores them in
|
|
// fourByteRoomContactKey, so message.text is safe to render as-is.
|
|
final messageText = message.text;
|
|
final translatedDisplayText =
|
|
message.translatedText != null &&
|
|
message.translatedText!.trim().isNotEmpty
|
|
? message.translatedText!.trim()
|
|
: messageText;
|
|
final originalDisplayText = isOutgoing
|
|
? message.originalText
|
|
: (translatedDisplayText != messageText ? messageText : null);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
|
child: Column(
|
|
crossAxisAlignment: isOutgoing
|
|
? CrossAxisAlignment.end
|
|
: CrossAxisAlignment.start,
|
|
children: [
|
|
LayoutBuilder(
|
|
builder: (context, constraints) => GestureDetector(
|
|
onLongPress: onLongPress,
|
|
onSecondaryTapUp: PlatformInfo.isDesktop
|
|
? (_) => onLongPress?.call()
|
|
: null,
|
|
child: Row(
|
|
mainAxisAlignment: isOutgoing
|
|
? MainAxisAlignment.end
|
|
: MainAxisAlignment.start,
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
if (!isOutgoing) ...[
|
|
_buildAvatar(senderName),
|
|
const SizedBox(width: 6),
|
|
],
|
|
Flexible(
|
|
child: Container(
|
|
padding: gifId != null
|
|
? const EdgeInsets.all(4)
|
|
: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 8,
|
|
),
|
|
constraints: BoxConstraints(
|
|
maxWidth: constraints.maxWidth * 0.72,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: bubbleColor,
|
|
borderRadius: borderRadius,
|
|
border: Border.all(color: bubbleBorder, width: 1),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (!isOutgoing) ...[
|
|
Padding(
|
|
padding: gifId != null
|
|
? const EdgeInsets.only(
|
|
left: 8,
|
|
top: 4,
|
|
bottom: 4,
|
|
)
|
|
: EdgeInsets.zero,
|
|
child: Text(
|
|
senderName,
|
|
style: MeshTheme.mono(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
color: _colorForName(senderName),
|
|
),
|
|
),
|
|
),
|
|
if (gifId == null) const SizedBox(height: 2),
|
|
],
|
|
if (poi != null)
|
|
_buildPoiMessage(
|
|
context,
|
|
poi,
|
|
textColor,
|
|
metaColor,
|
|
textScale,
|
|
senderName,
|
|
)
|
|
else if (gifId != null)
|
|
Stack(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: GifMessage(
|
|
url:
|
|
'https://media.giphy.com/media/$gifId/giphy.gif',
|
|
backgroundColor: Colors.transparent,
|
|
fallbackTextColor: textColor.withValues(
|
|
alpha: 0.7,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
)
|
|
else
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Flexible(
|
|
child: TranslatedMessageContent(
|
|
displayText: translatedDisplayText,
|
|
originalText: originalDisplayText,
|
|
style: TextStyle(
|
|
color: textColor,
|
|
fontSize: bodyFontSize * textScale,
|
|
),
|
|
originalStyle: TextStyle(
|
|
color: textColor.withValues(alpha: 0.72),
|
|
fontSize: bodyFontSize * textScale,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (enableTracing &&
|
|
isOutgoing &&
|
|
message.retryCount > 0) ...[
|
|
const SizedBox(height: 3),
|
|
Padding(
|
|
padding: gifId != null
|
|
? const EdgeInsets.symmetric(horizontal: 8)
|
|
: EdgeInsets.zero,
|
|
child: Text(
|
|
context.l10n.chat_retryCount(
|
|
message.retryCount,
|
|
context
|
|
.read<AppSettingsService>()
|
|
.settings
|
|
.maxMessageRetries,
|
|
),
|
|
style: MeshTheme.mono(
|
|
fontSize: 9.5 * textScale,
|
|
color: metaColor,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 3),
|
|
// Meta row: timestamp + status icon + optional tracing
|
|
Padding(
|
|
padding: gifId != null
|
|
? const EdgeInsets.only(
|
|
left: 8,
|
|
right: 8,
|
|
bottom: 4,
|
|
)
|
|
: EdgeInsets.zero,
|
|
child: Wrap(
|
|
spacing: 4,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
Text(
|
|
_formatTime(message.timestamp),
|
|
style: MeshTheme.mono(
|
|
fontSize: 10 * textScale,
|
|
color: metaColor,
|
|
),
|
|
),
|
|
if (isOutgoing) ...[
|
|
const SizedBox(width: 2),
|
|
MessageStatusIcon(
|
|
size: 12 * textScale,
|
|
onColor: metaColor,
|
|
isAcked:
|
|
message.status ==
|
|
MessageStatus.delivered,
|
|
isPending:
|
|
message.status == MessageStatus.pending,
|
|
isFailed:
|
|
message.status == MessageStatus.failed,
|
|
),
|
|
],
|
|
if (enableTracing &&
|
|
message.tripTimeMs != null &&
|
|
message.status ==
|
|
MessageStatus.delivered) ...[
|
|
const SizedBox(width: 2),
|
|
Icon(
|
|
Icons.speed,
|
|
size: 10 * textScale,
|
|
color: isOutgoing
|
|
? metaColor
|
|
: scheme.tertiary,
|
|
),
|
|
Text(
|
|
'${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s',
|
|
style: MeshTheme.mono(
|
|
fontSize: 9 * textScale,
|
|
color: isOutgoing
|
|
? metaColor
|
|
: scheme.tertiary,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (message.reactions.isNotEmpty) ...[
|
|
const SizedBox(height: 4),
|
|
Padding(
|
|
padding: EdgeInsets.only(left: isOutgoing ? 0 : 42),
|
|
child: _buildReactionsDisplay(context, message, scheme),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPoiMessage(
|
|
BuildContext context,
|
|
MarkerPayload poi,
|
|
Color textColor,
|
|
Color metaColor,
|
|
double textScale,
|
|
String senderName, {
|
|
Widget? trailing,
|
|
}) {
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
IconButton(
|
|
icon: Icon(Icons.location_on_outlined, color: textColor),
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
|
|
onPressed: () async {
|
|
final selfName =
|
|
context.read<MeshCoreConnector>().selfName ??
|
|
context.l10n.chat_me;
|
|
final fromName = message.isOutgoing ? selfName : senderName;
|
|
final key = buildSharedMarkerKey(
|
|
sourceId: sourceId,
|
|
label: poi.label,
|
|
fromName: fromName,
|
|
flags: poi.flags,
|
|
isChannel: false,
|
|
);
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => MapScreen(
|
|
highlightPosition: poi.position,
|
|
highlightLabel: poi.label,
|
|
highlightMarkerKey: key,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
context.l10n.chat_poiShared,
|
|
style: TextStyle(
|
|
color: textColor,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 14 * textScale,
|
|
),
|
|
),
|
|
if (poi.label.isNotEmpty)
|
|
Text(
|
|
poi.label,
|
|
style: TextStyle(color: metaColor, fontSize: 12 * textScale),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (trailing != null) ...[const SizedBox(width: 4), trailing],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildReactionsDisplay(
|
|
BuildContext context,
|
|
Message message,
|
|
ColorScheme scheme,
|
|
) {
|
|
return Wrap(
|
|
spacing: 6,
|
|
runSpacing: 6,
|
|
children: message.reactions.entries.map((entry) {
|
|
final emoji = entry.key;
|
|
final count = entry.value;
|
|
final status = message.reactionStatuses[emoji];
|
|
final isPending =
|
|
status == MessageStatus.pending || status == MessageStatus.sent;
|
|
final isFailed = status == MessageStatus.failed;
|
|
|
|
return GestureDetector(
|
|
onTap: isFailed && onRetryReaction != null
|
|
? () => onRetryReaction!(message, emoji)
|
|
: null,
|
|
child: Opacity(
|
|
opacity: isPending ? 0.5 : 1.0,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: isFailed
|
|
? scheme.errorContainer
|
|
: scheme.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(MeshRadii.pill),
|
|
border: Border.all(
|
|
color: isFailed ? scheme.error : scheme.outlineVariant,
|
|
width: 1,
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
emoji,
|
|
style: MeshTheme.emoji(fontSize: 16),
|
|
textHeightBehavior: const TextHeightBehavior(
|
|
applyHeightToFirstAscent: false,
|
|
applyHeightToLastDescent: false,
|
|
),
|
|
),
|
|
if (count > 1) ...[
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'$count',
|
|
style: MeshTheme.mono(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
color: scheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
if (isPending) ...[
|
|
const SizedBox(width: 2),
|
|
SizedBox(
|
|
width: 8,
|
|
height: 8,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 1.5,
|
|
color: scheme.primary,
|
|
),
|
|
),
|
|
],
|
|
if (isFailed) ...[
|
|
const SizedBox(width: 2),
|
|
Icon(Icons.replay, size: 10, color: scheme.error),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
|
|
Widget _buildAvatar(String senderName) {
|
|
return AvatarCircle(name: senderName, size: 32);
|
|
}
|
|
|
|
String _formatTime(DateTime time) {
|
|
final hour = time.hour.toString().padLeft(2, '0');
|
|
final minute = time.minute.toString().padLeft(2, '0');
|
|
return '$hour:$minute';
|
|
}
|
|
}
|
|
|
|
/// Deterministic name-to-hue mapping consistent with [AvatarCircle].
|
|
Color _colorForName(String name) {
|
|
const hues = [
|
|
MeshPalette.blue,
|
|
MeshPalette.magenta,
|
|
MeshPalette.signal,
|
|
MeshPalette.warn,
|
|
Color(0xFF8FA8F0),
|
|
Color(0xFF6FD9CE),
|
|
];
|
|
var h = 0;
|
|
for (final c in name.codeUnits) {
|
|
h = (h * 31 + c) & 0x7fffffff;
|
|
}
|
|
return hues[h % hues.length];
|
|
}
|