💄 Flashing message background when jumped

This commit is contained in:
2025-09-23 16:19:54 +08:00
parent 84c38500d0
commit 06bb18bdaa
3 changed files with 202 additions and 19 deletions

View File

@@ -3,6 +3,7 @@ import "dart:convert";
import "dart:developer" as developer; import "dart:developer" as developer;
import "dart:io"; import "dart:io";
import "package:dio/dio.dart"; import "package:dio/dio.dart";
import "package:drift/drift.dart" show Variable;
import "package:easy_localization/easy_localization.dart"; import "package:easy_localization/easy_localization.dart";
import "package:file_picker/file_picker.dart"; import "package:file_picker/file_picker.dart";
import "package:flutter/foundation.dart"; import "package:flutter/foundation.dart";
@@ -46,6 +47,8 @@ part 'room.g.dart';
final isSyncingProvider = StateProvider.autoDispose<bool>((ref) => false); final isSyncingProvider = StateProvider.autoDispose<bool>((ref) => false);
final flashingMessagesProvider = StateProvider<Set<String>>((ref) => {});
final appLifecycleStateProvider = StreamProvider<AppLifecycleState>((ref) { final appLifecycleStateProvider = StreamProvider<AppLifecycleState>((ref) {
final controller = StreamController<AppLifecycleState>(); final controller = StreamController<AppLifecycleState>();
@@ -292,6 +295,7 @@ class MessagesNotifier extends _$MessagesNotifier {
static const int _pageSize = 20; static const int _pageSize = 20;
bool _hasMore = true; bool _hasMore = true;
bool _isSyncing = false; bool _isSyncing = false;
bool _isJumping = false;
@override @override
FutureOr<List<LocalChatMessage>> build(String roomId) async { FutureOr<List<LocalChatMessage>> build(String roomId) async {
@@ -372,26 +376,35 @@ class MessagesNotifier extends _$MessagesNotifier {
final dbLocalMessages = filteredMessages; final dbLocalMessages = filteredMessages;
// Always ensure unique messages to prevent duplicate keys
final uniqueMessages = <LocalChatMessage>[];
final seenIds = <String>{};
for (final message in dbLocalMessages) {
if (seenIds.add(message.id)) {
uniqueMessages.add(message);
}
}
if (offset == 0) { if (offset == 0) {
final pendingForRoom = final pendingForRoom =
_pendingMessages.values _pendingMessages.values
.where((msg) => msg.roomId == _roomId) .where((msg) => msg.roomId == _roomId)
.toList(); .toList();
final allMessages = [...pendingForRoom, ...dbLocalMessages]; final allMessages = [...pendingForRoom, ...uniqueMessages];
_sortMessages(allMessages); // Use the helper function _sortMessages(allMessages); // Use the helper function
final uniqueMessages = <LocalChatMessage>[]; final finalUniqueMessages = <LocalChatMessage>[];
final seenIds = <String>{}; final finalSeenIds = <String>{};
for (final message in allMessages) { for (final message in allMessages) {
if (seenIds.add(message.id)) { if (finalSeenIds.add(message.id)) {
uniqueMessages.add(message); finalUniqueMessages.add(message);
} }
} }
return uniqueMessages; return finalUniqueMessages;
} }
return dbLocalMessages; return uniqueMessages;
} }
Future<List<LocalChatMessage>> _fetchAndCacheMessages({ Future<List<LocalChatMessage>> _fetchAndCacheMessages({
@@ -982,6 +995,111 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
} }
Future<int> jumpToMessage(String messageId) async {
developer.log(
'Starting jump to message $messageId',
name: 'MessagesNotifier',
);
if (_isJumping) {
developer.log(
'Jump already in progress, skipping',
name: 'MessagesNotifier',
);
return -1;
}
_isJumping = true;
try {
developer.log('Fetching message $messageId', name: 'MessagesNotifier');
final message = await fetchMessageById(messageId);
if (message == null) {
developer.log('Message $messageId not found', name: 'MessagesNotifier');
showSnackBar('messageNotFound'.tr());
return -1;
}
// Check if message is already in current state to avoid duplicate loading
final currentMessages = state.value ?? [];
final existingIndex = currentMessages.indexWhere(
(m) => m.id == messageId,
);
if (existingIndex >= 0) {
developer.log(
'Message $messageId already in current state at index $existingIndex, jumping directly',
name: 'MessagesNotifier',
);
return existingIndex;
}
developer.log(
'Message $messageId not in current state, loading messages around it',
name: 'MessagesNotifier',
);
// Count messages newer than this one
final query = _database.customSelect(
'SELECT COUNT(*) as count FROM chat_messages WHERE room_id = ? AND created_at > ?',
variables: [
Variable.withString(_roomId),
Variable.withDateTime(message.createdAt),
],
readsFrom: {_database.chatMessages},
);
final result = await query.getSingle();
final newerCount = result.read<int>('count');
// Load messages around this position
final offset =
(newerCount - _pageSize ~/ 2).clamp(0, double.infinity).toInt();
developer.log(
'Loading messages with offset $offset, take $_pageSize',
name: 'MessagesNotifier',
);
final loadedMessages = await _getCachedMessages(
offset: offset,
take: _pageSize,
);
// Check if loaded messages are already in current state
final currentIds = currentMessages.map((m) => m.id).toSet();
final newMessages =
loadedMessages.where((m) => !currentIds.contains(m.id)).toList();
developer.log(
'Loaded ${loadedMessages.length} messages, ${newMessages.length} are new',
name: 'MessagesNotifier',
);
if (newMessages.isNotEmpty) {
// Merge with current messages
final allMessages = [...currentMessages, ...newMessages];
final uniqueMessages = <LocalChatMessage>[];
final seenIds = <String>{};
for (final message in allMessages) {
if (seenIds.add(message.id)) {
uniqueMessages.add(message);
}
}
_sortMessages(uniqueMessages);
state = AsyncValue.data(uniqueMessages);
developer.log(
'Updated state with ${uniqueMessages.length} total messages',
name: 'MessagesNotifier',
);
}
final finalIndex = (state.value ?? []).indexWhere(
(m) => m.id == messageId,
);
developer.log(
'Final index for message $messageId is $finalIndex',
name: 'MessagesNotifier',
);
return finalIndex;
} finally {
_isJumping = false;
}
}
bool _hasLink(LocalChatMessage message) { bool _hasLink(LocalChatMessage message) {
final content = message.toRemoteMessage().content; final content = message.toRemoteMessage().content;
if (content == null) return false; if (content == null) return false;
@@ -1454,17 +1572,40 @@ class ChatRoomScreen extends HookConsumerWidget {
(m) => m.id == messageId, (m) => m.id == messageId,
); );
if (messageIndex == -1) { if (messageIndex == -1) {
showSnackBar('messageJumpNotLoaded'.tr()); messagesNotifier.jumpToMessage(messageId).then((index) {
if (index != -1) {
WidgetsBinding.instance.addPostFrameCallback((_) {
listController.animateToItem(
index: index,
scrollController: scrollController,
alignment: 0.5,
duration:
(estimatedDistance) =>
Duration(milliseconds: 250),
curve: (estimatedDistance) => Curves.easeInOut,
);
});
ref
.read(flashingMessagesProvider.notifier)
.update((set) => set.union({messageId}));
}
});
return; return;
} }
listController.animateToItem( WidgetsBinding.instance.addPostFrameCallback((_) {
index: messageIndex, listController.animateToItem(
scrollController: scrollController, index: messageIndex,
alignment: 0.5, scrollController: scrollController,
duration: alignment: 0.5,
(estimatedDistance) => Duration(milliseconds: 250), duration:
curve: (estimatedDistance) => Curves.easeInOut, (estimatedDistance) =>
); Duration(milliseconds: 250),
curve: (estimatedDistance) => Curves.easeInOut,
);
});
ref
.read(flashingMessagesProvider.notifier)
.update((set) => set.union({messageId}));
}, },
progress: attachmentProgress.value[message.id], progress: attachmentProgress.value[message.id],
showAvatar: isLastInGroup, showAvatar: isLastInGroup,

View File

@@ -6,7 +6,7 @@ part of 'room.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$messagesNotifierHash() => r'196fe42438c716b2f975f5f14733974174b3dde7'; String _$messagesNotifierHash() => r'5787fcac9f6c77062aaf854daf2365464f771c2f';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
@@ -67,6 +68,46 @@ class MessageItem extends HookConsumerWidget {
final hasBackground = final hasBackground =
ref.watch(backgroundImageFileProvider).valueOrNull != null; ref.watch(backgroundImageFileProvider).valueOrNull != null;
final flashing = ref.watch(
flashingMessagesProvider.select((set) => set.contains(message.id)),
);
final isFlashing = useState(false);
final flashTimer = useState<Timer?>(null);
useEffect(() {
if (flashing) {
if (flashTimer.value != null) return null;
isFlashing.value = true;
flashTimer.value = Timer.periodic(const Duration(milliseconds: 200), (
timer,
) {
isFlashing.value = !isFlashing.value;
if (timer.tick >= 4) {
// 4 ticks: true, false, true, false
timer.cancel();
flashTimer.value = null;
isFlashing.value = false;
ref
.read(flashingMessagesProvider.notifier)
.update((set) => set.difference({message.id}));
}
});
} else {
flashTimer.value?.cancel();
flashTimer.value = null;
isFlashing.value = false;
}
return () {
flashTimer.value?.cancel();
};
}, [flashing]);
final flashColor =
isFlashing.value
? Theme.of(context).colorScheme.primary.withOpacity(0.8)
: containerColor;
final remoteMessage = message.toRemoteMessage(); final remoteMessage = message.toRemoteMessage();
final sender = remoteMessage.sender; final sender = remoteMessage.sender;
@@ -253,9 +294,10 @@ class MessageItem extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Flexible( Flexible(
child: Container( child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration( decoration: BoxDecoration(
color: containerColor, color: flashColor,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(