mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-16 05:40:46 +10:00
Merge branch 'main' into anupoh/main
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
@@ -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],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user