diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index 09edce7..4bb3a93 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -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 { ), 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), diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 986cbf2..d6f93ee 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -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 final List _folderChatScrollControllers = []; final List _folderChatScrollListenerFns = []; final Set _selectedChats = {}; + final Set _inflightContactIds = {}; DateTime _storiesRevealLayoutSettleUntil = DateTime.fromMillisecondsSinceEpoch(0); @@ -157,14 +159,11 @@ class _ChatListScreenState extends State 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 } _isInitialLoading = false; }); + _prefetchContactsForChats(chats); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _jumpFolderPageToSelection(); @@ -315,6 +315,33 @@ class _ChatListScreenState extends State return 0; } + void _prefetchContactsForChats(List chats) { + final myId = _profile?.id; + final ids = {}; + 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 _chatsForPageIndex(int pageIndex) { if (_folders.isEmpty) return _chats; if (pageIndex < 0 || pageIndex >= _folders.length) return _chats; @@ -1027,9 +1054,10 @@ class _ChatListScreenState extends State 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 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 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 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 ), ], ), + 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 ), child: CircleAvatar( radius: 26, - backgroundImage: NetworkImage(imageUrl), + backgroundImage: CachedNetworkImageProvider(imageUrl), ), ), const SizedBox(height: 6), @@ -1717,7 +1752,7 @@ class _ChatListScreenState extends State 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 ), child: CircleAvatar( radius: 12, - backgroundImage: NetworkImage(imageUrl), + backgroundImage: CachedNetworkImageProvider(imageUrl), ), ), ); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 28fb06d..93009ba 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -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 if (widget.imageUrl.isNotEmpty) CircleAvatar( radius: 18, - backgroundImage: NetworkImage(widget.imageUrl), + backgroundImage: CachedNetworkImageProvider(widget.imageUrl), ) else CircleAvatar( diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index 3d86580..a2d00d6 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -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 { ), 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), diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 2389838..783996c 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -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 { ), 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 diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 74c4ed9..9042070 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -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, diff --git a/pubspec.yaml b/pubspec.yaml index f681c96..ca642df 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: