mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
1015 lines
35 KiB
Dart
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});
|
|
}
|