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),
+ ),
+ );
+ },
+ );
+ }
+}