diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..2220133 --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 29f92af..0d5b4b1 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -146,6 +146,7 @@ class MeshCoreConnector extends ChangeNotifier { final Set _knownContactKeys = {}; final Map _contactLastReadMs = {}; final Map _channelLastReadMs = {}; + bool _unreadStateLoaded = false; final Map _pendingRepeaterAcks = {}; String? _activeContactKey; int? _activeChannelIndex; @@ -317,6 +318,7 @@ class MeshCoreConnector extends ChangeNotifier { } int getUnreadCountForContactKey(String contactKeyHex) { + if (!_unreadStateLoaded) return 0; if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return 0; final messages = _conversations[contactKeyHex]; if (messages == null || messages.isEmpty) return 0; @@ -336,6 +338,7 @@ class MeshCoreConnector extends ChangeNotifier { } int getUnreadCountForChannelIndex(int channelIndex) { + if (!_unreadStateLoaded) return 0; final messages = _channelMessages[channelIndex]; if (messages == null || messages.isEmpty) return 0; final lastReadMs = _channelLastReadMs[channelIndex] ?? 0; @@ -350,6 +353,7 @@ class MeshCoreConnector extends ChangeNotifier { } int getTotalUnreadCount() { + if (!_unreadStateLoaded) return 0; var total = 0; // Count unread contact messages for (final contact in _contacts) { @@ -381,6 +385,7 @@ class MeshCoreConnector extends ChangeNotifier { _channelLastReadMs ..clear() ..addAll(await _unreadStore.loadChannelLastRead()); + _unreadStateLoaded = true; notifyListeners(); } @@ -620,6 +625,17 @@ class MeshCoreConnector extends ChangeNotifier { _scanResults.clear(); _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) { _scanResults.clear(); for (var result in results) { diff --git a/lib/helpers/chat_scroll_controller.dart b/lib/helpers/chat_scroll_controller.dart new file mode 100644 index 0000000..d2c73fb --- /dev/null +++ b/lib/helpers/chat_scroll_controller.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; + +class ChatScrollController extends ScrollController { + final ValueNotifier 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(); + } +} diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 380c7ce..f45ed34 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -8,6 +8,7 @@ import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; +import '../helpers/chat_scroll_controller.dart'; import '../connector/meshcore_protocol.dart'; import '../helpers/link_handler.dart'; import '../helpers/utf8_length_limiter.dart'; @@ -17,6 +18,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'; @@ -35,42 +37,51 @@ class ChannelChatScreen extends StatefulWidget { class _ChannelChatScreenState extends State { final TextEditingController _textController = TextEditingController(); - final ScrollController _scrollController = ScrollController(); + final ChatScrollController _scrollController = ChatScrollController(); + final FocusNode _textFieldFocusNode = FocusNode(); ChannelMessage? _replyingToMessage; final Map _messageKeys = {}; + bool _isLoadingOlder = false; @override void initState() { super.initState(); + _textFieldFocusNode.addListener(_onTextFieldFocusChange); + _scrollController.onScrollNearTop = _loadOlderMessages; SchedulerBinding.instance.addPostFrameCallback((_) { if (!mounted) return; context.read().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 _loadOlderMessages() async { + if (_isLoadingOlder) return; + setState(() => _isLoadingOlder = true); + + final connector = context.read(); + await connector.loadOlderChannelMessages(widget.channel.index); + + if (mounted) { + setState(() => _isLoadingOlder = false); + } + } + @override void dispose() { context.read().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; @@ -155,10 +166,6 @@ class _ChannelChatScreenState extends State { builder: (context, connector, child) { final messages = connector.getChannelMessages(widget.channel); - SchedulerBinding.instance.addPostFrameCallback((_) { - _scrollToBottom(); - }); - if (messages.isEmpty) { return Center( child: Column( @@ -192,20 +199,51 @@ class _ChannelChatScreenState extends State { ); } - 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, + ), + ], ); }, ), @@ -243,7 +281,9 @@ class _ChannelChatScreenState extends State { 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, ), @@ -257,15 +297,20 @@ class _ChannelChatScreenState extends State { 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), @@ -274,12 +319,15 @@ class _ChannelChatScreenState extends State { 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 Linkify( @@ -299,46 +347,56 @@ class _ChannelChatScreenState extends State { ), 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], - ), - ], - ], + ), ), ], ), @@ -377,8 +435,7 @@ class _ChannelChatScreenState extends State { url: 'https://media.giphy.com/media/$gifId/giphy.gif', backgroundColor: colorScheme.surfaceContainerHighest, fallbackTextColor: previewTextColor, - width: 120, - height: 80, + maxSize: 80, ), ); } else if (poi != null) { @@ -703,14 +760,16 @@ class _ChannelChatScreenState extends State { 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), @@ -724,6 +783,7 @@ class _ChannelChatScreenState extends State { return TextField( controller: _textController, + focusNode: _textFieldFocusNode, inputFormatters: [ Utf8LengthLimitingTextInputFormatter(maxBytes), ], diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 30a99f0..c302fb3 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -312,6 +312,7 @@ class _ChannelsScreenState extends State ), floatingActionButton: FloatingActionButton( onPressed: () => _showAddChannelDialog(context), + tooltip: context.l10n.channels_addChannel, child: const Icon(Icons.add), ), bottomNavigationBar: SafeArea( diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 079f25d..efc3537 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -11,6 +11,7 @@ import 'package:latlong2/latlong.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; +import '../helpers/chat_scroll_controller.dart'; import '../helpers/link_handler.dart'; import '../helpers/utf8_length_limiter.dart'; import '../models/channel_message.dart'; @@ -22,6 +23,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'; @@ -38,25 +40,44 @@ class ChatScreen extends StatefulWidget { class _ChatScreenState extends State { 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().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 _loadOlderMessages() async { + if (_isLoadingOlder) return; + setState(() => _isLoadingOlder = true); + + final connector = context.read(); + await connector.loadOlderMessages(widget.contact.publicKeyHex); + + if (mounted) { + setState(() => _isLoadingOlder = false); + } + } + @override void dispose() { context.read().setActiveContact(null); + _textFieldFocusNode.removeListener(_onTextFieldFocusChange); + _textFieldFocusNode.dispose(); _textController.dispose(); _scrollController.dispose(); super.dispose(); @@ -169,9 +190,16 @@ class _ChatScreenState extends State { 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), ], @@ -203,13 +231,37 @@ class _ChatScreenState extends State { } Widget _buildMessageList(List 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( @@ -258,13 +310,15 @@ class _ChatScreenState extends State { 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), @@ -278,6 +332,7 @@ class _ChatScreenState extends State { return TextField( controller: _textController, + focusNode: _textFieldFocusNode, inputFormatters: [ Utf8LengthLimitingTextInputFormatter(maxBytes), ], @@ -339,16 +394,6 @@ class _ChatScreenState extends State { 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, - ); - } - }); } @@ -960,7 +1005,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, ), @@ -972,23 +1019,31 @@ 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 Linkify( @@ -1009,48 +1064,58 @@ class _MessageBubble extends StatelessWidget { ), 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], + ), + ), + ], ], - ], + ), ), ], ), diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index e91cd94..54f819c 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -313,6 +313,14 @@ class _ContactsScreenState extends State 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(); } @@ -863,21 +871,30 @@ class _ContactTile extends StatelessWidget { subtitle: Text( '${contact.typeLabel} • ${contact.pathLabel} $shotPublicKey', ), - 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, diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 5b804eb..74e5cf9 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -354,6 +354,7 @@ class _MapScreenState extends State { ), floatingActionButton: FloatingActionButton( onPressed: () => _showFilterDialog(context, settingsService), + tooltip: context.l10n.map_filterNodes, child: const Icon(Icons.filter_list), ), ), diff --git a/lib/widgets/gif_message.dart b/lib/widgets/gif_message.dart index 402565f..b98bdc6 100644 --- a/lib/widgets/gif_message.dart +++ b/lib/widgets/gif_message.dart @@ -6,16 +6,14 @@ class GifMessage extends StatefulWidget { final String url; final Color backgroundColor; final Color fallbackTextColor; - final double width; - final double height; + final double maxSize; const GifMessage({ super.key, required this.url, required this.backgroundColor, required this.fallbackTextColor, - this.width = 200, - this.height = 140, + this.maxSize = 200, }); @override @@ -122,6 +120,28 @@ class _GifMessageState extends State { @override 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; if (_error != null) { @@ -151,33 +171,30 @@ class _GifMessageState extends State { } else { content = RawImage( image: _image, - fit: BoxFit.cover, - width: widget.width, - height: widget.height, + fit: BoxFit.contain, + width: displayWidth, + height: displayHeight, ); } return GestureDetector( onTap: _togglePause, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Container( - color: widget.backgroundColor, - width: widget.width, - height: widget.height, - child: Stack( - fit: StackFit.expand, - children: [ - content, - if (_isPaused && _image != null) - Container( - color: Colors.black.withValues(alpha: 0.2), - child: const Center( - child: Icon(Icons.pause, color: Colors.white70, size: 28), - ), + child: Container( + color: widget.backgroundColor, + width: displayWidth, + height: displayHeight, + child: Stack( + fit: StackFit.expand, + children: [ + content, + if (_isPaused && _image != null) + Container( + color: Colors.black.withValues(alpha: 0.2), + child: const Center( + child: Icon(Icons.pause, color: Colors.white70, size: 28), ), - ], - ), + ), + ], ), ), ); diff --git a/lib/widgets/jump_to_bottom_button.dart b/lib/widgets/jump_to_bottom_button.dart new file mode 100644 index 0000000..08614f3 --- /dev/null +++ b/lib/widgets/jump_to_bottom_button.dart @@ -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( + 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), + ), + ); + }, + ); + } +}