Merge pull request #18 from KometTeam/fix/tab-switch-freeze

fix: устранить фриз при переключении вкладок в ChatListScreen
This commit is contained in:
klockky
2026-05-13 18:46:29 +03:00
committed by GitHub
7 changed files with 133 additions and 69 deletions
+5 -3
View File
@@ -1,3 +1,4 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../main.dart' show api;
@@ -160,10 +161,11 @@ class _CallsTabState extends State<CallsTab> {
),
child: ClipOval(
child: call.avatarUrl != null && call.avatarUrl!.isNotEmpty
? Image.network(
call.avatarUrl!,
? CachedNetworkImage(
imageUrl: call.avatarUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (context, url, error) =>
_buildPlaceholderAvatar(cs, call.name),
)
: _buildPlaceholderAvatar(cs, call.name),
@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:komet/backend/modules/messages.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -93,6 +94,7 @@ class _ChatListScreenState extends State<ChatListScreen>
final List<ScrollController> _folderChatScrollControllers = [];
final List<VoidCallback> _folderChatScrollListenerFns = [];
final Set<String> _selectedChats = {};
final Set<int> _inflightContactIds = {};
DateTime _storiesRevealLayoutSettleUntil =
DateTime.fromMillisecondsSinceEpoch(0);
@@ -157,14 +159,11 @@ class _ChatListScreenState extends State<ChatListScreen>
vsync: this,
duration: const Duration(milliseconds: 350),
);
_navPageAnimController =
AnimationController(
vsync: this,
duration: const Duration(milliseconds: 350),
value: 1.0,
)..addListener(() {
if (mounted) setState(() {});
});
_navPageAnimController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 350),
value: 1.0,
);
_shimmerController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
@@ -262,6 +261,7 @@ class _ChatListScreenState extends State<ChatListScreen>
}
_isInitialLoading = false;
});
_prefetchContactsForChats(chats);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_jumpFolderPageToSelection();
@@ -315,6 +315,33 @@ class _ChatListScreenState extends State<ChatListScreen>
return 0;
}
void _prefetchContactsForChats(List<CachedChat> chats) {
final myId = _profile?.id;
final ids = <int>{};
for (final chat in chats) {
if (chat.type == 'DIALOG' && chat.id != 0) {
for (final entry in chat.participants.entries) {
if (entry.key != myId) {
ids.add(entry.key);
break;
}
}
}
final senderId = chat.lastMsgSenderId;
if (senderId != null) ids.add(senderId);
}
ids.removeWhere((id) => ContactCache.get(id) != null);
ids.removeAll(_inflightContactIds);
if (ids.isEmpty) return;
_inflightContactIds.addAll(ids);
for (final id in ids) {
messagesModule.searchContactById(id).whenComplete(() {
_inflightContactIds.remove(id);
if (mounted) setState(() {});
});
}
}
List<CachedChat> _chatsForPageIndex(int pageIndex) {
if (_folders.isEmpty) return _chats;
if (pageIndex < 0 || pageIndex >= _folders.length) return _chats;
@@ -1027,9 +1054,10 @@ class _ChatListScreenState extends State<ChatListScreen>
final chat = chats[index];
if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) {
final secondId = chat.participants.entries.where((entry) => entry.key != _profile?.id).first.key;
// TODO: Нормальное кеширование контактов
final ss = messagesModule.searchContactById(secondId);
final secondId = chat.participants.entries
.where((entry) => entry.key != _profile?.id)
.first
.key;
final name = ContactCache.get(secondId);
final avatar = ContactCache.getAvatar(secondId);
@@ -1044,10 +1072,6 @@ class _ChatListScreenState extends State<ChatListScreen>
isMuted: chat.dontDisturbUntil > 0,
);
} else {
if (chat.lastMsgSenderId != null ) {
final ss = messagesModule.searchContactById(chat.lastMsgSenderId!);
}
final name = chat.lastMsgSenderId != null
? ContactCache.get(chat.lastMsgSenderId!)
: null;
@@ -1356,17 +1380,6 @@ class _ChatListScreenState extends State<ChatListScreen>
return lo + 4;
}
final pageDisplayT = _effectivePageNavRowT(
inactiveWidth: inactiveWidth,
bubbleLeftForIndex: bubbleLeftForPageT,
);
final showChatsFab =
!_isSelectionMode &&
(_navDragging || _navPageAnimController.isAnimating
? pageDisplayT < 1.0
: _currentNavIndex == 0);
return Stack(
children: [
ClipRect(
@@ -1380,8 +1393,8 @@ class _ChatListScreenState extends State<ChatListScreen>
child: SizedBox(
width: pageW * 4,
height: pageH,
child: Transform.translate(
offset: Offset(-pageDisplayT * pageW, 0),
child: AnimatedBuilder(
animation: _navPageAnimController,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -1415,15 +1428,37 @@ class _ChatListScreenState extends State<ChatListScreen>
),
],
),
builder: (context, child) {
final pageDisplayT = _effectivePageNavRowT(
inactiveWidth: inactiveWidth,
bubbleLeftForIndex: bubbleLeftForPageT,
);
return Transform.translate(
offset: Offset(-pageDisplayT * pageW, 0),
child: child,
);
},
),
),
),
),
),
_buildDockedBottomNav(cs, navInnerW, bottomInset),
ListenableBuilder(
listenable: _fabController,
builder: (context, child) {
AnimatedBuilder(
animation: Listenable.merge([
_fabController,
_navPageAnimController,
]),
builder: (context, _) {
final pageDisplayT = _effectivePageNavRowT(
inactiveWidth: inactiveWidth,
bubbleLeftForIndex: bubbleLeftForPageT,
);
final showChatsFab =
!_isSelectionMode &&
(_navDragging || _navPageAnimController.isAnimating
? pageDisplayT < 1.0
: _currentNavIndex == 0);
final double val = Curves.easeOutCubic.transform(
_fabController.value,
);
@@ -1565,7 +1600,7 @@ class _ChatListScreenState extends State<ChatListScreen>
),
child: CircleAvatar(
radius: 26,
backgroundImage: NetworkImage(imageUrl),
backgroundImage: CachedNetworkImageProvider(imageUrl),
),
),
const SizedBox(height: 6),
@@ -1717,7 +1752,7 @@ class _ChatListScreenState extends State<ChatListScreen>
radius: 24,
backgroundColor: cs.surfaceContainerHighest,
backgroundImage: imageUrl.isNotEmpty
? NetworkImage(imageUrl)
? CachedNetworkImageProvider(imageUrl)
: null,
child: imageUrl.isEmpty
? Text(
@@ -2019,7 +2054,7 @@ class _ChatListScreenState extends State<ChatListScreen>
),
child: CircleAvatar(
radius: 12,
backgroundImage: NetworkImage(imageUrl),
backgroundImage: CachedNetworkImageProvider(imageUrl),
),
),
);
+2 -1
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:komet/backend/modules/chats.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -270,7 +271,7 @@ class _ChatScreenState extends State<ChatScreen>
if (widget.imageUrl.isNotEmpty)
CircleAvatar(
radius: 18,
backgroundImage: NetworkImage(widget.imageUrl),
backgroundImage: CachedNetworkImageProvider(widget.imageUrl),
)
else
CircleAvatar(
@@ -1,3 +1,4 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/storage/app_database.dart';
@@ -84,10 +85,11 @@ class _ContactsTabState extends State<ContactsTab> {
),
child: ClipOval(
child: contact.baseUrl != null && contact.baseUrl!.isNotEmpty
? Image.network(
contact.baseUrl!,
? CachedNetworkImage(
imageUrl: contact.baseUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (context, url, error) =>
_buildPlaceholderAvatar(cs, nameToDisplay),
)
: _buildPlaceholderAvatar(cs, nameToDisplay),
+20 -4
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:package_info_plus/package_info_plus.dart';
@@ -320,10 +321,11 @@ class _SettingsTabState extends State<SettingsTab> {
),
child: ClipOval(
child: _profile?.baseUrl != null && _profile!.baseUrl!.isNotEmpty
? Image.network(
_profile!.baseUrl!,
? CachedNetworkImage(
imageUrl: _profile!.baseUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (context, url, error) =>
_buildPlaceholderAvatar(cs, name),
)
: _buildPlaceholderAvatar(cs, name),
@@ -501,7 +503,21 @@ class _PhoneSpoilerState extends State<_PhoneSpoiler>
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat();
);
if (!widget.isVisible) {
_controller.repeat();
}
}
@override
void didUpdateWidget(covariant _PhoneSpoiler oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isVisible == oldWidget.isVisible) return;
if (widget.isVisible) {
_controller.stop();
} else if (!_controller.isAnimating) {
_controller.repeat();
}
}
@override
+31 -24
View File
@@ -1,3 +1,4 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:komet/backend/modules/chats.dart';
import 'package:komet/backend/modules/contacts.dart';
@@ -310,7 +311,7 @@ class MessageBubble extends StatelessWidget {
&& nextMessage?.senderId != message.senderId && prevMessage?.senderId == message.senderId)
CircleAvatar(
radius: 15,
backgroundImage: NetworkImage(senderAvatar),
backgroundImage: CachedNetworkImageProvider(senderAvatar),
backgroundColor: cs.primaryContainer,
)
else if (displaySender != null && !isMe && chatType != "DIALOG"
@@ -454,7 +455,7 @@ class MessageBubble extends StatelessWidget {
if (senderAvatar != null && senderAvatar.isNotEmpty)
CircleAvatar(
radius: 10,
backgroundImage: NetworkImage(senderAvatar),
backgroundImage: CachedNetworkImageProvider(senderAvatar),
backgroundColor: cs.primaryContainer,
)
else
@@ -706,7 +707,7 @@ class MessageBubble extends StatelessWidget {
if (senderAvatar != null && senderAvatar.isNotEmpty)
CircleAvatar(
radius: 10,
backgroundImage: NetworkImage(senderAvatar),
backgroundImage: CachedNetworkImageProvider(senderAvatar),
backgroundColor: cs.primaryContainer,
)
else
@@ -779,7 +780,7 @@ class MessageBubble extends StatelessWidget {
if (senderAvatar != null && senderAvatar.isNotEmpty)
CircleAvatar(
radius: 10,
backgroundImage: NetworkImage(senderAvatar),
backgroundImage: CachedNetworkImageProvider(senderAvatar),
backgroundColor: cs.primaryContainer,
)
else
@@ -845,7 +846,7 @@ class MessageBubble extends StatelessWidget {
if (senderAvatar != null && senderAvatar.isNotEmpty)
CircleAvatar(
radius: 10,
backgroundImage: NetworkImage(senderAvatar),
backgroundImage: CachedNetworkImageProvider(senderAvatar),
backgroundColor: cs.primaryContainer,
)
else
@@ -911,12 +912,13 @@ class MessageBubble extends StatelessWidget {
child: Stack(
children: [
if (imageUrl.isNotEmpty)
Image.network(
imageUrl,
CachedNetworkImage(
imageUrl: imageUrl,
width: constrainedWidth,
height: constrainedHeight,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildPhotoPlaceholder(
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) => _buildPhotoPlaceholder(
ctx,
constrainedWidth,
constrainedHeight,
@@ -1015,12 +1017,13 @@ class MessageBubble extends StatelessWidget {
child: Stack(
children: [
if (imageUrl.isNotEmpty)
Image.network(
imageUrl,
CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
errorBuilder: (_, __, ___) =>
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) =>
_buildPhotoPlaceholder(ctx, 100, 100),
)
else
@@ -1049,12 +1052,13 @@ class MessageBubble extends StatelessWidget {
child: Stack(
children: [
if (imageUrl.isNotEmpty)
Image.network(
imageUrl,
CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
errorBuilder: (_, __, ___) =>
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) =>
_buildPhotoPlaceholder(ctx, 100, 100),
)
else
@@ -1276,12 +1280,13 @@ class MessageBubble extends StatelessWidget {
child: Stack(
children: [
if (preview.isNotEmpty)
Image.network(
preview,
CachedNetworkImage(
imageUrl: preview,
width: 150,
height: 150,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) =>
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) =>
_buildPhotoPlaceholder(ctx, 150, 150),
)
else
@@ -1333,10 +1338,11 @@ class MessageBubble extends StatelessWidget {
child: photoUrl != null && photoUrl.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Image.network(
photoUrl,
child: CachedNetworkImage(
imageUrl: photoUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Icon(
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) => Icon(
Symbols.person,
color: isMe ? Colors.white : cs.primary,
size: 24,
@@ -1435,7 +1441,7 @@ class MessageBubble extends StatelessWidget {
if (senderAvatar != null && senderAvatar.isNotEmpty)
CircleAvatar(
radius: 10,
backgroundImage: NetworkImage(senderAvatar),
backgroundImage: CachedNetworkImageProvider(senderAvatar),
backgroundColor: cs.primaryContainer,
)
else
@@ -1478,10 +1484,11 @@ class MessageBubble extends StatelessWidget {
child: photoUrl != null && photoUrl.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Image.network(
photoUrl,
child: CachedNetworkImage(
imageUrl: photoUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Icon(
fadeInDuration: const Duration(milliseconds: 120),
errorWidget: (_, __, ___) => Icon(
Symbols.person,
color: isMe ? Colors.white : cs.primary,
size: 24,
+1
View File
@@ -53,6 +53,7 @@ dependencies:
shared_preferences: ^2.5.4
package_info_plus: ^9.0.1
mobile_scanner: ^7.2.0
cached_network_image: ^3.4.1
dev_dependencies:
flutter_test: