mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
- Implemented ChatScrollController to manage scroll behavior and visibility of jump-to-bottom button. - Added functionality to automatically scroll to the bottom when the keyboard opens. - Created JumpToBottomButton widget that appears when the user scrolls up, allowing quick navigation back to the bottom of the chat.
967 lines
33 KiB
Dart
967 lines
33 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/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;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
|
|
_scrollController.onScrollNearTop = _loadOlderMessages;
|
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
context.read<MeshCoreConnector>().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() {
|
|
context.read<MeshCoreConnector>().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);
|
|
},
|
|
),
|
|
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>();
|
|
// Send reaction with full messageId to find target, but parser will extract
|
|
// lightweight reactionKey (timestamp_senderPrefix) for deduplication
|
|
final reactionText = 'r:${message.messageId}:$emoji';
|
|
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,
|
|
});
|
|
}
|