Merge branch 'main' into anupoh/main

This commit is contained in:
zjs81
2026-01-24 00:22:28 -07:00
10 changed files with 1141 additions and 204 deletions
File diff suppressed because one or more lines are too long
+16
View File
@@ -146,6 +146,7 @@ class MeshCoreConnector extends ChangeNotifier {
final Set<String> _knownContactKeys = {}; final Set<String> _knownContactKeys = {};
final Map<String, int> _contactLastReadMs = {}; final Map<String, int> _contactLastReadMs = {};
final Map<int, int> _channelLastReadMs = {}; final Map<int, int> _channelLastReadMs = {};
bool _unreadStateLoaded = false;
final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {}; final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {};
String? _activeContactKey; String? _activeContactKey;
int? _activeChannelIndex; int? _activeChannelIndex;
@@ -317,6 +318,7 @@ class MeshCoreConnector extends ChangeNotifier {
} }
int getUnreadCountForContactKey(String contactKeyHex) { int getUnreadCountForContactKey(String contactKeyHex) {
if (!_unreadStateLoaded) return 0;
if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return 0; if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return 0;
final messages = _conversations[contactKeyHex]; final messages = _conversations[contactKeyHex];
if (messages == null || messages.isEmpty) return 0; if (messages == null || messages.isEmpty) return 0;
@@ -336,6 +338,7 @@ class MeshCoreConnector extends ChangeNotifier {
} }
int getUnreadCountForChannelIndex(int channelIndex) { int getUnreadCountForChannelIndex(int channelIndex) {
if (!_unreadStateLoaded) return 0;
final messages = _channelMessages[channelIndex]; final messages = _channelMessages[channelIndex];
if (messages == null || messages.isEmpty) return 0; if (messages == null || messages.isEmpty) return 0;
final lastReadMs = _channelLastReadMs[channelIndex] ?? 0; final lastReadMs = _channelLastReadMs[channelIndex] ?? 0;
@@ -350,6 +353,7 @@ class MeshCoreConnector extends ChangeNotifier {
} }
int getTotalUnreadCount() { int getTotalUnreadCount() {
if (!_unreadStateLoaded) return 0;
var total = 0; var total = 0;
// Count unread contact messages // Count unread contact messages
for (final contact in _contacts) { for (final contact in _contacts) {
@@ -381,6 +385,7 @@ class MeshCoreConnector extends ChangeNotifier {
_channelLastReadMs _channelLastReadMs
..clear() ..clear()
..addAll(await _unreadStore.loadChannelLastRead()); ..addAll(await _unreadStore.loadChannelLastRead());
_unreadStateLoaded = true;
notifyListeners(); notifyListeners();
} }
@@ -620,6 +625,17 @@ class MeshCoreConnector extends ChangeNotifier {
_scanResults.clear(); _scanResults.clear();
_setState(MeshCoreConnectionState.scanning); _setState(MeshCoreConnectionState.scanning);
// Ensure any previous scan is fully stopped
await FlutterBluePlus.stopScan();
await _scanSubscription?.cancel();
// On iOS/macOS, add a small delay to allow BLE stack to reset
// This prevents cached results from interfering with new scans
if (defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS) {
await Future.delayed(const Duration(milliseconds: 300));
}
_scanSubscription = FlutterBluePlus.scanResults.listen((results) { _scanSubscription = FlutterBluePlus.scanResults.listen((results) {
_scanResults.clear(); _scanResults.clear();
for (var result in results) { for (var result in results) {
+68
View File
@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
class ChatScrollController extends ScrollController {
final ValueNotifier<bool> showJumpToBottom = ValueNotifier(false);
VoidCallback? onScrollNearTop;
static const _bottomThreshold = 100.0;
static const _topThreshold = 50.0;
ChatScrollController() {
addListener(_handleScroll);
}
void _handleScroll() {
if (!hasClients) return;
final pos = position;
// With reverse: true, position 0 is bottom, maxScrollExtent is top
// Show jump button when scrolled away from bottom (position > threshold)
final isAtBottom = pos.pixels <= _bottomThreshold;
if (showJumpToBottom.value == isAtBottom) {
showJumpToBottom.value = !isAtBottom;
}
// Pagination trigger when scrolled near top (maxScrollExtent)
if (pos.pixels >= pos.maxScrollExtent - _topThreshold) {
onScrollNearTop?.call();
}
}
void jumpToBottom() {
if (hasClients && position.maxScrollExtent > 0) {
animateTo(
0, // With reverse: true, position 0 is bottom
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
}
void handleKeyboardOpen() {
// Simple: just scroll to bottom when keyboard opens
if (hasClients) {
animateTo(
0, // With reverse: true, position 0 is bottom
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
}
void scrollToBottomIfAtBottom() {
// Only scroll if jump button is NOT showing (i.e., already at bottom)
if (!showJumpToBottom.value && hasClients && position.maxScrollExtent > 0) {
animateTo(
0, // With reverse: true, position 0 is bottom
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
}
@override
void dispose() {
showJumpToBottom.dispose();
super.dispose();
}
}
+152 -92
View File
@@ -8,6 +8,7 @@ import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../helpers/chat_scroll_controller.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../helpers/link_handler.dart'; import '../helpers/link_handler.dart';
import '../helpers/utf8_length_limiter.dart'; import '../helpers/utf8_length_limiter.dart';
@@ -17,6 +18,7 @@ import '../models/channel_message.dart';
import '../utils/emoji_utils.dart'; import '../utils/emoji_utils.dart';
import '../widgets/emoji_picker.dart'; import '../widgets/emoji_picker.dart';
import '../widgets/gif_message.dart'; import '../widgets/gif_message.dart';
import '../widgets/jump_to_bottom_button.dart';
import '../widgets/gif_picker.dart'; import '../widgets/gif_picker.dart';
import 'channel_message_path_screen.dart'; import 'channel_message_path_screen.dart';
import 'map_screen.dart'; import 'map_screen.dart';
@@ -35,42 +37,51 @@ class ChannelChatScreen extends StatefulWidget {
class _ChannelChatScreenState extends State<ChannelChatScreen> { class _ChannelChatScreenState extends State<ChannelChatScreen> {
final TextEditingController _textController = TextEditingController(); final TextEditingController _textController = TextEditingController();
final ScrollController _scrollController = ScrollController(); final ChatScrollController _scrollController = ChatScrollController();
final FocusNode _textFieldFocusNode = FocusNode();
ChannelMessage? _replyingToMessage; ChannelMessage? _replyingToMessage;
final Map<String, GlobalKey> _messageKeys = {}; final Map<String, GlobalKey> _messageKeys = {};
bool _isLoadingOlder = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
_scrollController.onScrollNearTop = _loadOlderMessages;
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
context.read<MeshCoreConnector>().setActiveChannel(widget.channel.index); 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 @override
void dispose() { void dispose() {
context.read<MeshCoreConnector>().setActiveChannel(null); context.read<MeshCoreConnector>().setActiveChannel(null);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose();
_textController.dispose(); _textController.dispose();
_scrollController.dispose(); _scrollController.dispose();
super.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) { void _setReplyingTo(ChannelMessage message) {
setState(() { setState(() {
_replyingToMessage = message; _replyingToMessage = message;
@@ -155,10 +166,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
builder: (context, connector, child) { builder: (context, connector, child) {
final messages = connector.getChannelMessages(widget.channel); final messages = connector.getChannelMessages(widget.channel);
SchedulerBinding.instance.addPostFrameCallback((_) {
_scrollToBottom();
});
if (messages.isEmpty) { if (messages.isEmpty) {
return Center( return Center(
child: Column( child: Column(
@@ -192,20 +199,51 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
); );
} }
return ListView.builder( // Reverse messages so newest appear at bottom with reverse: true
controller: _scrollController, final reversedMessages = messages.reversed.toList();
padding: const EdgeInsets.all(8), final itemCount = reversedMessages.length + (_isLoadingOlder ? 1 : 0);
itemCount: messages.length,
itemBuilder: (context, index) { // Auto-scroll to bottom if user is already at bottom
final message = messages[index]; WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_messageKeys.containsKey(message.messageId)) { _scrollController.scrollToBottomIfAtBottom();
_messageKeys[message.messageId] = GlobalKey(); });
}
return Container( return Stack(
key: _messageKeys[message.messageId]!, children: [
child: _buildMessageBubble(message), 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,
),
],
); );
}, },
), ),
@@ -243,7 +281,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
onTap: () => _showMessagePathInfo(message), onTap: () => _showMessagePathInfo(message),
onLongPress: () => _showMessageActions(message), onLongPress: () => _showMessageActions(message),
child: Container( 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( constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65, maxWidth: MediaQuery.of(context).size.width * 0.65,
), ),
@@ -257,15 +297,20 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (!isOutgoing) ...[ if (!isOutgoing) ...[
Text( Padding(
message.senderName, padding: gifId != null
style: TextStyle( ? const EdgeInsets.only(left: 8, top: 4, bottom: 4)
fontSize: 12, : EdgeInsets.zero,
fontWeight: FontWeight.bold, child: Text(
color: Theme.of(context).colorScheme.primary, 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) ...[ if (message.replyToMessageId != null) ...[
_buildReplyPreview(message), _buildReplyPreview(message),
@@ -274,12 +319,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
if (poi != null) if (poi != null)
_buildPoiMessage(context, poi, isOutgoing) _buildPoiMessage(context, poi, isOutgoing)
else if (gifId != null) else if (gifId != null)
GifMessage( ClipRRect(
url: 'https://media.giphy.com/media/$gifId/giphy.gif', borderRadius: BorderRadius.circular(8),
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, child: GifMessage(
fallbackTextColor: isOutgoing url: 'https://media.giphy.com/media/$gifId/giphy.gif',
? Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.7) backgroundColor: Colors.transparent,
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), fallbackTextColor: isOutgoing
? Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.7)
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
) )
else else
Linkify( Linkify(
@@ -299,46 +347,56 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
), ),
if (displayPath.isNotEmpty) ...[ if (displayPath.isNotEmpty) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Padding(
'via ${_formatPathPrefixes(displayPath)}', padding: gifId != null
style: TextStyle(fontSize: 11, color: Colors.grey[600]), ? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
'via ${_formatPathPrefixes(displayPath)}',
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
), ),
], ],
const SizedBox(height: 4), const SizedBox(height: 4),
Row( Padding(
mainAxisSize: MainAxisSize.min, padding: gifId != null
children: [ ? const EdgeInsets.only(left: 8, right: 8, bottom: 4)
Text( : EdgeInsets.zero,
_formatTime(message.timestamp), child: Row(
style: TextStyle( mainAxisSize: MainAxisSize.min,
fontSize: 11, children: [
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( Text(
'${message.repeatCount}', _formatTime(message.timestamp),
style: TextStyle(fontSize: 11, color: Colors.grey[600]), 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],
),
],
],
), ),
], ],
), ),
@@ -377,8 +435,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
url: 'https://media.giphy.com/media/$gifId/giphy.gif', url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: colorScheme.surfaceContainerHighest, backgroundColor: colorScheme.surfaceContainerHighest,
fallbackTextColor: previewTextColor, fallbackTextColor: previewTextColor,
width: 120, maxSize: 80,
height: 80,
), ),
); );
} else if (poi != null) { } else if (poi != null) {
@@ -703,14 +760,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return Row( return Row(
children: [ children: [
Expanded( Expanded(
child: GifMessage( child: ClipRRect(
url: 'https://media.giphy.com/media/$gifId/giphy.gif', borderRadius: BorderRadius.circular(12),
backgroundColor: child: GifMessage(
Theme.of(context).colorScheme.surfaceContainerHighest, url: 'https://media.giphy.com/media/$gifId/giphy.gif',
fallbackTextColor: backgroundColor:
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), Theme.of(context).colorScheme.surfaceContainerHighest,
width: 160, fallbackTextColor:
height: 110, Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
maxSize: 160,
),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -724,6 +783,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return TextField( return TextField(
controller: _textController, controller: _textController,
focusNode: _textFieldFocusNode,
inputFormatters: [ inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes), Utf8LengthLimitingTextInputFormatter(maxBytes),
], ],
+1
View File
@@ -312,6 +312,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () => _showAddChannelDialog(context), onPressed: () => _showAddChannelDialog(context),
tooltip: context.l10n.channels_addChannel,
child: const Icon(Icons.add), child: const Icon(Icons.add),
), ),
bottomNavigationBar: SafeArea( bottomNavigationBar: SafeArea(
+138 -73
View File
@@ -11,6 +11,7 @@ import 'package:latlong2/latlong.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../helpers/chat_scroll_controller.dart';
import '../helpers/link_handler.dart'; import '../helpers/link_handler.dart';
import '../helpers/utf8_length_limiter.dart'; import '../helpers/utf8_length_limiter.dart';
import '../models/channel_message.dart'; import '../models/channel_message.dart';
@@ -22,6 +23,7 @@ import 'map_screen.dart';
import '../utils/emoji_utils.dart'; import '../utils/emoji_utils.dart';
import '../widgets/emoji_picker.dart'; import '../widgets/emoji_picker.dart';
import '../widgets/gif_message.dart'; import '../widgets/gif_message.dart';
import '../widgets/jump_to_bottom_button.dart';
import '../widgets/gif_picker.dart'; import '../widgets/gif_picker.dart';
import '../widgets/path_selection_dialog.dart'; import '../widgets/path_selection_dialog.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
@@ -38,25 +40,44 @@ class ChatScreen extends StatefulWidget {
class _ChatScreenState extends State<ChatScreen> { class _ChatScreenState extends State<ChatScreen> {
final _textController = TextEditingController(); final _textController = TextEditingController();
final _scrollController = ScrollController(); final _scrollController = ChatScrollController();
final _textFieldFocusNode = FocusNode();
bool _isLoadingOlder = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_textFieldFocusNode.addListener(_onTextFieldFocusChange);
_scrollController.onScrollNearTop = _loadOlderMessages;
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
context.read<MeshCoreConnector>().setActiveContact(widget.contact.publicKeyHex); 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 @override
void dispose() { void dispose() {
context.read<MeshCoreConnector>().setActiveContact(null); context.read<MeshCoreConnector>().setActiveContact(null);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose();
_textController.dispose(); _textController.dispose();
_scrollController.dispose(); _scrollController.dispose();
super.dispose(); super.dispose();
@@ -169,9 +190,16 @@ class _ChatScreenState extends State<ChatScreen> {
return Column( return Column(
children: [ children: [
Expanded( Expanded(
child: messages.isEmpty child: Stack(
? _buildEmptyState() children: [
: _buildMessageList(messages, connector), messages.isEmpty
? _buildEmptyState()
: _buildMessageList(messages, connector),
JumpToBottomButton(
scrollController: _scrollController,
),
],
),
), ),
_buildInputBar(connector), _buildInputBar(connector),
], ],
@@ -203,13 +231,37 @@ class _ChatScreenState extends State<ChatScreen> {
} }
Widget _buildMessageList(List<Message> messages, MeshCoreConnector connector) { 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( return ListView.builder(
reverse: true, // List grows from bottom up
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
itemCount: messages.length, itemCount: itemCount,
itemBuilder: (context, index) { 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; Contact contact = widget.contact;
final message = messages[index]; final message = reversedMessages[messageIndex];
String fourByteHex = ''; String fourByteHex = '';
if (widget.contact.type == advTypeRoom) { if (widget.contact.type == advTypeRoom) {
contact = _resolveContactFrom4Bytes( contact = _resolveContactFrom4Bytes(
@@ -258,13 +310,15 @@ class _ChatScreenState extends State<ChatScreen> {
return Row( return Row(
children: [ children: [
Expanded( Expanded(
child: GifMessage( child: ClipRRect(
url: 'https://media.giphy.com/media/$gifId/giphy.gif', borderRadius: BorderRadius.circular(12),
backgroundColor: colorScheme.surfaceContainerHighest, child: GifMessage(
fallbackTextColor: url: 'https://media.giphy.com/media/$gifId/giphy.gif',
colorScheme.onSurface.withValues(alpha: 0.6), backgroundColor: colorScheme.surfaceContainerHighest,
width: 160, fallbackTextColor:
height: 110, colorScheme.onSurface.withValues(alpha: 0.6),
maxSize: 160,
),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -278,6 +332,7 @@ class _ChatScreenState extends State<ChatScreen> {
return TextField( return TextField(
controller: _textController, controller: _textController,
focusNode: _textFieldFocusNode,
inputFormatters: [ inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes), Utf8LengthLimitingTextInputFormatter(maxBytes),
], ],
@@ -339,16 +394,6 @@ class _ChatScreenState extends State<ChatScreen> {
text, text,
); );
_textController.clear(); _textController.clear();
Future.delayed(const Duration(milliseconds: 100), () {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
} }
@@ -960,7 +1005,9 @@ class _MessageBubble extends StatelessWidget {
], ],
Flexible( Flexible(
child: Container( 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( constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65, maxWidth: MediaQuery.of(context).size.width * 0.65,
), ),
@@ -972,23 +1019,31 @@ class _MessageBubble extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (!isOutgoing) ...[ if (!isOutgoing) ...[
Text( Padding(
senderName, padding: gifId != null
style: TextStyle( ? const EdgeInsets.only(left: 8, top: 4, bottom: 4)
fontSize: 12, : EdgeInsets.zero,
fontWeight: FontWeight.bold, child: Text(
color: colorScheme.primary, 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) if (poi != null)
_buildPoiMessage(context, poi, textColor, metaColor) _buildPoiMessage(context, poi, textColor, metaColor)
else if (gifId != null) else if (gifId != null)
GifMessage( ClipRRect(
url: 'https://media.giphy.com/media/$gifId/giphy.gif', borderRadius: BorderRadius.circular(12),
backgroundColor: bubbleColor, child: GifMessage(
fallbackTextColor: textColor.withValues(alpha: 0.7), url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Colors.transparent,
fallbackTextColor: textColor.withValues(alpha: 0.7),
),
) )
else else
Linkify( Linkify(
@@ -1009,48 +1064,58 @@ class _MessageBubble extends StatelessWidget {
), ),
if (isOutgoing && message.retryCount > 0) ...[ if (isOutgoing && message.retryCount > 0) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Padding(
context.l10n.chat_retryCount(message.retryCount, 4), padding: gifId != null
style: TextStyle( ? const EdgeInsets.symmetric(horizontal: 8)
fontSize: 10, : EdgeInsets.zero,
color: metaColor, child: Text(
fontWeight: FontWeight.w500, context.l10n.chat_retryCount(message.retryCount, 4),
style: TextStyle(
fontSize: 10,
color: metaColor,
fontWeight: FontWeight.w500,
),
), ),
), ),
], ],
const SizedBox(height: 4), const SizedBox(height: 4),
Wrap( Padding(
spacing: 4, padding: gifId != null
crossAxisAlignment: WrapCrossAlignment.center, ? const EdgeInsets.only(left: 8, right: 8, bottom: 4)
children: [ : EdgeInsets.zero,
Text( child: Wrap(
_formatTime(message.timestamp), spacing: 4,
style: TextStyle( crossAxisAlignment: WrapCrossAlignment.center,
fontSize: 10, children: [
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( Text(
'${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s', _formatTime(message.timestamp),
style: TextStyle( style: TextStyle(
fontSize: 9, fontSize: 10,
color: isOutgoing ? metaColor : Colors.green[700], 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],
),
),
],
], ],
], ),
), ),
], ],
), ),
+31 -14
View File
@@ -313,6 +313,14 @@ class _ContactsScreenState extends State<ContactsScreen>
return matchesContactQuery(contact, _searchQuery); return matchesContactQuery(contact, _searchQuery);
}).toList(); }).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) { if (_typeFilter != ContactTypeFilter.all) {
filtered = filtered.where(_matchesTypeFilter).toList(); filtered = filtered.where(_matchesTypeFilter).toList();
} }
@@ -863,21 +871,30 @@ class _ContactTile extends StatelessWidget {
subtitle: Text( subtitle: Text(
'${contact.typeLabel}${contact.pathLabel} $shotPublicKey', '${contact.typeLabel}${contact.pathLabel} $shotPublicKey',
), ),
trailing: Column( // Clamp text scaling in trailing section to prevent overflow while
mainAxisAlignment: MainAxisAlignment.center, // maintaining accessibility. Primary content (title/subtitle) scales normally.
crossAxisAlignment: CrossAxisAlignment.end, trailing: MediaQuery(
children: [ data: MediaQuery.of(context).copyWith(
if (unreadCount > 0) ...[ textScaler: TextScaler.linear(
UnreadBadge(count: unreadCount), MediaQuery.textScalerOf(context).scale(1.0).clamp(1.0, 1.3),
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]), 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, onTap: onTap,
onLongPress: onLongPress, onLongPress: onLongPress,
+1
View File
@@ -354,6 +354,7 @@ class _MapScreenState extends State<MapScreen> {
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () => _showFilterDialog(context, settingsService), onPressed: () => _showFilterDialog(context, settingsService),
tooltip: context.l10n.map_filterNodes,
child: const Icon(Icons.filter_list), child: const Icon(Icons.filter_list),
), ),
), ),
+42 -25
View File
@@ -6,16 +6,14 @@ class GifMessage extends StatefulWidget {
final String url; final String url;
final Color backgroundColor; final Color backgroundColor;
final Color fallbackTextColor; final Color fallbackTextColor;
final double width; final double maxSize;
final double height;
const GifMessage({ const GifMessage({
super.key, super.key,
required this.url, required this.url,
required this.backgroundColor, required this.backgroundColor,
required this.fallbackTextColor, required this.fallbackTextColor,
this.width = 200, this.maxSize = 200,
this.height = 140,
}); });
@override @override
@@ -122,6 +120,28 @@ class _GifMessageState extends State<GifMessage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Calculate display size based on image aspect ratio
// Use 4:3 placeholder aspect ratio during loading to minimize layout shifts
double displayWidth = widget.maxSize;
double displayHeight = widget.maxSize * 0.75;
if (_image != null) {
final imageWidth = _image!.width.toDouble();
final imageHeight = _image!.height.toDouble();
final aspectRatio = imageWidth / imageHeight;
// Fit within maxSize, calculating dimensions from aspect ratio
if (aspectRatio >= 1) {
// Wider than tall: constrain by width
displayWidth = widget.maxSize;
displayHeight = displayWidth / aspectRatio;
} else {
// Taller than wide: constrain by height
displayHeight = widget.maxSize;
displayWidth = displayHeight * aspectRatio;
}
}
Widget content; Widget content;
if (_error != null) { if (_error != null) {
@@ -151,33 +171,30 @@ class _GifMessageState extends State<GifMessage> {
} else { } else {
content = RawImage( content = RawImage(
image: _image, image: _image,
fit: BoxFit.cover, fit: BoxFit.contain,
width: widget.width, width: displayWidth,
height: widget.height, height: displayHeight,
); );
} }
return GestureDetector( return GestureDetector(
onTap: _togglePause, onTap: _togglePause,
child: ClipRRect( child: Container(
borderRadius: BorderRadius.circular(10), color: widget.backgroundColor,
child: Container( width: displayWidth,
color: widget.backgroundColor, height: displayHeight,
width: widget.width, child: Stack(
height: widget.height, fit: StackFit.expand,
child: Stack( children: [
fit: StackFit.expand, content,
children: [ if (_isPaused && _image != null)
content, Container(
if (_isPaused && _image != null) color: Colors.black.withValues(alpha: 0.2),
Container( child: const Center(
color: Colors.black.withValues(alpha: 0.2), child: Icon(Icons.pause, color: Colors.white70, size: 28),
child: const Center(
child: Icon(Icons.pause, color: Colors.white70, size: 28),
),
), ),
], ),
), ],
), ),
), ),
); );
+29
View File
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import '../helpers/chat_scroll_controller.dart';
class JumpToBottomButton extends StatelessWidget {
final ChatScrollController scrollController;
const JumpToBottomButton({
super.key,
required this.scrollController,
});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: scrollController.showJumpToBottom,
builder: (context, show, _) {
if (!show) return const SizedBox.shrink();
return Positioned(
right: 16,
bottom: 16,
child: FloatingActionButton.small(
onPressed: scrollController.jumpToBottom,
child: const Icon(Icons.keyboard_arrow_down),
),
);
},
);
}
}