meshcore-open/lib/screens/channel_chat_screen.dart
zjs81 c320378be1
Refactor unread message tracking and implement channel caching (#126)
* Refactor unread message tracking and implement channel caching

* formatted files
2026-02-04 20:34:03 -07:00

1015 lines
35 KiB
Dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../helpers/chat_scroll_controller.dart';
import '../connector/meshcore_protocol.dart';
import '../helpers/link_handler.dart';
import '../helpers/reaction_helper.dart';
import '../helpers/utf8_length_limiter.dart';
import '../l10n/l10n.dart';
import '../models/channel.dart';
import '../models/channel_message.dart';
import '../utils/emoji_utils.dart';
import '../widgets/emoji_picker.dart';
import '../widgets/gif_message.dart';
import '../widgets/jump_to_bottom_button.dart';
import '../widgets/gif_picker.dart';
import 'channel_message_path_screen.dart';
import 'map_screen.dart';
class ChannelChatScreen extends StatefulWidget {
final Channel channel;
const ChannelChatScreen({super.key, required this.channel});
@override
State<ChannelChatScreen> createState() => _ChannelChatScreenState();
}
class _ChannelChatScreenState extends State<ChannelChatScreen> {
final TextEditingController _textController = TextEditingController();
final ChatScrollController _scrollController = ChatScrollController();
final FocusNode _textFieldFocusNode = FocusNode();
ChannelMessage? _replyingToMessage;
final Map<String, GlobalKey> _messageKeys = {};
bool _isLoadingOlder = false;
MeshCoreConnector? _connector;
@override
void initState() {
super.initState();
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
_scrollController.onScrollNearTop = _loadOlderMessages;
SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_connector = context.read<MeshCoreConnector>();
_connector?.setActiveChannel(widget.channel.index);
});
}
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.loadOlderChannelMessages(widget.channel.index);
if (mounted) {
setState(() => _isLoadingOlder = false);
}
}
@override
void dispose() {
_connector?.setActiveChannel(null);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose();
_textController.dispose();
_scrollController.dispose();
super.dispose();
}
void _setReplyingTo(ChannelMessage message) {
setState(() {
_replyingToMessage = message;
});
}
void _cancelReply() {
setState(() {
_replyingToMessage = null;
});
}
Future<void> _scrollToMessage(String messageId) async {
final key = _messageKeys[messageId];
if (key == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.chat_originalMessageNotFound),
duration: const Duration(seconds: 2),
),
);
return;
}
final targetContext = key.currentContext;
if (targetContext == null) return;
await Scrollable.ensureVisible(
targetContext,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
alignment: 0.3,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Row(
children: [
Icon(
widget.channel.isPublicChannel ? Icons.public : Icons.tag,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.channel.name.isEmpty
? context.l10n.channels_channelIndex(
widget.channel.index,
)
: widget.channel.name,
style: const TextStyle(fontSize: 16),
),
Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final unreadCount = connector
.getUnreadCountForChannelIndex(widget.channel.index);
final privacy = widget.channel.isPublicChannel
? context.l10n.channels_public
: context.l10n.channels_private;
return Text(
'$privacy${context.l10n.chat_unread(unreadCount)}',
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
);
},
),
],
),
),
],
),
centerTitle: false,
),
body: SafeArea(
top: false,
child: Column(
children: [
Expanded(
child: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final messages = connector.getChannelMessages(widget.channel);
if (messages.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
widget.channel.isPublicChannel
? Icons.public
: Icons.tag,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
context.l10n.chat_noMessages,
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
context.l10n.chat_sendMessageToStart,
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
),
),
],
),
);
}
// 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((_) {
_scrollController.scrollToBottomIfAtBottom();
});
return Stack(
children: [
ListView.builder(
reverse: true, // List grows from bottom up
controller: _scrollController,
padding: const EdgeInsets.all(8),
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;
final message = reversedMessages[messageIndex];
if (!_messageKeys.containsKey(message.messageId)) {
_messageKeys[message.messageId] = GlobalKey();
}
return Container(
key: _messageKeys[message.messageId]!,
child: _buildMessageBubble(message),
);
},
),
JumpToBottomButton(scrollController: _scrollController),
],
);
},
),
),
_buildMessageComposer(),
],
),
),
);
}
Widget _buildMessageBubble(ChannelMessage message) {
final isOutgoing = message.isOutgoing;
final gifId = _parseGifId(message.text);
final poi = _parsePoiMessage(message.text);
final displayPath = message.pathBytes.isNotEmpty
? message.pathBytes
: (message.pathVariants.isNotEmpty
? message.pathVariants.first
: Uint8List(0));
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Column(
crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: isOutgoing
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
_buildAvatar(message.senderName),
const SizedBox(width: 8),
],
Flexible(
child: GestureDetector(
onTap: () => _showMessagePathInfo(message),
onLongPress: () => _showMessageActions(message),
child: Container(
padding: gifId != null
? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65,
),
decoration: BoxDecoration(
color: isOutgoing
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
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(
message.senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
),
if (gifId == null) const SizedBox(height: 4),
],
if (message.replyToMessageId != null) ...[
_buildReplyPreview(message),
const SizedBox(height: 8),
],
if (poi != null)
_buildPoiMessage(context, poi, isOutgoing)
else if (gifId != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Colors.transparent,
fallbackTextColor: isOutgoing
? Theme.of(context)
.colorScheme
.onPrimaryContainer
.withValues(alpha: 0.7)
: Theme.of(context).colorScheme.onSurface
.withValues(alpha: 0.6),
),
)
else
Linkify(
text: message.text,
style: const TextStyle(fontSize: 14),
linkStyle: const TextStyle(
fontSize: 14,
color: Colors.green,
decoration: TextDecoration.underline,
),
options: const LinkifyOptions(
humanize: false,
defaultToHttps: false,
),
linkifiers: const [UrlLinkifier()],
onOpen: (link) =>
LinkHandler.handleLinkTap(context, link.url),
),
if (displayPath.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
'via ${_formatPathPrefixes(displayPath)}',
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
),
],
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.only(
left: 8,
right: 8,
bottom: 4,
)
: EdgeInsets.zero,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message.timestamp),
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
if (message.repeatCount > 0) ...[
const SizedBox(width: 6),
Icon(
Icons.repeat,
size: 12,
color: Colors.grey[600],
),
const SizedBox(width: 2),
Text(
'${message.repeatCount}',
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
],
if (isOutgoing) ...[
const SizedBox(width: 4),
Icon(
message.status == ChannelMessageStatus.sent
? Icons.check
: message.status ==
ChannelMessageStatus.pending
? Icons.schedule
: Icons.error_outline,
size: 14,
color:
message.status ==
ChannelMessageStatus.failed
? Colors.red
: Colors.grey[600],
),
],
],
),
),
],
),
),
),
),
],
),
if (message.reactions.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
child: _buildReactionsDisplay(message),
),
],
],
),
);
}
Widget _buildReplyPreview(ChannelMessage message) {
final connector = context.read<MeshCoreConnector>();
final isOwnNode = message.replyToSenderName == connector.selfName;
final replyText = message.replyToText ?? '';
final colorScheme = Theme.of(context).colorScheme;
final previewTextColor = colorScheme.onSurface.withValues(alpha: 0.7);
final gifId = _parseGifId(replyText);
final poi = _parsePoiMessage(replyText);
Widget contentPreview;
if (gifId != null) {
contentPreview = ClipRRect(
borderRadius: BorderRadius.circular(4),
child: GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: colorScheme.surfaceContainerHighest,
fallbackTextColor: previewTextColor,
maxSize: 80,
),
);
} else if (poi != null) {
contentPreview = Row(
children: [
Icon(Icons.location_on_outlined, size: 14, color: previewTextColor),
const SizedBox(width: 4),
Text(
context.l10n.chat_location,
style: TextStyle(fontSize: 12, color: previewTextColor),
),
],
);
} else {
contentPreview = Text(
replyText,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: previewTextColor,
fontStyle: FontStyle.italic,
),
);
}
return GestureDetector(
onTap: () => _scrollToMessage(message.replyToMessageId!),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border(
left: BorderSide(color: colorScheme.primary, width: 3),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.chat_replyTo(message.replyToSenderName ?? ''),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: isOwnNode
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 2),
contentPreview,
],
),
),
);
}
Widget _buildReactionsDisplay(ChannelMessage message) {
return Wrap(
spacing: 6,
runSpacing: 6,
children: message.reactions.entries.map((entry) {
final emoji = entry.key;
final count = entry.value;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(emoji, style: const TextStyle(fontSize: 16)),
if (count > 1) ...[
const SizedBox(width: 4),
Text(
'$count',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
),
],
],
),
);
}).toList(),
);
}
String? _parseGifId(String text) {
final trimmed = text.trim();
final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed);
return match?.group(1);
}
_PoiInfo? _parsePoiMessage(String text) {
final trimmed = text.trim();
final match = RegExp(
r'm:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|',
).firstMatch(trimmed);
if (match == null) return null;
final lat = double.tryParse(match.group(1) ?? '');
final lon = double.tryParse(match.group(2) ?? '');
if (lat == null || lon == null) return null;
final label = match.group(3) ?? '';
return _PoiInfo(lat: lat, lon: lon, label: label);
}
Widget _buildPoiMessage(BuildContext context, _PoiInfo poi, bool isOutgoing) {
final colorScheme = Theme.of(context).colorScheme;
final textColor = isOutgoing
? colorScheme.onPrimaryContainer
: colorScheme.onSurface;
final metaColor = textColor.withValues(alpha: 0.7);
final channelColor = widget.channel.isPublicChannel
? Colors.orange
: Colors.blue;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
icon: Icon(Icons.location_on_outlined, color: channelColor),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MapScreen(
highlightPosition: LatLng(poi.lat, poi.lon),
highlightLabel: poi.label,
),
),
);
},
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.chat_poiShared,
style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
),
if (poi.label.isNotEmpty)
Text(
poi.label,
style: TextStyle(color: metaColor, fontSize: 12),
),
],
),
),
],
);
}
void _showGifPicker(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => GifPicker(
onGifSelected: (gifId) {
_textController.text = 'g:$gifId';
},
),
);
}
Widget _buildAvatar(String senderName) {
final initial = _getFirstCharacterOrEmoji(senderName);
final color = _getColorForName(senderName);
return CircleAvatar(
radius: 18,
backgroundColor: color.withValues(alpha: 0.2),
child: Text(
initial,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: color,
),
),
);
}
String _getFirstCharacterOrEmoji(String name) {
if (name.isEmpty) return '?';
final emoji = firstEmoji(name);
if (emoji != null) return emoji;
final runes = name.runes.toList();
if (runes.isEmpty) return '?';
return String.fromCharCode(runes[0]).toUpperCase();
}
Color _getColorForName(String name) {
// Generate a consistent color based on the name hash
final hash = name.hashCode;
final colors = [
Colors.blue,
Colors.green,
Colors.orange,
Colors.purple,
Colors.pink,
Colors.teal,
Colors.indigo,
Colors.cyan,
Colors.amber,
Colors.deepOrange,
];
return colors[hash.abs() % colors.length];
}
Widget _buildReplyBanner() {
final message = _replyingToMessage!;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
border: Border(
bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1),
),
),
child: Row(
children: [
Icon(
Icons.reply,
size: 18,
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.chat_replyingTo(message.senderName),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
),
Text(
message.text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: Theme.of(
context,
).colorScheme.onSecondaryContainer.withValues(alpha: 0.7),
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: _cancelReply,
color: Theme.of(context).colorScheme.onSecondaryContainer,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
);
}
Widget _buildMessageComposer() {
final connector = context.watch<MeshCoreConnector>();
final maxBytes = maxChannelMessageBytes(connector.selfName);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_replyingToMessage != null) _buildReplyBanner(),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: context.l10n.chat_sendGif,
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = _parseGifId(value.text);
if (gifId != null) {
return Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
fallbackTextColor: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
maxSize: 160,
),
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => _textController.clear(),
),
],
);
}
return TextField(
controller: _textController,
focusNode: _textFieldFocusNode,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes),
],
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
maxLines: null,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
);
},
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.send),
onPressed: _sendMessage,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
],
);
}
void _sendMessage() {
final text = _textController.text.trim();
if (text.isEmpty) return;
final connector = context.read<MeshCoreConnector>();
String messageText = text;
if (_replyingToMessage != null) {
messageText = '@[${_replyingToMessage!.senderName}] $text';
}
final maxBytes = maxChannelMessageBytes(connector.selfName);
if (utf8.encode(messageText).length > maxBytes) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_messageTooLong(maxBytes))),
);
return;
}
connector.sendChannelMessage(widget.channel, messageText);
_textController.clear();
_cancelReply();
}
String _formatTime(DateTime time) {
final now = DateTime.now();
final diff = now.difference(time);
if (diff.inDays > 0) {
return '${time.day}/${time.month} ${time.hour}:${time.minute.toString().padLeft(2, '0')}';
} else {
return '${time.hour}:${time.minute.toString().padLeft(2, '0')}';
}
}
void _showMessagePathInfo(ChannelMessage message) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChannelMessagePathScreen(message: message),
),
);
}
void _showMessageActions(ChannelMessage message) {
showModalBottomSheet(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.reply),
title: Text(context.l10n.chat_reply),
onTap: () {
Navigator.pop(sheetContext);
_setReplyingTo(message);
},
),
// 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);
},
),
ListTile(
leading: const Icon(Icons.copy),
title: Text(context.l10n.common_copy),
onTap: () {
Navigator.pop(sheetContext);
_copyMessageText(message.text);
},
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: Text(context.l10n.common_delete),
onTap: () async {
Navigator.pop(sheetContext);
await _deleteMessage(message);
},
),
ListTile(
leading: const Icon(Icons.close),
title: Text(context.l10n.common_cancel),
onTap: () => Navigator.pop(sheetContext),
),
],
),
),
);
}
void _showEmojiPicker(ChannelMessage message) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => EmojiPicker(
onEmojiSelected: (emoji) {
_sendReaction(message, emoji);
},
),
);
}
void _sendReaction(ChannelMessage message, 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;
final hash = ReactionHelper.computeReactionHash(
timestampSecs,
message.senderName,
message.text,
);
final reactionText = 'r:$hash:$emojiIndex';
connector.sendChannelMessage(widget.channel, reactionText);
}
void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageCopied)));
}
Future<void> _deleteMessage(ChannelMessage message) async {
await context.read<MeshCoreConnector>().deleteChannelMessage(message);
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageDeleted)));
}
String _formatPathPrefixes(Uint8List pathBytes) {
return pathBytes
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(',');
}
}
class _PoiInfo {
final double lat;
final double lon;
final String label;
const _PoiInfo({required this.lat, required this.lon, required this.label});
}