Merge branch 'main' into community-#-names

This commit is contained in:
Ded
2026-01-29 08:07:05 -08:00
committed by GitHub
78 changed files with 13368 additions and 1290 deletions
+12
View File
@@ -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',
),
],
),
),
+184 -105
View File
@@ -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);
}
File diff suppressed because it is too large Load Diff
+176 -91
View File
@@ -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],
),
),
],
],
],
),
),
],
),
+76 -21
View File
@@ -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,
+1
View File
@@ -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),
),
),
+1 -1
View File
@@ -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),
+39 -10
View File
@@ -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(