From e7f401b01d381e8bbd6d6455c7803163de91643f Mon Sep 17 00:00:00 2001 From: Jganenok Date: Fri, 29 May 2026 11:21:41 +0700 Subject: [PATCH] =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D1=82=D0=BE=20=D0=B1?= =?UTF-8?q?=D0=BE=D1=81=D1=81,=20=D0=BF=D1=80=D0=BE=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=87=D0=B0=D0=BB=D1=8C=D0=BD=D0=B8=D0=BA=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=85=D1=83=D0=B9=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screens/chats/chat_list_screen.dart | 69 +++-- lib/frontend/screens/chats/chat_screen.dart | 41 ++- .../screens/profile/cloud_storage_screen.dart | 47 ++-- .../profile/password_entry_screen.dart | 266 ++++++++++-------- lib/frontend/widgets/message_bubble.dart | 118 ++++---- lib/main.dart | 41 ++- 6 files changed, 334 insertions(+), 248 deletions(-) diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 624382f..eb5b624 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -81,7 +81,7 @@ class _ChatListScreenState extends State double _navPageAnimStart = 0; double _navPageAnimEnd = 0; - double _navDragDx = 0; + final ValueNotifier _navDragDx = ValueNotifier(0); double _navDragBaseLeft = 0; double _revealAnimBegin = 0.0; double _closeAnimBegin = 0.0; @@ -144,7 +144,6 @@ class _ChatListScreenState extends State _isSelectionMode, _shouldCollapseSearch, _selectedChats.length, - _pullRatio, _storiesDockedOpen, _storiesAnimClosing, _storiesOverscrollRevealArmed, @@ -619,7 +618,19 @@ class _ChatListScreenState extends State }); } + int? _pageChatsBaseKey; + final Map> _pageChatsCache = {}; + List _chatsForPageIndex(int pageIndex) { + final baseKey = + Object.hash(identityHashCode(_chats), identityHashCode(_folders)); + if (_pageChatsBaseKey != baseKey) { + _pageChatsBaseKey = baseKey; + _pageChatsCache.clear(); + } + final cached = _pageChatsCache[pageIndex]; + if (cached != null) return cached; + List base; if (_folders.isEmpty) { base = _chats; @@ -634,7 +645,9 @@ class _ChatListScreenState extends State final pinned = base.where((c) => (c.favIndex ?? 0) > 0).toList() ..sort((a, b) => a.favIndex!.compareTo(b.favIndex!)); final regular = base.where((c) => (c.favIndex ?? 0) <= 0).toList(); - return [...pinned, ...regular]; + final result = [...pinned, ...regular]; + _pageChatsCache[pageIndex] = result; + return result; } void _syncFolderChatScrollControllers() { @@ -947,6 +960,7 @@ class _ChatListScreenState extends State } _contactRebuildTimer?.cancel(); _storiesUi.dispose(); + _navDragDx.dispose(); super.dispose(); } @@ -955,7 +969,7 @@ class _ChatListScreenState extends State required double Function(int index) bubbleLeftForIndex, }) { if (_navDragging) { - final left = (_navDragBaseLeft + _navDragDx).clamp( + final left = (_navDragBaseLeft + _navDragDx.value).clamp( bubbleLeftForIndex(0), bubbleLeftForIndex(3), ); @@ -1343,6 +1357,7 @@ class _ChatListScreenState extends State if (hasSeparator && index == pinnedCount) { return Padding( + key: const ValueKey('pinned_divider'), padding: const EdgeInsets.symmetric(horizontal: 20), child: Divider( height: 1, @@ -1357,10 +1372,13 @@ class _ChatListScreenState extends State final isPinned = (chat.favIndex ?? 0) > 0; if (chat.type.isNotEmpty && chat.type == "DIALOG" && chat.id != 0) { - final secondId = chat.participants.entries - .where((entry) => entry.key != _profile?.id) - .first - .key; + int secondId = _profile?.id ?? 0; + for (final entry in chat.participants.entries) { + if (entry.key != _profile?.id) { + secondId = entry.key; + break; + } + } final name = ContactCache.get(secondId); final avatar = ContactCache.getAvatar(secondId); // ContactCache.isOfficial covers contacts loaded via opcode 32; @@ -1510,12 +1528,6 @@ class _ChatListScreenState extends State final minBubbleLeft = bubbleLeftForIndex(0); final maxBubbleLeft = bubbleLeftForIndex(3); - final bubbleLeft = _navDragging - ? (_navDragBaseLeft + _navDragDx).clamp(minBubbleLeft, maxBubbleLeft) - : leftOffset; - - final navRowT = ((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0); - double navInterpolatedWidth(int tabIndex, double rowT) { final rt = rowT.clamp(0.0, 3.0); final i0 = rt.floor().clamp(0, 3); @@ -1568,39 +1580,46 @@ class _ChatListScreenState extends State if (_isSelectionMode) return; _navPageAnimController.stop(); _navPageAnimController.value = 1.0; + _navDragDx.value = 0; setState(() { _navDragging = true; - _navDragDx = 0; _navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex); }); }, onHorizontalDragUpdate: (details) { if (!_navDragging) return; - setState(() { - _navDragDx += details.delta.dx; - }); + _navDragDx.value += details.delta.dx; }, onHorizontalDragEnd: (_) { if (!_navDragging) return; - final left = (_navDragBaseLeft + _navDragDx).clamp( + final left = (_navDragBaseLeft + _navDragDx.value).clamp( minBubbleLeft, maxBubbleLeft, ); final next = indexForBubbleLeft(left); + _navDragDx.value = 0; setState(() { _currentNavIndex = next; _navDragging = false; - _navDragDx = 0; }); }, onHorizontalDragCancel: () { if (!_navDragging) return; + _navDragDx.value = 0; setState(() { _navDragging = false; - _navDragDx = 0; }); }, - child: Stack( + child: ValueListenableBuilder( + valueListenable: _navDragDx, + builder: (context, navDragDx, _) { + final bubbleLeft = _navDragging + ? (_navDragBaseLeft + navDragDx) + .clamp(minBubbleLeft, maxBubbleLeft) + : leftOffset; + final navRowT = + ((bubbleLeft - 4) / inactiveWidth).clamp(0.0, 3.0); + return Stack( clipBehavior: Clip.hardEdge, children: [ AnimatedPositioned( @@ -1672,6 +1691,8 @@ class _ChatListScreenState extends State ), ), ], + ); + }, ), ), ), @@ -1717,7 +1738,8 @@ class _ChatListScreenState extends State width: pageW * 4, height: pageH, child: AnimatedBuilder( - animation: _navPageAnimController, + animation: Listenable.merge( + [_navPageAnimController, _navDragDx]), child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -2066,6 +2088,7 @@ class _ChatListScreenState extends State final isSelected = _selectedChats.contains(id); return InkWell( + key: ValueKey('chat_$id'), onTap: () { if (_isSelectionMode) { _toggleSelection(id); diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 06467c2..b7c54d7 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -115,6 +115,7 @@ class _ChatScreenState extends State int _otherStatus = 0; int? _otherSeenTime; final ValueNotifier _headerStatusNotifier = ValueNotifier(''); + final ValueNotifier _otherReadTime = ValueNotifier(0); int _tempIdCounter = 0; late final AnimationController _attachAnim; @@ -123,6 +124,8 @@ class _ChatScreenState extends State Timer? _shimmerStartTimer; bool _historyKickedOff = false; List _messages = []; + List? _combinedItemsCache; + int? _combinedItemsKey; int _myId = 0; CachedChat? chat; @@ -183,6 +186,7 @@ class _ChatScreenState extends State chat = value.first; }); _recomputeHeaderStatus(); + _syncOtherReadTime(); } }).catchError((_) {}); @@ -395,6 +399,7 @@ class _ChatScreenState extends State } _typingTimers.clear(); _headerStatusNotifier.dispose(); + _otherReadTime.dispose(); _uploadStatus.dispose(); _attachAnim.dispose(); _messageController.dispose(); @@ -419,17 +424,28 @@ class _ChatScreenState extends State } } - String? _effectiveStatus(CachedMessage msg) { - if (msg.senderId != _myId) return null; - if (msg.status == 'sending' || msg.status == 'error') return msg.status; + int _computeOtherReadTime() { final c = chat; - if (c == null) return 'sent'; + if (c == null) return 0; int otherReadTime = 0; for (final entry in c.participants.entries) { if (entry.key != _myId && entry.value > otherReadTime) { otherReadTime = entry.value; } } + return otherReadTime; + } + + void _syncOtherReadTime() { + final t = _computeOtherReadTime(); + if (_otherReadTime.value != t) _otherReadTime.value = t; + } + + String? _effectiveStatus(CachedMessage msg) { + if (msg.senderId != _myId) return null; + if (msg.status == 'sending' || msg.status == 'error') return msg.status; + if (chat == null) return 'sent'; + final otherReadTime = _otherReadTime.value; if (otherReadTime > 0 && otherReadTime >= msg.time) return 'read'; return 'sent'; } @@ -555,9 +571,8 @@ class _ChatScreenState extends State final c = chat; if (c == null) return; if (c.participants[userId] == mark) return; - setState(() { - c.participants[userId] = mark; - }); + c.participants[userId] = mark; + _syncOtherReadTime(); } Future _sendMessage() async { @@ -614,6 +629,7 @@ class _ChatScreenState extends State ChatsModule.refreshChats(api, [widget.chatId]).then((list) { if (!mounted || list.isEmpty) return; setState(() => chat = list.first); + _syncOtherReadTime(); }), ); } @@ -708,6 +724,10 @@ class _ChatScreenState extends State } List _buildCombinedItems() { + final key = Object.hashAll(_messages.map(identityHashCode)); + final cached = _combinedItemsCache; + if (cached != null && _combinedItemsKey == key) return cached; + final List items = []; final Set usedDates = {}; @@ -740,6 +760,8 @@ class _ChatScreenState extends State } _separatorKeys.removeWhere((k, _) => !usedDates.contains(k)); + _combinedItemsCache = items; + _combinedItemsKey = key; return items; } @@ -1022,7 +1044,9 @@ class _ChatScreenState extends State return Stack( key: _listKey, children: [ - ValueListenableBuilder( + ValueListenableBuilder( + valueListenable: _otherReadTime, + builder: (context, _, _) => ValueListenableBuilder( valueListenable: AppCacheExtent.current, builder: (context, cacheExtent, _) => ListView.builder( controller: _scrollController, @@ -1082,6 +1106,7 @@ class _ChatScreenState extends State }, ), ), + ), Positioned( top: 8, left: 0, diff --git a/lib/frontend/screens/profile/cloud_storage_screen.dart b/lib/frontend/screens/profile/cloud_storage_screen.dart index 1f353b0..a08b595 100644 --- a/lib/frontend/screens/profile/cloud_storage_screen.dart +++ b/lib/frontend/screens/profile/cloud_storage_screen.dart @@ -43,7 +43,7 @@ class _CloudStorageScreenState extends State int? _accountId; List _files = []; bool _isUploading = false; - double _uploadProgress = 0; + final ValueNotifier _uploadProgress = ValueNotifier(0); bool _animateNewCard = false; @override @@ -68,16 +68,19 @@ class _CloudStorageScreenState extends State } mgr.onProgress = (progress, _) { if (!mounted) return; - setState(() { _isUploading = true; _uploadProgress = progress; }); + if (!_isUploading) setState(() => _isUploading = true); + _uploadProgress.value = progress; }; mgr.onDone = (file) { if (!mounted) return; - setState(() { _isUploading = false; _uploadProgress = 0; }); + _uploadProgress.value = 0; + setState(() => _isUploading = false); _prependFile(file); }; mgr.onError = (msg) { if (!mounted) return; - setState(() { _isUploading = false; _uploadProgress = 0; }); + _uploadProgress.value = 0; + setState(() => _isUploading = false); showCustomNotification(context, 'Ошибка: $msg'); }; } @@ -91,6 +94,7 @@ class _CloudStorageScreenState extends State _mode.dispose(); _pageController.dispose(); _currentFilePage.dispose(); + _uploadProgress.dispose(); super.dispose(); } @@ -218,7 +222,8 @@ class _CloudStorageScreenState extends State final picked = result.files.first; if (picked.path == null) return; - setState(() { _isUploading = true; _uploadProgress = 0; }); + _uploadProgress.value = 0; + setState(() => _isUploading = true); await UploadManager.instance.start( chatId: chatId, @@ -427,17 +432,27 @@ class _CloudStorageScreenState extends State const SizedBox(height: 16), ], if (_isUploading) ...[ - LinearProgressIndicator( - value: _uploadProgress, - borderRadius: BorderRadius.circular(4), - minHeight: 5, - color: cs.primary, - backgroundColor: cs.surfaceContainerHighest, - ), - const SizedBox(height: 8), - Text( - 'Загрузка ${(_uploadProgress * 100).toStringAsFixed(0)}%', - style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ValueListenableBuilder( + valueListenable: _uploadProgress, + builder: (context, progress, _) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LinearProgressIndicator( + value: progress, + borderRadius: BorderRadius.circular(4), + minHeight: 5, + color: cs.primary, + backgroundColor: cs.surfaceContainerHighest, + ), + const SizedBox(height: 8), + Text( + 'Загрузка ${(progress * 100).toStringAsFixed(0)}%', + style: + TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), ), ] else if (_files.isEmpty) ...[ Text( diff --git a/lib/frontend/screens/profile/password_entry_screen.dart b/lib/frontend/screens/profile/password_entry_screen.dart index 895028d..f127133 100644 --- a/lib/frontend/screens/profile/password_entry_screen.dart +++ b/lib/frontend/screens/profile/password_entry_screen.dart @@ -307,33 +307,40 @@ class TwoFactorSetupScreen extends StatefulWidget { class _TwoFactorSetupScreenState extends State { final _passwordController = TextEditingController(); + final _confirmController = TextEditingController(); final _hintController = TextEditingController(); final _emailController = TextEditingController(); final _codeController = TextEditingController(); int _step = 0; - bool _isLoading = false; + final ValueNotifier _isLoading = ValueNotifier(false); String? _trackId; String? _errorMessage; @override void dispose() { _passwordController.dispose(); + _confirmController.dispose(); _hintController.dispose(); _emailController.dispose(); _codeController.dispose(); + _isLoading.dispose(); super.dispose(); } Future _nextStep() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); + _isLoading.value = true; + setState(() => _errorMessage = null); try { switch (_step) { case 0: + if (_passwordController.text.length < 6) { + setState( + () => _errorMessage = 'Пароль должен быть минимум 6 символов', + ); + break; + } final trackId = await accountModule.create2faTrack(); setState(() { _trackId = trackId; @@ -341,10 +348,8 @@ class _TwoFactorSetupScreenState extends State { }); break; case 1: - if (_passwordController.text.length < 6) { - setState( - () => _errorMessage = 'Пароль должен быть минимум 6 символов', - ); + if (_confirmController.text != _passwordController.text) { + setState(() => _errorMessage = 'Пароли не совпадают'); break; } await accountModule.set2faPassword( @@ -392,7 +397,7 @@ class _TwoFactorSetupScreenState extends State { setState(() => _errorMessage = e.toString()); } finally { if (mounted) { - setState(() => _isLoading = false); + _isLoading.value = false; } } } @@ -458,26 +463,29 @@ class _TwoFactorSetupScreenState extends State { const SizedBox(height: 24), SizedBox( width: double.infinity, - child: FilledButton( - onPressed: _isLoading ? null : _nextStep, - style: FilledButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.onPrimary, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + child: ValueListenableBuilder( + valueListenable: _isLoading, + builder: (context, loading, _) => FilledButton( + onPressed: loading ? null : _nextStep, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : Text(_step == 4 ? 'Установить пароль' : 'Продолжить'), ), - child: _isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) - : Text(_step == 4 ? 'Установить пароль' : 'Продолжить'), ), ), ], @@ -591,7 +599,7 @@ class _TwoFactorSetupScreenState extends State { ), const SizedBox(height: 16), _PasswordField( - controller: _passwordController, + controller: _confirmController, hintText: 'Повторите пароль', ), ], @@ -714,7 +722,7 @@ class TwoFactorManageScreen extends StatefulWidget { class _TwoFactorManageScreenState extends State { final _passwordController = TextEditingController(); - bool _isLoading = false; + final ValueNotifier _isLoading = ValueNotifier(false); bool _isAuthenticated = false; String? _trackId; TwoFactorDetails? _details; @@ -723,14 +731,13 @@ class _TwoFactorManageScreenState extends State { @override void dispose() { _passwordController.dispose(); + _isLoading.dispose(); super.dispose(); } Future _authenticate() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); + _isLoading.value = true; + setState(() => _errorMessage = null); try { _trackId = await accountModule.enter2faPanel(); @@ -743,7 +750,7 @@ class _TwoFactorManageScreenState extends State { } catch (e) { setState(() => _errorMessage = 'Неверный пароль'); } finally { - if (mounted) setState(() => _isLoading = false); + if (mounted) _isLoading.value = false; } } @@ -807,26 +814,29 @@ class _TwoFactorManageScreenState extends State { const SizedBox(height: 24), SizedBox( width: double.infinity, - child: FilledButton( - onPressed: _isLoading ? null : _authenticate, - style: FilledButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.onPrimary, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + child: ValueListenableBuilder( + valueListenable: _isLoading, + builder: (context, loading, _) => FilledButton( + onPressed: loading ? null : _authenticate, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : const Text('Продолжить'), ), - child: _isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) - : const Text('Продолжить'), ), ), ], @@ -917,13 +927,14 @@ class _TwoFactorPasswordChangeScreenState extends State { final _passwordController = TextEditingController(); final _hintController = TextEditingController(); - bool _isLoading = false; + final ValueNotifier _isLoading = ValueNotifier(false); String? _errorMessage; @override void dispose() { _passwordController.dispose(); _hintController.dispose(); + _isLoading.dispose(); super.dispose(); } @@ -933,10 +944,8 @@ class _TwoFactorPasswordChangeScreenState return; } - setState(() { - _isLoading = true; - _errorMessage = null; - }); + _isLoading.value = true; + setState(() => _errorMessage = null); try { final trackId = await accountModule.enter2faPanel(); @@ -956,7 +965,7 @@ class _TwoFactorPasswordChangeScreenState } catch (e) { setState(() => _errorMessage = e.toString()); } finally { - if (mounted) setState(() => _isLoading = false); + if (mounted) _isLoading.value = false; } } @@ -1037,26 +1046,29 @@ class _TwoFactorPasswordChangeScreenState const SizedBox(height: 24), SizedBox( width: double.infinity, - child: FilledButton( - onPressed: _isLoading ? null : _changePassword, - style: FilledButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.onPrimary, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + child: ValueListenableBuilder( + valueListenable: _isLoading, + builder: (context, loading, _) => FilledButton( + onPressed: loading ? null : _changePassword, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : const Text('Сохранить'), ), - child: _isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) - : const Text('Сохранить'), ), ), ], @@ -1080,7 +1092,7 @@ class _TwoFactorEmailChangeScreenState final _emailController = TextEditingController(); final _codeController = TextEditingController(); int _step = 0; - bool _isLoading = false; + final ValueNotifier _isLoading = ValueNotifier(false); String? _trackId; String? _errorMessage; @@ -1089,14 +1101,13 @@ class _TwoFactorEmailChangeScreenState _passwordController.dispose(); _emailController.dispose(); _codeController.dispose(); + _isLoading.dispose(); super.dispose(); } Future _nextStep() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); + _isLoading.value = true; + setState(() => _errorMessage = null); try { switch (_step) { @@ -1140,7 +1151,7 @@ class _TwoFactorEmailChangeScreenState } catch (e) { setState(() => _errorMessage = e.toString()); } finally { - if (mounted) setState(() => _isLoading = false); + if (mounted) _isLoading.value = false; } } @@ -1256,26 +1267,29 @@ class _TwoFactorEmailChangeScreenState const SizedBox(height: 24), SizedBox( width: double.infinity, - child: FilledButton( - onPressed: _isLoading ? null : _nextStep, - style: FilledButton.styleFrom( - backgroundColor: cs.primary, - foregroundColor: cs.onPrimary, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + child: ValueListenableBuilder( + valueListenable: _isLoading, + builder: (context, loading, _) => FilledButton( + onPressed: loading ? null : _nextStep, + style: FilledButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onPrimary, + ), + ) + : Text(_step == 2 ? 'Сохранить' : 'Продолжить'), ), - child: _isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) - : Text(_step == 2 ? 'Сохранить' : 'Продолжить'), ), ), ], @@ -1294,20 +1308,19 @@ class TwoFactorRemoveScreen extends StatefulWidget { class _TwoFactorRemoveScreenState extends State { final _passwordController = TextEditingController(); - bool _isLoading = false; + final ValueNotifier _isLoading = ValueNotifier(false); String? _errorMessage; @override void dispose() { _passwordController.dispose(); + _isLoading.dispose(); super.dispose(); } Future _remove2fa() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); + _isLoading.value = true; + setState(() => _errorMessage = null); try { final trackId = await accountModule.enter2faPanel(); @@ -1323,7 +1336,7 @@ class _TwoFactorRemoveScreenState extends State { } catch (e) { setState(() => _errorMessage = e.toString()); } finally { - if (mounted) setState(() => _isLoading = false); + if (mounted) _isLoading.value = false; } } @@ -1402,26 +1415,29 @@ class _TwoFactorRemoveScreenState extends State { const SizedBox(height: 24), SizedBox( width: double.infinity, - child: FilledButton( - onPressed: _isLoading ? null : _remove2fa, - style: FilledButton.styleFrom( - backgroundColor: cs.error, - foregroundColor: cs.onError, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + child: ValueListenableBuilder( + valueListenable: _isLoading, + builder: (context, loading, _) => FilledButton( + onPressed: loading ? null : _remove2fa, + style: FilledButton.styleFrom( + backgroundColor: cs.error, + foregroundColor: cs.onError, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), + child: loading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: cs.onError, + ), + ) + : const Text('Удалить пароль'), ), - child: _isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onError, - ), - ) - : const Text('Удалить пароль'), ), ), ], diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index ca642c6..e3c4478 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -50,6 +50,10 @@ class MessageBubble extends StatelessWidget { static const Radius _smallRadius = Radius.circular(4); static const Radius _photoRadius = Radius.circular(photoBorderRadius); + static final Color _reactionChipBg = Colors.black.withValues(alpha: 0.18); + static const BorderRadius _reactionChipRadius = + BorderRadius.all(Radius.circular(10)); + static Color bubbleTextColor(BuildContext context) => Theme.of(context).brightness == Brightness.dark ? Colors.white @@ -271,14 +275,30 @@ class MessageBubble extends StatelessWidget { final showAvatarSlot = !isMe; final showAvatar = showAvatarSlot && chatType == "CHAT" && - nextMessage?.senderId != message.senderId && - prevMessage?.senderId == message.senderId; + nextMessage?.senderId != message.senderId; - final bubbleListenable = Listenable.merge([ - AppBubbleShape.current, - AppBubbleBehavior.current, - ?reactionsListenable, - ]); + final maxBubbleWidth = MediaQuery.sizeOf(context).width * 0.75; + final bubbleColor = + isMe ? cs.primaryContainer : cs.surfaceContainerHighest; + + _BubbleCtx makeCtx() => _BubbleCtx( + context: context, + cs: cs, + text: textColor, + shape: shape, + contentType: contentType, + hasPhotoWithCaption: hasPhotoCap, + hasMultiplePhotosNoCaption: hasMultiPhotos, + reactionInfo: _resolveReactionInfo(), + ); + + final Widget bubbleContent = + reactionsListenable != null && contentType == MessageType.text + ? ValueListenableBuilder?>( + valueListenable: reactionsListenable!, + builder: (context, _, _) => _buildContent(makeCtx()), + ) + : _buildContent(makeCtx()); return GestureDetector( onTap: Haptics.tap, @@ -310,63 +330,35 @@ class MessageBubble extends StatelessWidget { isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ ListenableBuilder( - listenable: bubbleListenable, - builder: (context, _) { - final reactionInfo = _resolveReactionInfo(); - final ctx = _BubbleCtx( - context: context, - cs: cs, - text: textColor, - shape: shape, - contentType: contentType, - hasPhotoWithCaption: hasPhotoCap, - hasMultiplePhotosNoCaption: hasMultiPhotos, - reactionInfo: reactionInfo, - ); - return AnimatedSize( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOutCubic, - alignment: isMe - ? Alignment.bottomRight - : Alignment.bottomLeft, - child: Container( - constraints: BoxConstraints( - maxWidth: - MediaQuery.sizeOf(context).width * 0.75, - ), - decoration: BoxDecoration( - color: isMe - ? cs.primaryContainer - : cs.surfaceContainerHighest, - borderRadius: _borderRadiusFor( - AppBubbleShape.current.value, - AppBubbleBehavior.current.value, - shape, - hasPhotoCap, - hasMultiPhotos, - ), - ), - padding: padding, - child: _buildContent(ctx), + listenable: Listenable.merge([ + AppBubbleShape.current, + AppBubbleBehavior.current, + ]), + builder: (context, child) => Container( + constraints: BoxConstraints(maxWidth: maxBubbleWidth), + decoration: BoxDecoration( + color: bubbleColor, + borderRadius: _borderRadiusFor( + AppBubbleShape.current.value, + AppBubbleBehavior.current.value, + shape, + hasPhotoCap, + hasMultiPhotos, ), - ); - }, + ), + padding: padding, + child: child, + ), + child: bubbleContent, ), if (contentType != MessageType.text) - AnimatedSize( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOutCubic, - alignment: isMe - ? Alignment.centerRight - : Alignment.centerLeft, - child: reactionsListenable != null - ? ValueListenableBuilder?>( - valueListenable: reactionsListenable!, - builder: (context, info, _) => - _buildReactionsBarFor(cs, info), - ) - : _buildReactionsBar(cs), - ), + reactionsListenable != null + ? ValueListenableBuilder?>( + valueListenable: reactionsListenable!, + builder: (context, info, _) => + _buildReactionsBarFor(cs, info), + ) + : _buildReactionsBar(cs), ], ), ], @@ -432,8 +424,8 @@ class MessageBubble extends StatelessWidget { decoration: BoxDecoration( color: isYours ? cs.primary.withValues(alpha: 0.22) - : Colors.black.withValues(alpha: 0.18), - borderRadius: BorderRadius.circular(10), + : _reactionChipBg, + borderRadius: _reactionChipRadius, ), child: Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/main.dart b/lib/main.dart index 9f85669..b007b7e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -66,18 +66,33 @@ void main() async { } attachInfoCacheApi(api); ChatsModule.attachGlobalPushHandlers(api); + + final packageInfoFuture = PackageInfo.fromPlatform(); + final localeFuture = _loadInitialLocale(); + final hapticsFuture = Haptics.load(); + final prefsFuture = SharedPreferences.getInstance(); + final accentFuture = AppAccent.load(); + final bubbleShapeFuture = AppBubbleShape.load(); + final bubbleBehaviorFuture = AppBubbleBehavior.load(); + final cacheExtentFuture = AppCacheExtent.load(); + final themeModeFuture = AppThemeModeConfig.load(); + final amoledFuture = AppAmoled.load(); + final themeScheduleFuture = AppThemeSchedule.load(); + final messageActionsFuture = AppMessageActionsStyle.load(); + final swipeBackFuture = AppSwipeBackDesktop.load(); + await api.connect(); - final packageInfo = await PackageInfo.fromPlatform(); + final packageInfo = await packageInfoFuture; if (packageInfo.packageName == 'ru.oneme.app') { await PushService.instance.init(api: api, account: accountModule); } - final initialLocale = await _loadInitialLocale(); + final initialLocale = await localeFuture; - await Haptics.load(); + await hapticsFuture; - final prefs = await SharedPreferences.getInstance(); + final prefs = await prefsFuture; await FileHistoryCache.load(prefs); final initialFpsOverlay = prefs.getBool('dev_fps_overlay') ?? false; final initialVpnBypass = prefs.getBool(VpnBypassService.prefKey) ?? false; @@ -87,15 +102,15 @@ void main() async { final initialFontScale = AppFonts.clampScale( prefs.getDouble(AppFonts.scalePrefKey) ?? AppFonts.defaultScale, ); - final initialAccentSeed = await AppAccent.load(); - AppBubbleShape.current.value = await AppBubbleShape.load(); - AppBubbleBehavior.current.value = await AppBubbleBehavior.load(); - AppCacheExtent.current.value = await AppCacheExtent.load(); - AppThemeModeConfig.current.value = await AppThemeModeConfig.load(); - AppAmoled.current.value = await AppAmoled.load(); - AppThemeSchedule.current.value = await AppThemeSchedule.load(); - AppMessageActionsStyle.current.value = await AppMessageActionsStyle.load(); - AppSwipeBackDesktop.current.value = await AppSwipeBackDesktop.load(); + final initialAccentSeed = await accentFuture; + AppBubbleShape.current.value = await bubbleShapeFuture; + AppBubbleBehavior.current.value = await bubbleBehaviorFuture; + AppCacheExtent.current.value = await cacheExtentFuture; + AppThemeModeConfig.current.value = await themeModeFuture; + AppAmoled.current.value = await amoledFuture; + AppThemeSchedule.current.value = await themeScheduleFuture; + AppMessageActionsStyle.current.value = await messageActionsFuture; + AppSwipeBackDesktop.current.value = await swipeBackFuture; runApp( KometApp( initialLocale: initialLocale,