mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-16 22:00:50 +10:00
Merge branch 'main' into community-#-names
This commit is contained in:
@@ -471,6 +471,10 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
return context.l10n.appSettings_languageSk;
|
||||
case 'bg':
|
||||
return context.l10n.appSettings_languageBg;
|
||||
case 'ru':
|
||||
return context.l10n.appSettings_languageRu;
|
||||
case 'uk':
|
||||
return context.l10n.appSettings_languageUk;
|
||||
default:
|
||||
return context.l10n.appSettings_languageSystem;
|
||||
}
|
||||
@@ -547,6 +551,14 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
title: Text(context.l10n.appSettings_languageBg),
|
||||
value: 'bg',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageRu),
|
||||
value: 'ru',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageUk),
|
||||
value: 'uk',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,11 +3,15 @@ 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';
|
||||
@@ -15,6 +19,7 @@ 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';
|
||||
@@ -33,42 +38,51 @@ class ChannelChatScreen extends StatefulWidget {
|
||||
|
||||
class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
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);
|
||||
|
||||
// Scroll to bottom when opening channel chat - use SchedulerBinding for next frame
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 _scrollToBottom() {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _setReplyingTo(ChannelMessage message) {
|
||||
setState(() {
|
||||
_replyingToMessage = message;
|
||||
@@ -153,10 +167,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
builder: (context, connector, child) {
|
||||
final messages = connector.getChannelMessages(widget.channel);
|
||||
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToBottom();
|
||||
});
|
||||
|
||||
if (messages.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
@@ -190,20 +200,51 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
if (!_messageKeys.containsKey(message.messageId)) {
|
||||
_messageKeys[message.messageId] = GlobalKey();
|
||||
}
|
||||
return Container(
|
||||
key: _messageKeys[message.messageId]!,
|
||||
child: _buildMessageBubble(message),
|
||||
);
|
||||
},
|
||||
// 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -241,7 +282,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
onTap: () => _showMessagePathInfo(message),
|
||||
onLongPress: () => _showMessageActions(message),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.all(4)
|
||||
: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.65,
|
||||
),
|
||||
@@ -255,15 +298,20 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
Text(
|
||||
message.senderName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (gifId == null) const SizedBox(height: 4),
|
||||
],
|
||||
if (message.replyToMessageId != null) ...[
|
||||
_buildReplyPreview(message),
|
||||
@@ -272,60 +320,84 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
if (poi != null)
|
||||
_buildPoiMessage(context, poi, isOutgoing)
|
||||
else if (gifId != null)
|
||||
GifMessage(
|
||||
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
fallbackTextColor: isOutgoing
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.7)
|
||||
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
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
|
||||
Text(
|
||||
message.text,
|
||||
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),
|
||||
Text(
|
||||
'via ${_formatPathPrefixes(displayPath)}',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||
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),
|
||||
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),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.only(left: 8, right: 8, bottom: 4)
|
||||
: EdgeInsets.zero,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${message.repeatCount}',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||
_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 (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],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -364,8 +436,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
fallbackTextColor: previewTextColor,
|
||||
width: 120,
|
||||
height: 80,
|
||||
maxSize: 80,
|
||||
),
|
||||
);
|
||||
} else if (poi != null) {
|
||||
@@ -690,14 +761,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
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),
|
||||
width: 160,
|
||||
height: 110,
|
||||
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),
|
||||
@@ -711,9 +784,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
|
||||
return TextField(
|
||||
controller: _textController,
|
||||
focusNode: _textFieldFocusNode,
|
||||
inputFormatters: [
|
||||
Utf8LengthLimitingTextInputFormatter(maxBytes),
|
||||
],
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.chat_typeMessage,
|
||||
border: OutlineInputBorder(
|
||||
@@ -803,14 +878,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
_setReplyingTo(message);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_reaction_outlined),
|
||||
title: Text(context.l10n.chat_addReaction),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showEmojiPicker(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),
|
||||
@@ -852,9 +929,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
|
||||
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';
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
+418
-370
File diff suppressed because it is too large
Load Diff
+176
-91
@@ -5,11 +5,15 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../helpers/reaction_helper.dart';
|
||||
import '../helpers/chat_scroll_controller.dart';
|
||||
import '../helpers/link_handler.dart';
|
||||
import '../helpers/utf8_length_limiter.dart';
|
||||
import '../models/channel_message.dart';
|
||||
import '../models/contact.dart';
|
||||
@@ -20,6 +24,7 @@ import 'map_screen.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 '../widgets/path_selection_dialog.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
@@ -36,25 +41,44 @@ class ChatScreen extends StatefulWidget {
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
final _textController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
final _scrollController = ChatScrollController();
|
||||
final _textFieldFocusNode = FocusNode();
|
||||
bool _isLoadingOlder = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
|
||||
_scrollController.onScrollNearTop = _loadOlderMessages;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
context.read<MeshCoreConnector>().setActiveContact(widget.contact.publicKeyHex);
|
||||
|
||||
// Scroll to bottom when opening chat use SchedulerBinding for next frame
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
context.read<MeshCoreConnector>().setActiveContact(null);
|
||||
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
|
||||
_textFieldFocusNode.dispose();
|
||||
_textController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
@@ -167,9 +191,16 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: messages.isEmpty
|
||||
? _buildEmptyState()
|
||||
: _buildMessageList(messages, connector),
|
||||
child: Stack(
|
||||
children: [
|
||||
messages.isEmpty
|
||||
? _buildEmptyState()
|
||||
: _buildMessageList(messages, connector),
|
||||
JumpToBottomButton(
|
||||
scrollController: _scrollController,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildInputBar(connector),
|
||||
],
|
||||
@@ -201,13 +232,37 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
|
||||
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((_) {
|
||||
_scrollController.scrollToBottomIfAtBottom();
|
||||
});
|
||||
|
||||
return ListView.builder(
|
||||
reverse: true, // List grows from bottom up
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
|
||||
itemCount: messages.length,
|
||||
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 = widget.contact;
|
||||
final message = messages[index];
|
||||
final message = reversedMessages[messageIndex];
|
||||
String fourByteHex = '';
|
||||
if (widget.contact.type == advTypeRoom) {
|
||||
contact = _resolveContactFrom4Bytes(
|
||||
@@ -256,13 +311,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GifMessage(
|
||||
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
fallbackTextColor:
|
||||
colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
width: 160,
|
||||
height: 110,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: GifMessage(
|
||||
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
fallbackTextColor:
|
||||
colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
maxSize: 160,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -276,9 +333,11 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
return TextField(
|
||||
controller: _textController,
|
||||
focusNode: _textFieldFocusNode,
|
||||
inputFormatters: [
|
||||
Utf8LengthLimitingTextInputFormatter(maxBytes),
|
||||
],
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.chat_typeMessage,
|
||||
border: const OutlineInputBorder(),
|
||||
@@ -336,16 +395,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
text,
|
||||
);
|
||||
_textController.clear();
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -802,14 +851,16 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_reaction_outlined),
|
||||
title: Text(context.l10n.chat_addReaction),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showEmojiPicker(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, contact);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.copy),
|
||||
title: Text(context.l10n.common_copy),
|
||||
@@ -883,25 +934,29 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showEmojiPicker(Message message) {
|
||||
void _showEmojiPicker(Message message, Contact senderContact) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => EmojiPicker(
|
||||
onEmojiSelected: (emoji) {
|
||||
_sendReaction(message, emoji);
|
||||
_sendReaction(message, senderContact, emoji);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _sendReaction(Message message, String emoji) {
|
||||
void _sendReaction(Message message, Contact senderContact, String emoji) {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
// Send reaction with messageId if available, otherwise use lightweight format
|
||||
// Parser will extract reactionKey (timestamp_senderPrefix) for deduplication
|
||||
final messageId = message.messageId ??
|
||||
'${message.timestamp.millisecondsSinceEpoch}_${message.senderKeyHex.substring(0, 8)}';
|
||||
final reactionText = 'r:$messageId:$emoji';
|
||||
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 senderName = widget.contact.type == advTypeRoom ? senderContact.name : null;
|
||||
final hash = ReactionHelper.computeReactionHash(timestampSecs, senderName, message.text);
|
||||
final reactionText = 'r:$hash:$emojiIndex';
|
||||
connector.sendMessage(widget.contact, reactionText);
|
||||
}
|
||||
}
|
||||
@@ -957,7 +1012,9 @@ class _MessageBubble extends StatelessWidget {
|
||||
],
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.all(4)
|
||||
: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.65,
|
||||
),
|
||||
@@ -969,75 +1026,103 @@ class _MessageBubble extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isOutgoing) ...[
|
||||
Text(
|
||||
senderName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.only(left: 8, top: 4, bottom: 4)
|
||||
: EdgeInsets.zero,
|
||||
child: Text(
|
||||
senderName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (gifId == null) const SizedBox(height: 4),
|
||||
],
|
||||
if (poi != null)
|
||||
_buildPoiMessage(context, poi, textColor, metaColor)
|
||||
else if (gifId != null)
|
||||
GifMessage(
|
||||
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
|
||||
backgroundColor: bubbleColor,
|
||||
fallbackTextColor: textColor.withValues(alpha: 0.7),
|
||||
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
|
||||
Text(
|
||||
messageText,
|
||||
Linkify(
|
||||
text: messageText,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
),
|
||||
linkStyle: const TextStyle(
|
||||
color: Colors.green,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
options: const LinkifyOptions(
|
||||
humanize: false,
|
||||
defaultToHttps: false,
|
||||
),
|
||||
linkifiers: const [UrlLinkifier()],
|
||||
onOpen: (link) => LinkHandler.handleLinkTap(context, link.url),
|
||||
),
|
||||
if (isOutgoing && message.retryCount > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.chat_retryCount(message.retryCount, 4),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: metaColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.symmetric(horizontal: 8)
|
||||
: EdgeInsets.zero,
|
||||
child: Text(
|
||||
context.l10n.chat_retryCount(message.retryCount, 4),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: metaColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(message.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: metaColor,
|
||||
),
|
||||
),
|
||||
if (isOutgoing) ...[
|
||||
const SizedBox(width: 4),
|
||||
_buildStatusIcon(metaColor),
|
||||
],
|
||||
if (message.tripTimeMs != null &&
|
||||
message.status == MessageStatus.delivered) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.speed,
|
||||
size: 10,
|
||||
color: isOutgoing ? metaColor : Colors.green[700],
|
||||
),
|
||||
Padding(
|
||||
padding: gifId != null
|
||||
? const EdgeInsets.only(left: 8, right: 8, bottom: 4)
|
||||
: EdgeInsets.zero,
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s',
|
||||
_formatTime(message.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: isOutgoing ? metaColor : Colors.green[700],
|
||||
fontSize: 10,
|
||||
color: metaColor,
|
||||
),
|
||||
),
|
||||
if (isOutgoing) ...[
|
||||
const SizedBox(width: 4),
|
||||
_buildStatusIcon(metaColor),
|
||||
],
|
||||
if (message.tripTimeMs != null &&
|
||||
message.status == MessageStatus.delivered) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.speed,
|
||||
size: 10,
|
||||
color: isOutgoing ? metaColor : Colors.green[700],
|
||||
),
|
||||
Text(
|
||||
'${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: isOutgoing ? metaColor : Colors.green[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meshcore_open/widgets/path_trace_dialog.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
@@ -51,7 +53,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
final ContactGroupStore _groupStore = ContactGroupStore();
|
||||
List<ContactGroup> _groups = [];
|
||||
Timer? _searchDebounce;
|
||||
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -313,6 +315,14 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
return matchesContactQuery(contact, _searchQuery);
|
||||
}).toList();
|
||||
|
||||
// Filter out own node from the list
|
||||
if (connector.selfPublicKey != null) {
|
||||
final selfPubKeyHex = pubKeyToHex(connector.selfPublicKey!);
|
||||
filtered = filtered.where((contact) {
|
||||
return contact.publicKeyHex != selfPubKeyHex;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
if (_typeFilter != ContactTypeFilter.all) {
|
||||
filtered = filtered.where(_matchesTypeFilter).toList();
|
||||
}
|
||||
@@ -752,7 +762,19 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isRepeater)
|
||||
if (isRepeater) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
title: contact.pathLength > 0 ? Text(context.l10n.contacts_pathTrace) : Text(context.l10n.contacts_ping),
|
||||
onTap: () {
|
||||
showDialog(context: context, builder: (context) {
|
||||
return PathTraceDialog(
|
||||
title: contact.pathLength > 0 ? context.l10n.contacts_repeaterPathTrace : context.l10n.contacts_repeaterPing,
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
);
|
||||
});
|
||||
}
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.cell_tower, color: Colors.orange),
|
||||
title: Text(context.l10n.contacts_manageRepeater),
|
||||
@@ -761,7 +783,19 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
_showRepeaterLogin(context, contact);
|
||||
},
|
||||
)
|
||||
else if (isRoom) ...[
|
||||
]else if (isRoom) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
title: contact.pathLength > 0 ? Text(context.l10n.contacts_pathTrace) : Text(context.l10n.contacts_ping),
|
||||
onTap: () {
|
||||
showDialog(context: context, builder: (context) {
|
||||
return PathTraceDialog(
|
||||
title: contact.pathLength > 0 ? context.l10n.contacts_roomPathTrace : context.l10n.contacts_roomPing,
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
);
|
||||
});
|
||||
}
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.room, color: Colors.blue),
|
||||
title: Text(context.l10n.contacts_roomLogin),
|
||||
@@ -778,7 +812,20 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
_showRoomLogin(context, contact, RoomLoginDestination.management);
|
||||
},
|
||||
),
|
||||
] else
|
||||
] else ...[
|
||||
if(contact.pathLength > 0)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
title: Text(context.l10n.contacts_chatTraceRoute),
|
||||
onTap: () {
|
||||
showDialog(context: context, builder: (context) {
|
||||
return PathTraceDialog(
|
||||
title: context.l10n.contacts_pathTraceTo(contact.name),
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
);
|
||||
});
|
||||
}
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.chat),
|
||||
title: Text(context.l10n.contacts_openChat),
|
||||
@@ -798,6 +845,7 @@ class _ContactsScreenState extends State<ContactsScreen>
|
||||
_confirmDelete(context, connector, contact);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -852,8 +900,6 @@ class _ContactTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shotPublicKey =
|
||||
"<${contact.publicKeyHex.substring(0, 8)}...${contact.publicKeyHex.substring(contact.publicKeyHex.length - 8)}>";
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _getTypeColor(contact.type),
|
||||
@@ -861,23 +907,32 @@ class _ContactTile extends StatelessWidget {
|
||||
),
|
||||
title: Text(contact.name),
|
||||
subtitle: Text(
|
||||
'${contact.typeLabel} • ${contact.pathLabel} $shotPublicKey',
|
||||
'${contact.typeLabel} • ${contact.pathLabel} ${contact.shortPubKeyHex}',
|
||||
),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (unreadCount > 0) ...[
|
||||
UnreadBadge(count: unreadCount),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Text(
|
||||
_formatLastSeen(context, lastSeen),
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
// Clamp text scaling in trailing section to prevent overflow while
|
||||
// maintaining accessibility. Primary content (title/subtitle) scales normally.
|
||||
trailing: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaler: TextScaler.linear(
|
||||
MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
|
||||
),
|
||||
if (contact.hasLocation)
|
||||
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (unreadCount > 0) ...[
|
||||
UnreadBadge(count: unreadCount),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Text(
|
||||
_formatLastSeen(context, lastSeen),
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
if (contact.hasLocation)
|
||||
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
|
||||
],
|
||||
),
|
||||
),
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
|
||||
@@ -354,6 +354,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showFilterDialog(context, settingsService),
|
||||
tooltip: context.l10n.map_filterNodes,
|
||||
child: const Icon(Icons.filter_list),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -73,7 +73,7 @@ class RepeaterHubScreen extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'<${repeater.publicKeyHex.substring(0, 8)}...${repeater.publicKeyHex.substring(repeater.publicKeyHex.length - 8)}>',
|
||||
repeater.shortPubKeyHex,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -8,9 +8,47 @@ import '../widgets/device_tile.dart';
|
||||
import 'contacts_screen.dart';
|
||||
|
||||
/// Screen for scanning and connecting to MeshCore devices
|
||||
class ScannerScreen extends StatelessWidget {
|
||||
class ScannerScreen extends StatefulWidget {
|
||||
const ScannerScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ScannerScreen> createState() => _ScannerScreenState();
|
||||
}
|
||||
|
||||
class _ScannerScreenState extends State<ScannerScreen> {
|
||||
bool _changedNavigation = false;
|
||||
late final VoidCallback _connectionListener;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
_connectionListener = () {
|
||||
if (connector.state == MeshCoreConnectionState.disconnected) {
|
||||
_changedNavigation = false;
|
||||
} else if (connector.state == MeshCoreConnectionState.connected && !_changedNavigation) {
|
||||
_changedNavigation = true;
|
||||
if (mounted) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ContactsScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
connector.addListener(_connectionListener);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
connector.removeListener(_connectionListener);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -161,15 +199,6 @@ final l10n = context.l10n;
|
||||
? result.device.platformName
|
||||
: result.advertisementData.advName;
|
||||
await connector.connect(result.device, displayName: name);
|
||||
|
||||
if (context.mounted && connector.isConnected) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ContactsScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
Reference in New Issue
Block a user