diff --git a/android/app/src/main/jniLibs/arm64-v8a/libeslz4-android64.so b/android/app/src/main/jniLibs/arm64-v8a/libeslz4-android64.so new file mode 100644 index 0000000..5b51523 Binary files /dev/null and b/android/app/src/main/jniLibs/arm64-v8a/libeslz4-android64.so differ diff --git a/android/app/src/main/jniLibs/arm64-v8a/liblz4.so b/android/app/src/main/jniLibs/arm64-v8a/liblz4.so new file mode 100644 index 0000000..5b51523 Binary files /dev/null and b/android/app/src/main/jniLibs/arm64-v8a/liblz4.so differ diff --git a/android/app/src/main/jniLibs/armeabi-v7a/libeslz4-android32.so b/android/app/src/main/jniLibs/armeabi-v7a/libeslz4-android32.so new file mode 100644 index 0000000..5fe6718 Binary files /dev/null and b/android/app/src/main/jniLibs/armeabi-v7a/libeslz4-android32.so differ diff --git a/android/app/src/main/jniLibs/armeabi-v7a/liblz4.so b/android/app/src/main/jniLibs/armeabi-v7a/liblz4.so new file mode 100644 index 0000000..5fe6718 Binary files /dev/null and b/android/app/src/main/jniLibs/armeabi-v7a/liblz4.so differ diff --git a/android/app/src/main/jniLibs/x86/libeslz4-android32.so b/android/app/src/main/jniLibs/x86/libeslz4-android32.so new file mode 100644 index 0000000..c493832 Binary files /dev/null and b/android/app/src/main/jniLibs/x86/libeslz4-android32.so differ diff --git a/android/app/src/main/jniLibs/x86/liblz4.so b/android/app/src/main/jniLibs/x86/liblz4.so new file mode 100644 index 0000000..c493832 Binary files /dev/null and b/android/app/src/main/jniLibs/x86/liblz4.so differ diff --git a/android/app/src/main/jniLibs/x86_64/libeslz4-android64.so b/android/app/src/main/jniLibs/x86_64/libeslz4-android64.so new file mode 100644 index 0000000..c3dc979 Binary files /dev/null and b/android/app/src/main/jniLibs/x86_64/libeslz4-android64.so differ diff --git a/android/app/src/main/jniLibs/x86_64/liblz4.so b/android/app/src/main/jniLibs/x86_64/liblz4.so new file mode 100644 index 0000000..c3dc979 Binary files /dev/null and b/android/app/src/main/jniLibs/x86_64/liblz4.so differ diff --git a/lib/api/api_service.dart b/lib/api/api_service.dart index 6f0f956..7b01e8d 100644 --- a/lib/api/api_service.dart +++ b/lib/api/api_service.dart @@ -18,6 +18,7 @@ import 'package:gwid/services/account_manager.dart'; import 'package:gwid/services/avatar_cache_service.dart'; import 'package:gwid/services/cache_service.dart'; import 'package:gwid/services/chat_cache_service.dart'; +import 'package:gwid/services/profile_cache_service.dart'; import 'package:gwid/spoofing_service.dart'; import 'package:http/http.dart' as http; import 'package:image_picker/image_picker.dart'; diff --git a/lib/api/api_service_chats.dart b/lib/api/api_service_chats.dart index ffe5832..175d34a 100644 --- a/lib/api/api_service_chats.dart +++ b/lib/api/api_service_chats.dart @@ -69,6 +69,37 @@ extension ApiServiceChats on ApiService { _sendInitialSetupRequests(); } + if (profile != null && authToken != null) { + try { + final accountManager = AccountManager(); + await accountManager.initialize(); + final currentAccount = accountManager.currentAccount; + if (currentAccount != null && currentAccount.token == authToken) { + final profileObj = Profile.fromJson(profile); + await accountManager.updateAccountProfile( + currentAccount.id, + profileObj, + ); + + try { + final profileCache = ProfileCacheService(); + await profileCache.initialize(); + await profileCache.syncWithServerProfile(profileObj); + } catch (e) { + print('[ProfileCache] Ошибка синхронизации профиля: $e'); + } + + print( + '[_sendAuthRequestAfterHandshake] ✅ Профиль сохранен в AccountManager', + ); + } + } catch (e) { + print( + '[_sendAuthRequestAfterHandshake] Ошибка сохранения профиля в AccountManager: $e', + ); + } + } + if (_onlineCompleter != null && !_onlineCompleter!.isCompleted) { _onlineCompleter!.complete(); } @@ -95,9 +126,10 @@ extension ApiServiceChats on ApiService { }; _lastChatsPayload = result; - final contacts = contactListJson - .map((json) => Contact.fromJson(json)) - .toList(); + final contacts = (contactListJson as List) + .map((json) => Contact.fromJson(json as Map)) + .toList() + .cast(); updateContactCache(contacts); _lastChatsAt = DateTime.now(); _preloadContactAvatars(contacts); @@ -241,7 +273,11 @@ extension ApiServiceChats on ApiService { } try { - final payload = {"chatsCount": 100}; + // Используем opcode 48 для запроса конкретных чатов + // chatIds:[0] - это "Избранное" (Saved Messages) + final payload = { + "chatIds": [0], + }; final int chatSeq = _sendMessage(48, payload); final chatResponse = await messages.firstWhere( @@ -265,15 +301,16 @@ extension ApiServiceChats on ApiService { contactIds.addAll(participants.keys.map((id) => int.parse(id))); } - final int contactSeq = _sendMessage(32, { - "contactIds": contactIds.toList(), - }); - final contactResponse = await messages.firstWhere( - (msg) => msg['seq'] == contactSeq, - ); - - final List contactListJson = - contactResponse['payload']?['contacts'] ?? []; + List contactListJson = []; + if (contactIds.isNotEmpty) { + final int contactSeq = _sendMessage(32, { + "contactIds": contactIds.toList(), + }); + final contactResponse = await messages.firstWhere( + (msg) => msg['seq'] == contactSeq, + ); + contactListJson = contactResponse['payload']?['contacts'] ?? []; + } final result = { 'chats': chatListJson, @@ -283,8 +320,8 @@ extension ApiServiceChats on ApiService { }; _lastChatsPayload = result; - final contacts = contactListJson - .map((json) => Contact.fromJson(json)) + final List contacts = contactListJson + .map((json) => Contact.fromJson(json as Map)) .toList(); updateContactCache(contacts); _lastChatsAt = DateTime.now(); @@ -295,7 +332,7 @@ extension ApiServiceChats on ApiService { unawaited(_chatCacheService.cacheContacts(contacts)); return result; } catch (e) { - print('Ошибка получения чатов: $e'); + print('Ошибка получения чатов через opcode 48: $e'); rethrow; } } @@ -445,6 +482,14 @@ extension ApiServiceChats on ApiService { currentAccount.id, profileObj, ); + + try { + final profileCache = ProfileCacheService(); + await profileCache.initialize(); + await profileCache.syncWithServerProfile(profileObj); + } catch (e) { + print('[ProfileCache] Ошибка синхронизации профиля: $e'); + } } } catch (e) { print('Ошибка сохранения профиля в AccountManager: $e'); @@ -510,8 +555,8 @@ extension ApiServiceChats on ApiService { }; _lastChatsPayload = result; - final contacts = contactListJson - .map((json) => Contact.fromJson(json)) + final List contacts = contactListJson + .map((json) => Contact.fromJson(json as Map)) .toList(); updateContactCache(contacts); _lastChatsAt = DateTime.now(); diff --git a/lib/api/api_service_connection.dart b/lib/api/api_service_connection.dart index 1492261..a22d4cf 100644 --- a/lib/api/api_service_connection.dart +++ b/lib/api/api_service_connection.dart @@ -679,6 +679,7 @@ extension ApiServiceConnection on ApiService { _reconnectTimer?.cancel(); _isSessionOnline = false; _isSessionReady = false; + _handshakeSent = false; _onlineCompleter = Completer(); _chatsFetchedInThisSession = false; diff --git a/lib/chat_screen.dart b/lib/chat_screen.dart index 297409f..518488c 100644 --- a/lib/chat_screen.dart +++ b/lib/chat_screen.dart @@ -17,8 +17,12 @@ import 'package:image_picker/image_picker.dart'; import 'package:gwid/services/chat_cache_service.dart'; import 'package:gwid/services/avatar_cache_service.dart'; import 'package:gwid/services/chat_read_settings_service.dart'; +import 'package:gwid/services/contact_local_names_service.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:gwid/screens/group_settings_screen.dart'; +import 'package:gwid/screens/edit_contact_screen.dart'; +import 'package:gwid/widgets/contact_name_widget.dart'; +import 'package:gwid/widgets/contact_avatar_widget.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:video_player/video_player.dart'; @@ -91,6 +95,8 @@ class _ChatScreenState extends State { ItemPositionsListener.create(); final ValueNotifier _showScrollToBottomNotifier = ValueNotifier(false); + bool _isUserAtBottom = true; + late Contact _currentContact; Message? _pinnedMessage; @@ -419,7 +425,9 @@ class _ChatScreenState extends State { _itemPositionsListener.itemPositions.addListener(() { final positions = _itemPositionsListener.itemPositions.value; if (positions.isNotEmpty) { - _showScrollToBottomNotifier.value = positions.first.index > 0; + final isAtBottom = positions.first.index == 0; + _isUserAtBottom = isAtBottom; + _showScrollToBottomNotifier.value = !isAtBottom; } }); @@ -855,7 +863,7 @@ class _ChatScreenState extends State { } } - void _addMessage(Message message) { + void _addMessage(Message message, {bool forceScroll = false}) { if (_messages.any((m) => m.id == message.id)) { print('Сообщение ${message.id} уже существует, пропускаем добавление'); return; @@ -863,6 +871,10 @@ class _ChatScreenState extends State { ApiService.instance.clearCacheForChat(widget.chatId); + final wasAtBottom = _isUserAtBottom; + + final isMyMessage = message.senderId == _actualMyId; + final lastMessage = _messages.isNotEmpty ? _messages.last : null; _messages.add(message); @@ -887,6 +899,18 @@ class _ChatScreenState extends State { final isFirstInGroup = lastMessageItem == null || !isGrouped; final isLastInGroup = true; + if (isGrouped && lastMessageItem != null) { + _chatItems.removeLast(); + _chatItems.add( + MessageItem( + lastMessageItem.message, + isFirstInGroup: lastMessageItem.isFirstInGroup, + isLastInGroup: false, + isGrouped: lastMessageItem.isGrouped, + ), + ); + } + final messageItem = MessageItem( message, isFirstInGroup: isFirstInGroup, @@ -907,6 +931,15 @@ class _ChatScreenState extends State { if (mounted) { setState(() {}); + + if ((wasAtBottom || isMyMessage || forceScroll) && + _itemScrollController.isAttached) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_itemScrollController.isAttached) { + _itemScrollController.jumpTo(index: 0); + } + }); + } } } @@ -1198,6 +1231,7 @@ class _ChatScreenState extends State { showDialog( context: context, + barrierDismissible: true, builder: (context) => _EditMessageDialog( initialText: message.text, onSave: (newText) async { @@ -1276,9 +1310,21 @@ class _ChatScreenState extends State { return; } - showDialog( + showGeneralDialog( context: context, - builder: (context) => AlertDialog( + barrierDismissible: true, + barrierLabel: '', + transitionDuration: const Duration(milliseconds: 250), + transitionBuilder: (context, animation, secondaryAnimation, child) { + return ScaleTransition( + scale: CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + child: FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), + child: child, + ), + ); + }, + pageBuilder: (context, animation, secondaryAnimation) => AlertDialog( title: const Text('Переслать сообщение'), content: SizedBox( width: double.maxFinite, @@ -1432,17 +1478,41 @@ class _ChatScreenState extends State { } void _showComplaintDialog(String messageId) { - showDialog( + showGeneralDialog( context: context, - builder: (context) => + barrierDismissible: true, + barrierLabel: '', + transitionDuration: const Duration(milliseconds: 250), + transitionBuilder: (context, animation, secondaryAnimation, child) { + return ScaleTransition( + scale: CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + child: FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), + child: child, + ), + ); + }, + pageBuilder: (context, animation, secondaryAnimation) => ComplaintDialog(messageId: messageId, chatId: widget.chatId), ); } void _showBlockDialog() { - showDialog( + showGeneralDialog( context: context, - builder: (context) => AlertDialog( + barrierDismissible: true, + barrierLabel: '', + transitionDuration: const Duration(milliseconds: 250), + transitionBuilder: (context, animation, secondaryAnimation, child) { + return ScaleTransition( + scale: CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + child: FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), + child: child, + ), + ); + }, + pageBuilder: (context, animation, secondaryAnimation) => AlertDialog( title: const Text('Заблокировать контакт'), content: Text( 'Вы уверены, что хотите заблокировать ${_currentContact.name}?', @@ -1502,9 +1572,21 @@ class _ChatScreenState extends State { } void _showUnblockDialog() { - showDialog( + showGeneralDialog( context: context, - builder: (context) => AlertDialog( + barrierDismissible: true, + barrierLabel: '', + transitionDuration: const Duration(milliseconds: 250), + transitionBuilder: (context, animation, secondaryAnimation, child) { + return ScaleTransition( + scale: CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + child: FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), + child: child, + ), + ); + }, + pageBuilder: (context, animation, secondaryAnimation) => AlertDialog( title: const Text('Разблокировать контакт'), content: Text( 'Вы уверены, что хотите разблокировать ${_currentContact.name}?', @@ -1564,26 +1646,51 @@ class _ChatScreenState extends State { } void _showWallpaperDialog() { - showDialog( + showGeneralDialog( context: context, - builder: (context) => _WallpaperSelectionDialog( - chatId: widget.chatId, - onImageSelected: (imagePath) async { - Navigator.of(context).pop(); - await _setChatWallpaper(imagePath); - }, - onRemoveWallpaper: () async { - Navigator.of(context).pop(); - await _removeChatWallpaper(); - }, - ), + barrierDismissible: true, + barrierLabel: '', + transitionDuration: const Duration(milliseconds: 250), + transitionBuilder: (context, animation, secondaryAnimation, child) { + return ScaleTransition( + scale: CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + child: FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), + child: child, + ), + ); + }, + pageBuilder: (context, animation, secondaryAnimation) => + _WallpaperSelectionDialog( + chatId: widget.chatId, + onImageSelected: (imagePath) async { + Navigator.of(context).pop(); + await _setChatWallpaper(imagePath); + }, + onRemoveWallpaper: () async { + Navigator.of(context).pop(); + await _removeChatWallpaper(); + }, + ), ); } void _showClearHistoryDialog() { - showDialog( + showGeneralDialog( context: context, - builder: (context) => AlertDialog( + barrierDismissible: true, + barrierLabel: '', + transitionDuration: const Duration(milliseconds: 250), + transitionBuilder: (context, animation, secondaryAnimation, child) { + return ScaleTransition( + scale: CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + child: FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), + child: child, + ), + ); + }, + pageBuilder: (context, animation, secondaryAnimation) => AlertDialog( title: const Text('Очистить историю чата'), content: Text( 'Вы уверены, что хотите очистить историю чата с ${_currentContact.name}? Это действие нельзя отменить.', @@ -1633,9 +1740,21 @@ class _ChatScreenState extends State { } void _showDeleteChatDialog() { - showDialog( + showGeneralDialog( context: context, - builder: (context) => AlertDialog( + barrierDismissible: true, + barrierLabel: '', + transitionDuration: const Duration(milliseconds: 250), + transitionBuilder: (context, animation, secondaryAnimation, child) { + return ScaleTransition( + scale: CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + child: FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), + child: child, + ), + ); + }, + pageBuilder: (context, animation, secondaryAnimation) => AlertDialog( title: const Text('Удалить чат'), content: Text( 'Вы уверены, что хотите удалить чат с ${_currentContact.name}? Это действие нельзя отменить.', //1231231233 @@ -1694,9 +1813,21 @@ class _ChatScreenState extends State { } void _showLeaveGroupDialog() { - showDialog( + showGeneralDialog( context: context, - builder: (context) => AlertDialog( + barrierDismissible: true, + barrierLabel: '', + transitionDuration: const Duration(milliseconds: 250), + transitionBuilder: (context, animation, secondaryAnimation, child) { + return ScaleTransition( + scale: CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + child: FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), + child: child, + ), + ); + }, + pageBuilder: (context, animation, secondaryAnimation) => AlertDialog( title: const Text('Выйти из группы'), content: Text( 'Вы уверены, что хотите выйти из группы "${widget.contact.name}"?', @@ -1857,304 +1988,464 @@ class _ChatScreenState extends State { return Scaffold( extendBodyBehindAppBar: theme.useGlassPanels, + resizeToAvoidBottomInset: false, appBar: _buildAppBar(), body: Stack( children: [ Positioned.fill(child: _buildChatWallpaper(theme)), Column( children: [ - if (_pinnedMessage != null) - SafeArea( - child: PinnedMessageWidget( - pinnedMessage: _pinnedMessage!, - contacts: _contactDetailsCache, - myId: _actualMyId ?? 0, - onTap: () { - // TODO: Прокрутить к закрепленному сообщению - }, - onClose: () { - setState(() { - _pinnedMessage = null; - }); - }, - ), - ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: (child, animation) { + return SlideTransition( + position: Tween( + begin: const Offset(0, -0.5), + end: Offset.zero, + ).animate(animation), + child: FadeTransition(opacity: animation, child: child), + ); + }, + child: _pinnedMessage != null + ? SafeArea( + key: ValueKey(_pinnedMessage!.id), + child: PinnedMessageWidget( + pinnedMessage: _pinnedMessage!, + contacts: _contactDetailsCache, + myId: _actualMyId ?? 0, + onTap: () { + // TODO: Прокрутить к закрепленному сообщению + }, + onClose: () { + setState(() { + _pinnedMessage = null; + }); + }, + ), + ) + : const SizedBox.shrink(key: ValueKey('empty')), + ), Expanded( child: Stack( children: [ - if (!_isIdReady || _isLoadingHistory) - const Center(child: CircularProgressIndicator()) - else - ScrollablePositionedList.builder( - itemScrollController: _itemScrollController, - itemPositionsListener: _itemPositionsListener, - reverse: true, - padding: EdgeInsets.fromLTRB( - 8.0, - 8.0, // Убираем дополнительный padding сверху, т.к. теперь pinned message в Column - 8.0, - widget.isChannel ? 30.0 : 110.0, - ), - itemCount: _chatItems.length, - itemBuilder: (context, index) { - final mappedIndex = _chatItems.length - 1 - index; - final item = _chatItems[mappedIndex]; - final isLastVisual = index == _chatItems.length - 1; - - if (isLastVisual && _hasMore && !_isLoadingMore) { - _loadMore(); - } - - if (item is MessageItem) { - final message = item.message; - final key = _messageKeys.putIfAbsent( - message.id, - () => GlobalKey(), - ); - final bool isHighlighted = - _isSearching && - _searchResults.isNotEmpty && - _currentResultIndex != -1 && - message.id == - _searchResults[_currentResultIndex].id; - - final isControlMessage = message.attaches.any( - (a) => a['_type'] == 'CONTROL', - ); - if (isControlMessage) { - return _ControlMessageChip( - message: message, - contacts: _contactDetailsCache, - myId: _actualMyId ?? widget.myId, - ); - } - - final bool isMe = - item.message.senderId == _actualMyId; - - MessageReadStatus? readStatus; - if (isMe) { - final messageId = item.message.id; - if (messageId.startsWith('local_')) { - readStatus = MessageReadStatus.sending; - } else { - readStatus = MessageReadStatus.sent; - } - } - - String? forwardedFrom; - String? forwardedFromAvatarUrl; - if (message.isForwarded) { - final link = message.link; - if (link is Map) { - final chatName = link['chatName'] as String?; - final chatIconUrl = - link['chatIconUrl'] as String?; - - if (chatName != null) { - forwardedFrom = chatName; - forwardedFromAvatarUrl = chatIconUrl; - } else { - final forwardedMessage = - link['message'] as Map?; - final originalSenderId = - forwardedMessage?['sender'] as int?; - if (originalSenderId != null) { - final originalSenderContact = - _contactDetailsCache[originalSenderId]; - if (originalSenderContact == null) { - _loadContactIfNeeded(originalSenderId); - forwardedFrom = - 'Участник $originalSenderId'; - forwardedFromAvatarUrl = null; - } else { - forwardedFrom = - originalSenderContact.name; - forwardedFromAvatarUrl = - originalSenderContact.photoBaseUrl; - } - } - } - } - } - String? senderName; - if (widget.isGroupChat && !isMe) { - bool shouldShowName = true; - if (mappedIndex > 0) { - final previousItem = - _chatItems[mappedIndex - 1]; - if (previousItem is MessageItem) { - final previousMessage = previousItem.message; - if (previousMessage.senderId == - message.senderId) { - final timeDifferenceInMinutes = - (message.time - previousMessage.time) / - (1000 * 60); - if (timeDifferenceInMinutes < 5) { - shouldShowName = false; - } - } - } - } - if (shouldShowName) { - final senderContact = - _contactDetailsCache[message.senderId]; - if (senderContact != null) { - senderName = senderContact.name; - } else { - senderName = 'ID ${message.senderId}'; - _loadContactIfNeeded(message.senderId); - } - } - } - final hasPhoto = item.message.attaches.any( - (a) => a['_type'] == 'PHOTO', - ); - final isNew = !_animatedMessageIds.contains( - item.message.id, - ); - final deferImageLoading = - hasPhoto && - isNew && - !_anyOptimize && - !context - .read() - .animatePhotoMessages; - - final bubble = ChatMessageBubble( - key: key, - message: item.message, - isMe: isMe, - readStatus: readStatus, - deferImageLoading: deferImageLoading, - myUserId: _actualMyId, - chatId: widget.chatId, - onReply: widget.isChannel - ? null - : () => _replyToMessage(item.message), - onForward: () => _forwardMessage(item.message), - onEdit: isMe - ? () => _editMessage(item.message) - : null, - canEditMessage: isMe - ? item.message.canEdit(_actualMyId!) - : null, - onDeleteForMe: isMe - ? () async { - await ApiService.instance.deleteMessage( - widget.chatId, - item.message.id, - forMe: true, - ); - widget.onChatUpdated?.call(); - } - : null, - onDeleteForAll: isMe - ? () async { - await ApiService.instance.deleteMessage( - widget.chatId, - item.message.id, - forMe: false, - ); - widget.onChatUpdated?.call(); - } - : null, - onReaction: (emoji) { - _updateReactionOptimistically( - item.message.id, - emoji, - ); - ApiService.instance.sendReaction( - widget.chatId, - item.message.id, - emoji, - ); - widget.onChatUpdated?.call(); - }, - onRemoveReaction: () { - _removeReactionOptimistically(item.message.id); - ApiService.instance.removeReaction( - widget.chatId, - item.message.id, - ); - widget.onChatUpdated?.call(); - }, - isGroupChat: widget.isGroupChat, - isChannel: widget.isChannel, - senderName: senderName, - forwardedFrom: forwardedFrom, - forwardedFromAvatarUrl: forwardedFromAvatarUrl, - contactDetailsCache: _contactDetailsCache, - onReplyTap: _scrollToMessage, - useAutoReplyColor: context - .read() - .useAutoReplyColor, - customReplyColor: context - .read() - .customReplyColor, - isFirstInGroup: item.isFirstInGroup, - isLastInGroup: item.isLastInGroup, - isGrouped: item.isGrouped, - avatarVerticalOffset: - -8.0, // Смещение аватарки вверх на 8px - onComplain: () => - _showComplaintDialog(item.message.id), - ); - - Widget finalMessageWidget = bubble as Widget; - - if (isHighlighted) { - return Container( - margin: const EdgeInsets.symmetric(vertical: 2), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .primaryContainer - .withOpacity(0.5), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.primary, - width: 1.5, - ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: Tween(begin: 0.8, end: 1.0).animate( + CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ), + ), + child: child, + ), + ); + }, + child: (!_isIdReady || _isLoadingHistory) + ? const Center( + key: ValueKey('loading'), + child: CircularProgressIndicator(), + ) + : AnimatedPadding( + key: const ValueKey('chat_list'), + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOutCubic, + padding: EdgeInsets.only( + bottom: MediaQuery.of( + context, + ).viewInsets.bottom, + ), + child: ScrollablePositionedList.builder( + itemScrollController: _itemScrollController, + itemPositionsListener: _itemPositionsListener, + reverse: true, + padding: EdgeInsets.fromLTRB( + 8.0, + 8.0, + 8.0, + widget.isChannel ? 16.0 : 100.0, ), - child: finalMessageWidget, - ); - } + itemCount: _chatItems.length, + itemBuilder: (context, index) { + final mappedIndex = + _chatItems.length - 1 - index; + final item = _chatItems[mappedIndex]; + final isLastVisual = + index == _chatItems.length - 1; - return finalMessageWidget; - } else if (item is DateSeparatorItem) { - return _DateSeparatorChip(date: item.date); - } - if (isLastVisual && _isLoadingMore) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Center(child: CircularProgressIndicator()), - ); - } - return const SizedBox.shrink(); - }, - ), - if (_showScrollToBottomNotifier.value) - Positioned( - right: 16, - bottom: 120, - child: Opacity( - opacity: 0.85, + if (isLastVisual && + _hasMore && + !_isLoadingMore) { + _loadMore(); + } + + if (item is MessageItem) { + final message = item.message; + final key = _messageKeys.putIfAbsent( + message.id, + () => GlobalKey(), + ); + final bool isHighlighted = + _isSearching && + _searchResults.isNotEmpty && + _currentResultIndex != -1 && + message.id == + _searchResults[_currentResultIndex] + .id; + + final isControlMessage = message.attaches + .any((a) => a['_type'] == 'CONTROL'); + if (isControlMessage) { + return _ControlMessageChip( + message: message, + contacts: _contactDetailsCache, + myId: _actualMyId ?? widget.myId, + ); + } + + final bool isMe = + item.message.senderId == _actualMyId; + + MessageReadStatus? readStatus; + if (isMe) { + final messageId = item.message.id; + if (messageId.startsWith('local_')) { + readStatus = MessageReadStatus.sending; + } else { + readStatus = MessageReadStatus.sent; + } + } + + String? forwardedFrom; + String? forwardedFromAvatarUrl; + if (message.isForwarded) { + final link = message.link; + if (link is Map) { + final chatName = + link['chatName'] as String?; + final chatIconUrl = + link['chatIconUrl'] as String?; + + if (chatName != null) { + forwardedFrom = chatName; + forwardedFromAvatarUrl = chatIconUrl; + } else { + final forwardedMessage = + link['message'] + as Map?; + final originalSenderId = + forwardedMessage?['sender'] + as int?; + if (originalSenderId != null) { + final originalSenderContact = + _contactDetailsCache[originalSenderId]; + if (originalSenderContact == null) { + _loadContactIfNeeded( + originalSenderId, + ); + forwardedFrom = + 'Участник $originalSenderId'; + forwardedFromAvatarUrl = null; + } else { + forwardedFrom = + originalSenderContact.name; + forwardedFromAvatarUrl = + originalSenderContact + .photoBaseUrl; + } + } + } + } + } + String? senderName; + if (widget.isGroupChat && !isMe) { + bool shouldShowName = true; + if (mappedIndex > 0) { + final previousItem = + _chatItems[mappedIndex - 1]; + if (previousItem is MessageItem) { + final previousMessage = + previousItem.message; + if (previousMessage.senderId == + message.senderId) { + final timeDifferenceInMinutes = + (message.time - + previousMessage.time) / + (1000 * 60); + if (timeDifferenceInMinutes < 5) { + shouldShowName = false; + } + } + } + } + if (shouldShowName) { + final senderContact = + _contactDetailsCache[message + .senderId]; + if (senderContact != null) { + senderName = getContactDisplayName( + contactId: senderContact.id, + originalName: senderContact.name, + originalFirstName: + senderContact.firstName, + originalLastName: + senderContact.lastName, + ); + } else { + senderName = 'ID ${message.senderId}'; + _loadContactIfNeeded( + message.senderId, + ); + } + } + } + final hasPhoto = item.message.attaches.any( + (a) => a['_type'] == 'PHOTO', + ); + final isNew = !_animatedMessageIds.contains( + item.message.id, + ); + final deferImageLoading = + hasPhoto && + isNew && + !_anyOptimize && + !context + .read() + .animatePhotoMessages; + + final bubble = ChatMessageBubble( + key: key, + message: item.message, + isMe: isMe, + readStatus: readStatus, + deferImageLoading: deferImageLoading, + myUserId: _actualMyId, + chatId: widget.chatId, + onReply: widget.isChannel + ? null + : () => _replyToMessage(item.message), + onForward: () => + _forwardMessage(item.message), + onEdit: isMe + ? () => _editMessage(item.message) + : null, + canEditMessage: isMe + ? item.message.canEdit(_actualMyId!) + : null, + onDeleteForMe: isMe + ? () async { + await ApiService.instance + .deleteMessage( + widget.chatId, + item.message.id, + forMe: true, + ); + widget.onChatUpdated?.call(); + } + : null, + onDeleteForAll: isMe + ? () async { + await ApiService.instance + .deleteMessage( + widget.chatId, + item.message.id, + forMe: false, + ); + widget.onChatUpdated?.call(); + } + : null, + onReaction: (emoji) { + _updateReactionOptimistically( + item.message.id, + emoji, + ); + ApiService.instance.sendReaction( + widget.chatId, + item.message.id, + emoji, + ); + widget.onChatUpdated?.call(); + }, + onRemoveReaction: () { + _removeReactionOptimistically( + item.message.id, + ); + ApiService.instance.removeReaction( + widget.chatId, + item.message.id, + ); + widget.onChatUpdated?.call(); + }, + isGroupChat: widget.isGroupChat, + isChannel: widget.isChannel, + senderName: senderName, + forwardedFrom: forwardedFrom, + forwardedFromAvatarUrl: + forwardedFromAvatarUrl, + contactDetailsCache: _contactDetailsCache, + onReplyTap: _scrollToMessage, + useAutoReplyColor: context + .read() + .useAutoReplyColor, + customReplyColor: context + .read() + .customReplyColor, + isFirstInGroup: item.isFirstInGroup, + isLastInGroup: item.isLastInGroup, + isGrouped: item.isGrouped, + avatarVerticalOffset: + -8.0, // Смещение аватарки вверх на 8px + onComplain: () => + _showComplaintDialog(item.message.id), + ); + + Widget finalMessageWidget = + bubble as Widget; + + if (isHighlighted) { + return TweenAnimationBuilder( + duration: const Duration( + milliseconds: 600, + ), + tween: Tween( + begin: 0.3, + end: 0.6, + ), + curve: Curves.easeInOut, + builder: (context, value, child) { + return Container( + margin: const EdgeInsets.symmetric( + vertical: 2, + ), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primaryContainer + .withOpacity(value), + borderRadius: + BorderRadius.circular(16), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.primary, + width: 1.5, + ), + ), + child: child, + ); + }, + child: finalMessageWidget, + ); + } + + // Плавное появление новых сообщений + if (isNew && !_anyOptimize) { + return TweenAnimationBuilder( + duration: const Duration( + milliseconds: 400, + ), + tween: Tween( + begin: 0.0, + end: 1.0, + ), + curve: Curves.easeOutCubic, + builder: (context, value, child) { + return Opacity( + opacity: value, + child: Transform.translate( + offset: Offset( + 0, + 20 * (1 - value), + ), + child: child, + ), + ); + }, + child: finalMessageWidget, + ); + } + + return finalMessageWidget; + } else if (item is DateSeparatorItem) { + return _DateSeparatorChip(date: item.date); + } + if (isLastVisual && _isLoadingMore) { + return TweenAnimationBuilder( + duration: const Duration( + milliseconds: 300, + ), + tween: Tween( + begin: 0.0, + end: 1.0, + ), + curve: Curves.easeOut, + builder: (context, value, child) { + return Opacity( + opacity: value, + child: Transform.scale( + scale: 0.7 + (0.3 * value), + child: child, + ), + ); + }, + child: const Padding( + padding: EdgeInsets.symmetric( + vertical: 12, + ), + child: Center( + child: CircularProgressIndicator(), + ), + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 100), + curve: Curves.easeOutQuad, + right: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 100, + child: AnimatedScale( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutBack, + scale: _showScrollToBottomNotifier.value ? 1.0 : 0.0, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: _showScrollToBottomNotifier.value + ? 1.0 + : 0.0, child: FloatingActionButton( mini: true, onPressed: _scrollToBottom, + elevation: 4, child: const Icon(Icons.arrow_downward_rounded), ), ), ), + ), ], ), ), ], ), - Positioned(left: 0, right: 0, bottom: 0, child: _buildTextInput()), + AnimatedPositioned( + duration: const Duration(milliseconds: 100), + curve: Curves.easeOutQuad, + left: 8, + right: 8, + bottom: MediaQuery.of(context).viewInsets.bottom + 12, + child: _buildTextInput(), + ), ], ), ); @@ -2172,9 +2463,20 @@ class _ChatScreenState extends State { ); }, transitionsBuilder: (context, animation, secondaryAnimation, child) { - return FadeTransition(opacity: animation, child: child); + return FadeTransition( + opacity: CurvedAnimation( + parent: animation, + curve: Curves.easeInOutCubic, + ), + child: ScaleTransition( + scale: Tween(begin: 0.95, end: 1.0).animate( + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + ), + child: child, + ), + ); }, - transitionDuration: const Duration(milliseconds: 300), + transitionDuration: const Duration(milliseconds: 350), ), ); } @@ -2256,13 +2558,37 @@ class _ChatScreenState extends State { onPressed: () { if (_actualMyId == null) return; Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => GroupSettingsScreen( - chatId: widget.chatId, - initialContact: _currentContact, - myId: _actualMyId!, - onChatUpdated: widget.onChatUpdated, - ), + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => + GroupSettingsScreen( + chatId: widget.chatId, + initialContact: _currentContact, + myId: _actualMyId!, + onChatUpdated: widget.onChatUpdated, + ), + transitionsBuilder: + (context, animation, secondaryAnimation, child) { + return SlideTransition( + position: + Tween( + begin: const Offset(1.0, 0.0), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ), + ), + child: FadeTransition( + opacity: CurvedAnimation( + parent: animation, + curve: Curves.easeOut, + ), + child: child, + ), + ); + }, + transitionDuration: const Duration(milliseconds: 350), ), ); }, @@ -2430,18 +2756,13 @@ class _ChatScreenState extends State { color: Theme.of(context).colorScheme.onPrimaryContainer, ), ) - : CircleAvatar( + : ContactAvatarWidget( + contactId: widget.contact.id, + originalAvatarUrl: widget.contact.photoBaseUrl, radius: 18, - backgroundImage: widget.contact.photoBaseUrl != null - ? NetworkImage(widget.contact.photoBaseUrl!) - : null, - child: widget.contact.photoBaseUrl == null - ? Text( - widget.contact.name.isNotEmpty - ? widget.contact.name[0].toUpperCase() - : '?', - ) - : null, + fallbackText: widget.contact.name.isNotEmpty + ? widget.contact.name[0].toUpperCase() + : '?', ), ), ), @@ -2457,8 +2778,11 @@ class _ChatScreenState extends State { Row( children: [ Expanded( - child: Text( - widget.contact.name, + child: ContactNameWidget( + contactId: widget.contact.id, + originalName: widget.contact.name, + originalFirstName: widget.contact.firstName, + originalLastName: widget.contact.lastName, overflow: TextOverflow.ellipsis, ), ), @@ -2635,7 +2959,8 @@ class _ChatScreenState extends State { } if (theme.useGlassPanels) { - return ClipRect( + return ClipRRect( + borderRadius: BorderRadius.circular(16), child: BackdropFilter( filter: ImageFilter.blur( sigmaX: theme.bottomBarBlur, @@ -2643,49 +2968,51 @@ class _ChatScreenState extends State { ), child: Container( padding: const EdgeInsets.symmetric( - horizontal: 8.0, - vertical: 12.0, + horizontal: 12.0, + vertical: 8.0, ), decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surface.withOpacity(theme.bottomBarOpacity), - border: Border( - top: BorderSide( - color: Theme.of(context).dividerColor, - width: 0.5, + color: Theme.of(context).colorScheme.surface.withOpacity(0.7), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 2), ), - ), + ], ), child: SafeArea( top: false, + bottom: false, child: Column( mainAxisSize: MainAxisSize.min, children: [ if (_replyingToMessage != null) ...[ Container( width: double.infinity, - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(10), margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( color: Theme.of( context, - ).colorScheme.primaryContainer.withOpacity(0.3), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.primary.withOpacity(0.3), + ).colorScheme.primaryContainer.withOpacity(0.4), + borderRadius: BorderRadius.circular(12), + border: Border( + left: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 3, + ), ), ), child: Row( children: [ Icon( - Icons.reply, - size: 16, + Icons.reply_rounded, + size: 18, color: Theme.of(context).colorScheme.primary, ), - const SizedBox(width: 8), + const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -2700,7 +3027,7 @@ class _ChatScreenState extends State { ).colorScheme.primary, ), ), - const SizedBox(height: 2), + const SizedBox(height: 3), Text( _replyingToMessage!.text.isNotEmpty ? _replyingToMessage!.text @@ -2709,7 +3036,7 @@ class _ChatScreenState extends State { fontSize: 13, color: Theme.of( context, - ).colorScheme.onSurface.withOpacity(0.8), + ).colorScheme.onSurface.withOpacity(0.7), ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -2717,11 +3044,20 @@ class _ChatScreenState extends State { ], ), ), - IconButton( - onPressed: _cancelReply, - icon: const Icon(Icons.close), - iconSize: 18, - color: Theme.of(context).colorScheme.primary, + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: _cancelReply, + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Icon( + Icons.close_rounded, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), ), ], ), @@ -2735,12 +3071,13 @@ class _ChatScreenState extends State { decoration: BoxDecoration( color: Theme.of( context, - ).colorScheme.errorContainer.withOpacity(0.3), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.error.withOpacity(0.5), + ).colorScheme.errorContainer.withOpacity(0.4), + borderRadius: BorderRadius.circular(12), + border: Border( + left: BorderSide( + color: Theme.of(context).colorScheme.error, + width: 3, + ), ), ), child: Column( @@ -2749,38 +3086,39 @@ class _ChatScreenState extends State { Row( children: [ Icon( - Icons.block, + Icons.block_rounded, color: Theme.of(context).colorScheme.error, size: 20, ), - const SizedBox(width: 8), + const SizedBox(width: 10), Text( 'Пользователь заблокирован', style: TextStyle( color: Theme.of(context).colorScheme.error, fontWeight: FontWeight.w600, + fontSize: 13, ), ), ], ), - const SizedBox(height: 4), + const SizedBox(height: 6), Text( 'Разблокируйте пользователя для отправки сообщений', style: TextStyle( color: Theme.of( context, ).colorScheme.onErrorContainer, - fontSize: 14, + fontSize: 13, ), ), - const SizedBox(height: 4), + const SizedBox(height: 3), Text( 'или включите block_bypass', style: TextStyle( color: Theme.of( context, - ).colorScheme.onErrorContainer, - fontSize: 12, + ).colorScheme.onErrorContainer.withOpacity(0.7), + fontSize: 11, fontStyle: FontStyle.italic, ), ), @@ -2789,7 +3127,7 @@ class _ChatScreenState extends State { ), ], Row( - crossAxisAlignment: CrossAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Focus( @@ -2831,22 +3169,31 @@ class _ChatScreenState extends State { ? 'Пользователь заблокирован' : 'Сообщение...', filled: true, + isDense: true, fillColor: isBlocked ? Theme.of(context) .colorScheme .surfaceContainerHighest - .withOpacity(0.3) + .withOpacity(0.25) : Theme.of(context) .colorScheme .surfaceContainerHighest - .withOpacity(0.5), + .withOpacity(0.4), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(30), + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), borderSide: BorderSide.none, ), contentPadding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 10.0, + horizontal: 18.0, + vertical: 12.0, ), ), @@ -2860,72 +3207,115 @@ class _ChatScreenState extends State { ), ), ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.photo_library_outlined), - tooltip: isBlocked - ? 'Пользователь заблокирован' - : 'Отправить фото', - onPressed: isBlocked - ? null - : () async { - final result = await _pickPhotosFlow(context); - if (result != null && result.paths.isNotEmpty) { - await ApiService.instance.sendPhotoMessages( + const SizedBox(width: 4), + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: isBlocked + ? null + : () async { + final result = await _pickPhotosFlow(context); + if (result != null && + result.paths.isNotEmpty) { + await ApiService.instance.sendPhotoMessages( + widget.chatId, + localPaths: result.paths, + caption: result.caption, + senderId: _actualMyId, + ); + } + }, + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Icon( + Icons.photo_library_outlined, + color: isBlocked + ? Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.3) + : Theme.of(context).colorScheme.primary, + size: 24, + ), + ), + ), + ), + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: isBlocked + ? null + : () async { + await ApiService.instance.sendFileMessage( widget.chatId, - localPaths: result.paths, - caption: result.caption, senderId: _actualMyId, ); - } - }, - color: isBlocked - ? Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.3) - : Theme.of(context).colorScheme.primary, - ), - IconButton( - icon: const Icon(Icons.attach_file), - tooltip: isBlocked - ? 'Пользователь заблокирован' - : 'Отправить файл', - onPressed: isBlocked - ? null - : () async { - await ApiService.instance.sendFileMessage( - widget.chatId, - senderId: _actualMyId, - ); - }, - color: isBlocked - ? Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.3) - : Theme.of(context).colorScheme.primary, + }, + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Icon( + Icons.attach_file, + color: isBlocked + ? Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.3) + : Theme.of(context).colorScheme.primary, + size: 24, + ), + ), + ), ), if (context.watch().messageTransition == TransitionOption.slide) - IconButton( - icon: const Icon(Icons.animation), - onPressed: isBlocked ? null : _testSlideAnimation, + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: isBlocked ? null : _testSlideAnimation, + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Icon( + Icons.animation, + color: isBlocked + ? Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.3) + : Colors.orange, + size: 24, + ), + ), + ), + ), + const SizedBox(width: 4), + Container( + decoration: BoxDecoration( color: isBlocked ? Theme.of( context, - ).colorScheme.onSurface.withOpacity(0.3) - : Colors.orange, - tooltip: isBlocked - ? 'Пользователь заблокирован' - : 'Тест Slide+ анимации', + ).colorScheme.onSurface.withOpacity(0.2) + : Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: isBlocked ? null : _sendMessage, + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Icon( + Icons.send_rounded, + color: isBlocked + ? Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.5) + : Theme.of(context).colorScheme.onPrimary, + size: 24, + ), + ), + ), ), - IconButton( - icon: const Icon(Icons.send), - onPressed: isBlocked ? null : _sendMessage, - color: isBlocked - ? Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.3) - : Theme.of(context).colorScheme.primary, ), ], ), @@ -2936,233 +3326,313 @@ class _ChatScreenState extends State { ), ); } else { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, // Обычный цвет фона - border: Border( - top: BorderSide(color: Theme.of(context).dividerColor, width: 0.5), - ), - ), - child: SafeArea( - top: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_replyingToMessage != null) ...[ - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - margin: const EdgeInsets.only(bottom: 8), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primaryContainer.withOpacity(0.3), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.primary.withOpacity(0.3), - ), - ), - child: Row( - children: [ - Icon( - Icons.reply, - size: 16, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Ответ на сообщение', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(height: 2), - Text( - _replyingToMessage!.text.isNotEmpty - ? _replyingToMessage!.text - : 'Фото', - style: TextStyle( - fontSize: 13, - color: Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.8), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - IconButton( - onPressed: _cancelReply, - icon: const Icon(Icons.close), - iconSize: 18, - color: Theme.of(context).colorScheme.primary, - ), - ], - ), + return ClipRRect( + borderRadius: BorderRadius.circular(16), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withOpacity( + 0.85, + ), // Прозрачный фон для эффекта стекла + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 2), ), ], - if (isBlocked) ...[ - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - margin: const EdgeInsets.only(bottom: 8), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.errorContainer.withOpacity(0.3), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.error.withOpacity(0.5), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + ), + child: SafeArea( + top: false, + bottom: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_replyingToMessage != null) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primaryContainer.withOpacity(0.4), + borderRadius: BorderRadius.circular(12), + border: Border( + left: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 3, + ), + ), + ), + child: Row( children: [ Icon( - Icons.block, - color: Theme.of(context).colorScheme.error, - size: 20, + Icons.reply_rounded, + size: 18, + color: Theme.of(context).colorScheme.primary, ), - const SizedBox(width: 8), - Text( - 'Пользователь заблокирован', - style: TextStyle( - color: Theme.of(context).colorScheme.error, - fontWeight: FontWeight.w600, + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ответ на сообщение', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Theme.of( + context, + ).colorScheme.primary, + ), + ), + const SizedBox(height: 3), + Text( + _replyingToMessage!.text.isNotEmpty + ? _replyingToMessage!.text + : 'Фото', + style: TextStyle( + fontSize: 13, + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.7), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: _cancelReply, + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Icon( + Icons.close_rounded, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + ), ), ), ], ), - const SizedBox(height: 4), - Text( - 'Разблокируйте пользователя для отправки сообщений', - style: TextStyle( - color: Theme.of(context).colorScheme.onErrorContainer, - fontSize: 14, + ), + ], + if (isBlocked) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.errorContainer.withOpacity(0.4), + borderRadius: BorderRadius.circular(12), + border: Border( + left: BorderSide( + color: Theme.of(context).colorScheme.error, + width: 3, + ), ), ), - const SizedBox(height: 4), - Text( - 'или включите block_bypass', - style: TextStyle( - color: Theme.of(context).colorScheme.onErrorContainer, - fontSize: 12, - fontStyle: FontStyle.italic, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.block_rounded, + color: Theme.of(context).colorScheme.error, + size: 20, + ), + const SizedBox(width: 10), + Text( + 'Пользователь заблокирован', + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Разблокируйте пользователя для отправки сообщений', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onErrorContainer, + fontSize: 13, + ), + ), + const SizedBox(height: 3), + Text( + 'или включите block_bypass', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onErrorContainer.withOpacity(0.7), + fontSize: 11, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ), + ], + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: TextField( + controller: _textController, + enabled: !isBlocked, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + minLines: 1, + maxLines: 5, + decoration: InputDecoration( + hintText: isBlocked + ? 'Пользователь заблокирован' + : 'Сообщение...', + filled: true, + isDense: true, + fillColor: isBlocked + ? Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withOpacity(0.25) + : Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withOpacity(0.4), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 18.0, + vertical: 12.0, + ), + ), + onChanged: isBlocked + ? null + : (v) { + if (v.isNotEmpty) { + _scheduleTypingPing(); + } + }, + ), + ), + const SizedBox(width: 4), + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: isBlocked + ? null + : () async { + final result = await _pickPhotosFlow(context); + if (result != null && + result.paths.isNotEmpty) { + await ApiService.instance.sendPhotoMessages( + widget.chatId, + localPaths: result.paths, + caption: result.caption, + senderId: _actualMyId, + ); + } + }, + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Icon( + Icons.photo_library_outlined, + color: isBlocked + ? Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.3) + : Theme.of(context).colorScheme.primary, + size: 24, + ), + ), + ), + ), + if (context.watch().messageTransition == + TransitionOption.slide) + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: isBlocked ? null : _testSlideAnimation, + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Icon( + Icons.animation, + color: isBlocked + ? Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.3) + : Colors.orange, + size: 24, + ), + ), + ), + ), + const SizedBox(width: 4), + Container( + decoration: BoxDecoration( + color: isBlocked + ? Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.2) + : Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: isBlocked ? null : _sendMessage, + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Icon( + Icons.send_rounded, + color: isBlocked + ? Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.5) + : Theme.of(context).colorScheme.onPrimary, + size: 24, + ), + ), + ), ), ), ], ), - ), - ], - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: TextField( - controller: _textController, - enabled: !isBlocked, - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.newline, - minLines: 1, - maxLines: 5, - decoration: InputDecoration( - hintText: isBlocked - ? 'Пользователь заблокирован' - : 'Сообщение...', - filled: true, - fillColor: isBlocked - ? Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withOpacity(0.3) - : Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withOpacity(0.5), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(30), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 10.0, - ), - ), - onChanged: isBlocked - ? null - : (v) { - if (v.isNotEmpty) { - _scheduleTypingPing(); - } - }, - ), - ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.photo_library_outlined), - tooltip: isBlocked - ? 'Пользователь заблокирован' - : 'Отправить фото', - onPressed: isBlocked - ? null - : () async { - final result = await _pickPhotosFlow(context); - if (result != null && result.paths.isNotEmpty) { - await ApiService.instance.sendPhotoMessages( - widget.chatId, - localPaths: result.paths, - caption: result.caption, - senderId: _actualMyId, - ); - } - }, - color: isBlocked - ? Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.3) - : Theme.of(context).colorScheme.primary, - ), - if (context.watch().messageTransition == - TransitionOption.slide) - IconButton( - icon: const Icon(Icons.animation), - onPressed: isBlocked ? null : _testSlideAnimation, - color: isBlocked - ? Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.3) - : Colors.orange, - tooltip: isBlocked - ? 'Пользователь заблокирован' - : 'Тест Slide+ анимации', - ), - IconButton( - icon: const Icon(Icons.send), - onPressed: isBlocked ? null : _sendMessage, - color: isBlocked - ? Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.3) - : Theme.of(context).colorScheme.primary, - ), ], ), - ], + ), ), ), ); @@ -3857,21 +4327,32 @@ class _DateSeparatorChip extends StatelessWidget { @override Widget build(BuildContext context) { - return Center( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - margin: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primaryContainer.withOpacity(0.5), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - _formatDate(date), - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryContainer, - fontWeight: FontWeight.w500, + return TweenAnimationBuilder( + duration: const Duration(milliseconds: 400), + tween: Tween(begin: 0.0, end: 1.0), + curve: Curves.easeOutCubic, + builder: (context, value, child) { + return Opacity( + opacity: value, + child: Transform.scale(scale: 0.8 + (0.2 * value), child: child), + ); + }, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + margin: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primaryContainer.withOpacity(0.5), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _formatDate(date), + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w500, + ), ), ), ), @@ -3918,19 +4399,13 @@ class GroupProfileDraggableDialog extends StatelessWidget { padding: const EdgeInsets.all(20), child: Hero( tag: 'contact_avatar_${contact.id}', - child: CircleAvatar( + child: ContactAvatarWidget( + contactId: contact.id, + originalAvatarUrl: contact.photoBaseUrl, radius: 60, - backgroundImage: contact.photoBaseUrl != null - ? NetworkImage(contact.photoBaseUrl!) - : null, - child: contact.photoBaseUrl == null - ? Text( - contact.name.isNotEmpty - ? contact.name[0].toUpperCase() - : '?', - style: const TextStyle(fontSize: 32), - ) - : null, + fallbackText: contact.name.isNotEmpty + ? contact.name[0].toUpperCase() + : '?', ), ), ), @@ -3999,16 +4474,68 @@ class GroupProfileDraggableDialog extends StatelessWidget { } } -class ContactProfileDialog extends StatelessWidget { +class ContactProfileDialog extends StatefulWidget { final Contact contact; final bool isChannel; - const ContactProfileDialog({required this.contact, this.isChannel = false}); + const ContactProfileDialog({ + super.key, + required this.contact, + this.isChannel = false, + }); + + @override + State createState() => _ContactProfileDialogState(); +} + +class _ContactProfileDialogState extends State { + String? _localDescription; + StreamSubscription? _changesSubscription; + + @override + void initState() { + super.initState(); + _loadLocalDescription(); + + // Подписываемся на изменения + _changesSubscription = ContactLocalNamesService().changes.listen(( + contactId, + ) { + if (contactId == widget.contact.id && mounted) { + _loadLocalDescription(); + } + }); + } + + Future _loadLocalDescription() async { + final localData = await ContactLocalNamesService().getContactData( + widget.contact.id, + ); + if (mounted) { + setState(() { + _localDescription = localData?['notes'] as String?; + }); + } + } + + @override + void dispose() { + _changesSubscription?.cancel(); + super.dispose(); + } @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final String nickname = contact.name; - final String description = contact.description ?? ''; + final String nickname = getContactDisplayName( + contactId: widget.contact.id, + originalName: widget.contact.name, + originalFirstName: widget.contact.firstName, + originalLastName: widget.contact.lastName, + ); + final String description = + (_localDescription != null && _localDescription!.isNotEmpty) + ? _localDescription! + : (widget.contact.description ?? ''); final theme = context.watch(); @@ -4056,20 +4583,14 @@ class ContactProfileDialog extends StatelessWidget { ); }, child: Hero( - tag: 'contact_avatar_${contact.id}', - child: CircleAvatar( + tag: 'contact_avatar_${widget.contact.id}', + child: ContactAvatarWidget( + contactId: widget.contact.id, + originalAvatarUrl: widget.contact.photoBaseUrl, radius: 96, - backgroundImage: contact.photoBaseUrl != null - ? NetworkImage(contact.photoBaseUrl!) - : null, - child: contact.photoBaseUrl == null - ? Text( - contact.name.isNotEmpty - ? contact.name[0].toUpperCase() - : '?', - style: const TextStyle(fontSize: 48), - ) - : null, + fallbackText: widget.contact.name.isNotEmpty + ? widget.contact.name[0].toUpperCase() + : '?', ), ), ), @@ -4152,16 +4673,30 @@ class ContactProfileDialog extends StatelessWidget { else const SizedBox(height: 16), - if (!isChannel) + if (!widget.isChannel) SizedBox( width: double.infinity, child: ElevatedButton.icon( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Редактирование контакта'), + onPressed: () async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => EditContactScreen( + contactId: widget.contact.id, + originalFirstName: + widget.contact.firstName, + originalLastName: widget.contact.lastName, + originalDescription: + widget.contact.description, + originalAvatarUrl: + widget.contact.photoBaseUrl, + ), ), ); + + if (result == true && context.mounted) { + Navigator.of(context).pop(); + setState(() {}); + } }, icon: const Icon(Icons.edit), label: const Text('Редактировать'), @@ -4573,8 +5108,15 @@ class _ControlMessageChip extends StatelessWidget { ); final eventType = controlAttach['event']; - final senderName = - contacts[message.senderId]?.name ?? 'ID ${message.senderId}'; + final senderContact = contacts[message.senderId]; + final senderName = senderContact != null + ? getContactDisplayName( + contactId: senderContact.id, + originalName: senderContact.name, + originalFirstName: senderContact.firstName, + originalLastName: senderContact.lastName, + ) + : 'ID ${message.senderId}'; final isMe = message.senderId == myId; final senderDisplayName = isMe ? 'Вы' : senderName; @@ -4587,7 +5129,16 @@ class _ControlMessageChip extends StatelessWidget { if (id == myId) { return 'Вы'; } - return contacts[id]?.name ?? 'участник с ID $id'; + final contact = contacts[id]; + if (contact != null) { + return getContactDisplayName( + contactId: contact.id, + originalName: contact.name, + originalFirstName: contact.firstName, + originalLastName: contact.lastName, + ); + } + return 'участник с ID $id'; }) .where((name) => name.isNotEmpty) .join(', '); @@ -4740,23 +5291,34 @@ class _ControlMessageChip extends StatelessWidget { @override Widget build(BuildContext context) { - return Center( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - margin: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primaryContainer.withOpacity(0.5), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - _formatControlMessage(), - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onPrimaryContainer, - fontWeight: FontWeight.w500, + return TweenAnimationBuilder( + duration: const Duration(milliseconds: 400), + tween: Tween(begin: 0.0, end: 1.0), + curve: Curves.easeOutCubic, + builder: (context, value, child) { + return Opacity( + opacity: value, + child: Transform.scale(scale: 0.8 + (0.2 * value), child: child), + ); + }, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + margin: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primaryContainer.withOpacity(0.5), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _formatControlMessage(), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w500, + ), ), ), ), @@ -4798,6 +5360,10 @@ Future openUserProfileById(BuildContext context, int userId) async { context: context, isScrollControlled: true, backgroundColor: Colors.transparent, + transitionAnimationController: AnimationController( + vsync: Navigator.of(context), + duration: const Duration(milliseconds: 400), + )..forward(), builder: (context) => GroupProfileDraggableDialog(contact: contactData), ); } else { @@ -4809,9 +5375,23 @@ Future openUserProfileById(BuildContext context, int userId) async { return ContactProfileDialog(contact: contactData); }, transitionsBuilder: (context, animation, secondaryAnimation, child) { - return FadeTransition(opacity: animation, child: child); + return FadeTransition( + opacity: CurvedAnimation( + parent: animation, + curve: Curves.easeInOutCubic, + ), + child: ScaleTransition( + scale: Tween(begin: 0.95, end: 1.0).animate( + CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + ), + ), + child: child, + ), + ); }, - transitionDuration: const Duration(milliseconds: 300), + transitionDuration: const Duration(milliseconds: 350), ), ); } diff --git a/lib/chats_screen.dart b/lib/chats_screen.dart index ebd397d..e11d718 100644 --- a/lib/chats_screen.dart +++ b/lib/chats_screen.dart @@ -28,6 +28,10 @@ import 'package:gwid/user_id_lookup_screen.dart'; import 'package:gwid/screens/music_library_screen.dart'; import 'package:gwid/widgets/message_preview_dialog.dart'; import 'package:gwid/services/chat_read_settings_service.dart'; +import 'package:gwid/services/local_profile_manager.dart'; +import 'package:gwid/widgets/contact_name_widget.dart'; +import 'package:gwid/widgets/contact_avatar_widget.dart'; +import 'package:gwid/services/contact_local_names_service.dart'; import 'package:gwid/services/account_manager.dart'; import 'package:gwid/models/account.dart'; @@ -163,25 +167,48 @@ class _ChatsScreenState extends State _isProfileLoading = true; }); + Profile? serverProfile; + try { final accountManager = AccountManager(); await accountManager.initialize(); final currentAccount = accountManager.currentAccount; - if (currentAccount?.profile != null && mounted) { - setState(() { - _myProfile = currentAccount!.profile; - _isProfileLoading = false; - }); - return; + if (currentAccount?.profile != null) { + serverProfile = currentAccount!.profile; } } catch (e) { print('Ошибка загрузки профиля из AccountManager: $e'); } - final cachedProfileData = ApiService.instance.lastChatsPayload?['profile']; - if (cachedProfileData != null && mounted) { + if (serverProfile == null) { + final cachedProfileData = + ApiService.instance.lastChatsPayload?['profile']; + if (cachedProfileData != null) { + serverProfile = Profile.fromJson(cachedProfileData); + } + } + + try { + final profileManager = LocalProfileManager(); + await profileManager.initialize(); + final actualProfile = await profileManager.getActualProfile( + serverProfile, + ); + + if (mounted && actualProfile != null) { + setState(() { + _myProfile = actualProfile; + _isProfileLoading = false; + }); + return; + } + } catch (e) { + print('Ошибка загрузки локального профиля: $e'); + } + + if (mounted && serverProfile != null) { setState(() { - _myProfile = Profile.fromJson(cachedProfileData); + _myProfile = serverProfile; _isProfileLoading = false; }); return; @@ -277,6 +304,20 @@ class _ChatsScreenState extends State final opcode = message['opcode']; final cmd = message['cmd']; final payload = message['payload']; + + if (opcode == 19 && cmd == 1 && payload != null) { + final profileData = payload['profile']; + if (profileData != null) { + print('🔄 ChatsScreen: Получен профиль из opcode 19, обновляем UI'); + if (mounted) { + setState(() { + _myProfile = Profile.fromJson(profileData); + _isProfileLoading = false; + }); + } + } + } + if (payload == null) return; final chatIdValue = payload['chatId']; final int? chatId = chatIdValue != null ? chatIdValue as int? : null; @@ -640,6 +681,7 @@ class _ChatsScreenState extends State if (mounted) { final chats = data['chats'] as List; final contacts = data['contacts'] as List; + final profileData = data['profile']; _allChats = chats .where((json) => json != null) @@ -650,6 +692,14 @@ class _ChatsScreenState extends State final contact = Contact.fromJson(contactJson); _contacts[contact.id] = contact; } + + setState(() { + if (profileData != null) { + _myProfile = Profile.fromJson(profileData); + _isProfileLoading = false; + } + }); + _filterChats(); } }); @@ -1061,7 +1111,14 @@ class _ChatsScreenState extends State final isSelected = selectedContacts.contains(contact.id); return CheckboxListTile( - title: Text(contact.name), + title: Text( + getContactDisplayName( + contactId: contact.id, + originalName: contact.name, + originalFirstName: contact.firstName, + originalLastName: contact.lastName, + ), + ), subtitle: Text( contact.firstName.isNotEmpty && contact.lastName.isNotEmpty @@ -1387,12 +1444,20 @@ class _ChatsScreenState extends State if (contact == null) continue; - if (contact.name.toLowerCase().contains(query)) { + final displayName = getContactDisplayName( + contactId: contact.id, + originalName: contact.name, + originalFirstName: contact.firstName, + originalLastName: contact.lastName, + ); + + if (displayName.toLowerCase().contains(query) || + contact.name.toLowerCase().contains(query)) { results.add( SearchResult( chat: chat, contact: contact, - matchedText: contact.name, + matchedText: displayName, matchType: 'name', ), ); @@ -1465,6 +1530,7 @@ class _ChatsScreenState extends State if (mounted) { final chats = data['chats'] as List; final contacts = data['contacts'] as List; + final profileData = data['profile']; _allChats = chats .where((json) => json != null) @@ -1477,6 +1543,13 @@ class _ChatsScreenState extends State _contacts[contact.id] = contact; } + if (profileData != null) { + setState(() { + _myProfile = Profile.fromJson(profileData); + _isProfileLoading = false; + }); + } + _filterChats(); } }); @@ -1710,7 +1783,9 @@ class _ChatsScreenState extends State ApiService.instance.getBlockedContacts(); } - _loadFolders(snapshot.data!); + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadFolders(snapshot.data!); + }); _loadChatOrder().then((_) { setState(() { @@ -2285,7 +2360,15 @@ class _ChatsScreenState extends State ) : null, ), - title: _buildHighlightedText(contact.name, result.matchedText), + title: _buildHighlightedText( + getContactDisplayName( + contactId: contact.id, + originalName: contact.name, + originalFirstName: contact.firstName, + originalLastName: contact.lastName, + ), + result.matchedText, + ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -2394,7 +2477,12 @@ class _ChatsScreenState extends State } else if (isSavedMessages) { title = "Избранное"; } else if (contact != null) { - title = contact.name; + title = getContactDisplayName( + contactId: contact.id, + originalName: contact.name, + originalFirstName: contact.firstName, + originalLastName: contact.lastName, + ); } else if (chat.title?.isNotEmpty == true) { title = chat.title!; } else { @@ -2453,39 +2541,41 @@ class _ChatsScreenState extends State children: [ Stack( children: [ - CircleAvatar( - radius: 28, - backgroundColor: colors.primaryContainer, - backgroundImage: - !isSavedMessages && - !isGroupChat && - contact?.photoBaseUrl != null - ? NetworkImage(contact?.photoBaseUrl ?? '') - : (isGroupChat && chat.baseIconUrl != null) - ? NetworkImage(chat.baseIconUrl ?? '') - : null, - child: - isSavedMessages || - (isGroupChat && chat.baseIconUrl == null) - ? Icon( - isSavedMessages ? Icons.bookmark : Icons.group, - color: colors.onPrimaryContainer, - size: 20, - ) - : (contact?.photoBaseUrl == null - ? Text( - (contact != null && - contact.name.isNotEmpty) - ? contact.name[0].toUpperCase() - : '?', - style: TextStyle( - color: colors.onSurface, - fontWeight: FontWeight.w600, - fontSize: 16, - ), + isSavedMessages || isGroupChat + ? CircleAvatar( + radius: 28, + backgroundColor: colors.primaryContainer, + backgroundImage: + isGroupChat && chat.baseIconUrl != null + ? NetworkImage(chat.baseIconUrl ?? '') + : null, + child: + isSavedMessages || + (isGroupChat && chat.baseIconUrl == null) + ? Icon( + isSavedMessages + ? Icons.bookmark + : Icons.group, + color: colors.onPrimaryContainer, + size: 20, ) - : null), - ), + : null, + ) + : contact != null + ? ContactAvatarWidget( + contactId: contact.id, + originalAvatarUrl: contact.photoBaseUrl, + radius: 28, + fallbackText: contact.name.isNotEmpty + ? contact.name[0].toUpperCase() + : '?', + backgroundColor: colors.primaryContainer, + ) + : CircleAvatar( + radius: 28, + backgroundColor: colors.primaryContainer, + child: const Text('?'), + ), if (chat.newMessages > 0) Positioned( @@ -3823,7 +3913,12 @@ class _ChatsScreenState extends State contact = _contacts[otherParticipantId]; if (contact != null) { - title = contact.name; + title = getContactDisplayName( + contactId: contact.id, + originalName: contact.name, + originalFirstName: contact.firstName, + originalLastName: contact.lastName, + ); } else if (chat.title?.isNotEmpty == true) { title = chat.title!; } else { @@ -4531,7 +4626,12 @@ class _AddChatsToFolderDialogState extends State<_AddChatsToFolderDialog> { contact = widget.contacts[otherParticipantId]; if (contact != null) { - title = contact.name; + title = getContactDisplayName( + contactId: contact.id, + originalName: contact.name, + originalFirstName: contact.firstName, + originalLastName: contact.lastName, + ); } else if (chat.title?.isNotEmpty == true) { title = chat.title!; } else { diff --git a/lib/main.dart b/lib/main.dart index e69ef28..7e0bb9f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'connection_lifecycle_manager.dart'; import 'services/cache_service.dart'; import 'services/avatar_cache_service.dart'; import 'services/chat_cache_service.dart'; +import 'services/contact_local_names_service.dart'; import 'services/version_checker.dart'; import 'services/account_manager.dart'; import 'services/music_player_service.dart'; @@ -28,6 +29,7 @@ Future main() async { await CacheService().initialize(); await AvatarCacheService().initialize(); await ChatCacheService().initialize(); + await ContactLocalNamesService().initialize(); print("Сервисы кеширования инициализированы"); print("Инициализируем AccountManager..."); diff --git a/lib/manage_account_screen.dart b/lib/manage_account_screen.dart index 460d8c8..9918ff6 100644 --- a/lib/manage_account_screen.dart +++ b/lib/manage_account_screen.dart @@ -1,9 +1,10 @@ - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:gwid/api/api_service.dart'; import 'package:gwid/models/profile.dart'; import 'package:gwid/phone_entry_screen.dart'; +import 'package:gwid/services/profile_cache_service.dart'; +import 'package:gwid/services/local_profile_manager.dart'; import 'package:image_picker/image_picker.dart'; import 'dart:io'; @@ -20,39 +21,97 @@ class _ManageAccountScreenState extends State { late final TextEditingController _lastNameController; late final TextEditingController _descriptionController; final GlobalKey _formKey = GlobalKey(); + final ProfileCacheService _profileCache = ProfileCacheService(); + final LocalProfileManager _profileManager = LocalProfileManager(); + + Profile? _actualProfile; + String? _localAvatarPath; + bool _isLoading = false; @override void initState() { super.initState(); - _firstNameController = TextEditingController( - text: widget.myProfile?.firstName ?? '', - ); - _lastNameController = TextEditingController( - text: widget.myProfile?.lastName ?? '', - ); - _descriptionController = TextEditingController( - text: widget.myProfile?.description ?? '', - ); + _initializeProfileData(); } - void _saveProfile() { + Future _initializeProfileData() async { + await _profileManager.initialize(); + + _actualProfile = await _profileManager.getActualProfile(widget.myProfile); + + _firstNameController = TextEditingController( + text: _actualProfile?.firstName ?? '', + ); + _lastNameController = TextEditingController( + text: _actualProfile?.lastName ?? '', + ); + _descriptionController = TextEditingController( + text: _actualProfile?.description ?? '', + ); + final localPath = await _profileManager.getLocalAvatarPath(); + if (mounted) { + setState(() { + _localAvatarPath = localPath; + }); + } + } + + Future _saveProfile() async { if (!_formKey.currentState!.validate()) { return; } - ApiService.instance.updateProfileText( - _firstNameController.text.trim(), - _lastNameController.text.trim(), - _descriptionController.text.trim(), - ); + setState(() { + _isLoading = true; + }); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Профиль успешно сохранен"), - behavior: SnackBarBehavior.floating, - duration: Duration(seconds: 2), - ), - ); + try { + final firstName = _firstNameController.text.trim(); + final lastName = _lastNameController.text.trim(); + final description = _descriptionController.text.trim(); + + final userId = _actualProfile?.id ?? widget.myProfile?.id ?? 0; + final photoBaseUrl = + _actualProfile?.photoBaseUrl ?? widget.myProfile?.photoBaseUrl; + final photoId = _actualProfile?.photoId ?? widget.myProfile?.photoId ?? 0; + + await _profileCache.saveProfileData( + userId: userId, + firstName: firstName, + lastName: lastName, + description: description.isEmpty ? null : description, + photoBaseUrl: photoBaseUrl, + photoId: photoId, + ); + + _actualProfile = await _profileManager.getActualProfile(widget.myProfile); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Профиль сохранен локально"), + behavior: SnackBarBehavior.floating, + duration: Duration(seconds: 2), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Ошибка сохранения: $e"), + behavior: SnackBarBehavior.floating, + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } } void _logout() async { @@ -102,27 +161,62 @@ class _ManageAccountScreenState extends State { } } - void _pickAndUpdateProfilePhoto() async { - final ImagePicker picker = ImagePicker(); + Future _pickAndUpdateProfilePhoto() async { + try { + final ImagePicker picker = ImagePicker(); + final XFile? image = await picker.pickImage( + source: ImageSource.gallery, + maxWidth: 1024, + maxHeight: 1024, + imageQuality: 85, + ); - final XFile? image = await picker.pickImage(source: ImageSource.gallery); + if (image == null) return; - - if (image != null) { + setState(() { + _isLoading = true; + }); File imageFile = File(image.path); + final userId = _actualProfile?.id ?? widget.myProfile?.id ?? 0; + if (userId != 0) { + final localPath = await _profileCache.saveAvatar(imageFile, userId); + if (localPath != null && mounted) { + setState(() { + _localAvatarPath = localPath; + }); + _actualProfile = await _profileManager.getActualProfile( + widget.myProfile, + ); + } + } - - - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Фотография профиля обновляется..."), - behavior: SnackBarBehavior.floating, - ), - ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Фотография профиля сохранена"), + behavior: SnackBarBehavior.floating, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Ошибка загрузки фото: $e"), + behavior: SnackBarBehavior.floating, + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } } } @@ -155,7 +249,6 @@ class _ManageAccountScreenState extends State { _buildAvatarSection(theme), const SizedBox(height: 32), - Card( elevation: 2, shape: RoundedRectangleBorder( @@ -199,7 +292,6 @@ class _ManageAccountScreenState extends State { ), const SizedBox(height: 24), - Card( elevation: 2, shape: RoundedRectangleBorder( @@ -234,7 +326,6 @@ class _ManageAccountScreenState extends State { ), const SizedBox(height: 24), - if (widget.myProfile != null) Card( elevation: 2, @@ -281,22 +372,32 @@ class _ManageAccountScreenState extends State { ); } - - Widget _buildAvatarSection(ThemeData theme) { + ImageProvider? avatarImage; + + if (_localAvatarPath != null) { + avatarImage = FileImage(File(_localAvatarPath!)); + } else if (_actualProfile?.photoBaseUrl != null) { + if (_actualProfile!.photoBaseUrl!.startsWith('file://')) { + final path = _actualProfile!.photoBaseUrl!.replaceFirst('file://', ''); + avatarImage = FileImage(File(path)); + } else { + avatarImage = NetworkImage(_actualProfile!.photoBaseUrl!); + } + } else if (widget.myProfile?.photoBaseUrl != null) { + avatarImage = NetworkImage(widget.myProfile!.photoBaseUrl!); + } + return Center( child: GestureDetector( - - onTap: _pickAndUpdateProfilePhoto, // 2. Вызываем метод при нажатии + onTap: _pickAndUpdateProfilePhoto, child: Stack( children: [ CircleAvatar( radius: 60, backgroundColor: theme.colorScheme.secondaryContainer, - backgroundImage: widget.myProfile?.photoBaseUrl != null - ? NetworkImage(widget.myProfile!.photoBaseUrl!) - : null, - child: widget.myProfile?.photoBaseUrl == null + backgroundImage: avatarImage, + child: avatarImage == null ? Icon( Icons.person, size: 60, @@ -304,6 +405,21 @@ class _ManageAccountScreenState extends State { ) : null, ), + if (_isLoading) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: const Center( + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3, + ), + ), + ), + ), Positioned( bottom: 4, right: 4, @@ -329,7 +445,6 @@ class _ManageAccountScreenState extends State { IconData icon, { bool alignLabel = false, }) { - final prefixIcon = (label == "О себе") ? Padding( padding: const EdgeInsets.only(bottom: 60), // Смещаем иконку вверх diff --git a/lib/phone_entry_screen.dart b/lib/phone_entry_screen.dart index 110c280..755d78d 100644 --- a/lib/phone_entry_screen.dart +++ b/lib/phone_entry_screen.dart @@ -6,8 +6,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:gwid/api/api_service.dart'; import 'package:gwid/otp_screen.dart'; import 'package:gwid/proxy_service.dart'; -import 'package:gwid/screens/settings/proxy_settings_screen.dart'; -import 'package:gwid/screens/settings/session_spoofing_screen.dart'; +import 'package:gwid/screens/settings/auth_settings_screen.dart'; import 'package:gwid/token_auth_screen.dart'; import 'package:gwid/tos_screen.dart'; // Импорт экрана ToS import 'package:mask_text_input_formatter/mask_text_input_formatter.dart'; @@ -219,10 +218,6 @@ class _PhoneEntryScreenState extends State } } - void refreshProxySettings() { - _checkProxySettings(); - } - void _requestOtp() async { if (!_isButtonEnabled || _isLoading || !_isTosAccepted) return; setState(() => _isLoading = true); @@ -429,9 +424,14 @@ class _PhoneEntryScreenState extends State ), ), const SizedBox(height: 32), - _AnonymityCard(isConfigured: _hasCustomAnonymity), - const SizedBox(height: 16), - _ProxyCard(isConfigured: _hasProxyConfigured), + _SettingsButton( + hasCustomAnonymity: _hasCustomAnonymity, + hasProxyConfigured: _hasProxyConfigured, + onRefresh: () { + _checkAnonymitySettings(); + _checkProxySettings(); + }, + ), const SizedBox(height: 24), Text.rich( textAlign: TextAlign.center, @@ -600,172 +600,159 @@ class _CountryPicker extends StatelessWidget { } } -class _AnonymityCard extends StatelessWidget { - final bool isConfigured; - const _AnonymityCard({required this.isConfigured}); +class _SettingsButton extends StatelessWidget { + final bool hasCustomAnonymity; + final bool hasProxyConfigured; + final VoidCallback onRefresh; + + const _SettingsButton({ + required this.hasCustomAnonymity, + required this.hasProxyConfigured, + required this.onRefresh, + }); @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; - final Color cardColor = isConfigured - ? colors.secondaryContainer - : colors.surfaceContainerHighest.withOpacity(0.5); - final Color onCardColor = isConfigured - ? colors.onSecondaryContainer - : colors.onSurfaceVariant; - final IconData icon = isConfigured - ? Icons.verified_user_outlined - : Icons.visibility_outlined; + final hasAnySettings = hasCustomAnonymity || hasProxyConfigured; return Card( elevation: 0, - color: cardColor, + color: hasAnySettings + ? colors.primaryContainer.withOpacity(0.3) + : colors.surfaceContainerHighest.withOpacity(0.5), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: colors.outline.withOpacity(0.5)), + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: hasAnySettings + ? colors.primary.withOpacity(0.3) + : colors.outline.withOpacity(0.3), + width: hasAnySettings ? 2 : 1, + ), ), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - Row( - children: [ - Icon(icon, color: onCardColor, size: 20), - const SizedBox(width: 12), - Expanded( - child: Text( - isConfigured - ? 'Активны кастомные настройки анонимности' - : 'Настройте анонимность для скрытия данных', - style: GoogleFonts.manrope( - textStyle: textTheme.bodyMedium, - color: onCardColor, - fontWeight: FontWeight.w500, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const AuthSettingsScreen()), + ); + onRefresh(); + }, + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: hasAnySettings + ? colors.primary.withOpacity(0.15) + : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), ), + child: Icon( + Icons.tune_outlined, + color: hasAnySettings + ? colors.primary + : colors.onSurfaceVariant, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Настройки', + style: GoogleFonts.manrope( + textStyle: textTheme.titleMedium, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + hasAnySettings + ? 'Настроены дополнительные параметры' + : 'Прокси и анонимность', + style: GoogleFonts.manrope( + textStyle: textTheme.bodySmall, + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.arrow_forward_ios, + color: colors.onSurfaceVariant, + size: 16, + ), + ], + ), + if (hasAnySettings) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (hasCustomAnonymity) ...[ + Icon( + Icons.verified_user, + size: 16, + color: colors.primary, + ), + const SizedBox(width: 6), + Text( + 'Анонимность', + style: GoogleFonts.manrope( + textStyle: textTheme.labelSmall, + color: colors.primary, + fontWeight: FontWeight.bold, + ), + ), + ], + if (hasCustomAnonymity && hasProxyConfigured) ...[ + const SizedBox(width: 12), + Container( + width: 4, + height: 4, + decoration: BoxDecoration( + color: colors.primary, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + ], + if (hasProxyConfigured) ...[ + Icon(Icons.vpn_key, size: 16, color: colors.primary), + const SizedBox(width: 6), + Text( + 'Прокси', + style: GoogleFonts.manrope( + textStyle: textTheme.labelSmall, + color: colors.primary, + fontWeight: FontWeight.bold, + ), + ), + ], + ], ), ), ], - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: isConfigured - ? FilledButton.tonalIcon( - onPressed: _navigateToSpoofingScreen(context), - icon: const Icon(Icons.settings, size: 18), - label: Text( - 'Изменить настройки', - style: GoogleFonts.manrope(fontWeight: FontWeight.bold), - ), - ) - : FilledButton.icon( - onPressed: _navigateToSpoofingScreen(context), - icon: const Icon(Icons.visibility_off, size: 18), - label: Text( - 'Настроить анонимность', - style: GoogleFonts.manrope(fontWeight: FontWeight.bold), - ), - ), - ), - ], + ], + ), ), ), ); } - - VoidCallback _navigateToSpoofingScreen(BuildContext context) { - return () { - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const SessionSpoofingScreen()), - ); - }; - } -} - -class _ProxyCard extends StatelessWidget { - final bool isConfigured; - const _ProxyCard({required this.isConfigured}); - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - final Color cardColor = isConfigured - ? colors.secondaryContainer - : colors.surfaceContainerHighest.withOpacity(0.5); - final Color onCardColor = isConfigured - ? colors.onSecondaryContainer - : colors.onSurfaceVariant; - final IconData icon = isConfigured ? Icons.vpn_key : Icons.vpn_key_outlined; - - return Card( - elevation: 0, - color: cardColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: colors.outline.withOpacity(0.5)), - ), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - Row( - children: [ - Icon(icon, color: onCardColor, size: 20), - const SizedBox(width: 12), - Expanded( - child: Text( - isConfigured - ? 'Прокси-сервер настроен и активен' - : 'Настройте прокси-сервер для подключения', - style: GoogleFonts.manrope( - textStyle: textTheme.bodyMedium, - color: onCardColor, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: isConfigured - ? FilledButton.tonalIcon( - onPressed: _navigateToProxyScreen(context), - icon: const Icon(Icons.settings, size: 18), - label: Text( - 'Изменить настройки', - style: GoogleFonts.manrope(fontWeight: FontWeight.bold), - ), - ) - : FilledButton.icon( - onPressed: _navigateToProxyScreen(context), - icon: const Icon(Icons.vpn_key, size: 18), - label: Text( - 'Настроить прокси', - style: GoogleFonts.manrope(fontWeight: FontWeight.bold), - ), - ), - ), - ], - ), - ), - ); - } - - VoidCallback _navigateToProxyScreen(BuildContext context) { - return () async { - await Navigator.of(context).push( - MaterialPageRoute(builder: (context) => const ProxySettingsScreen()), - ); - if (context.mounted) { - final state = context.findAncestorStateOfType<_PhoneEntryScreenState>(); - state?.refreshProxySettings(); - } - }; - } } diff --git a/lib/screens/edit_contact_screen.dart b/lib/screens/edit_contact_screen.dart new file mode 100644 index 0000000..707c93d --- /dev/null +++ b/lib/screens/edit_contact_screen.dart @@ -0,0 +1,610 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:gwid/services/contact_local_names_service.dart'; +import 'package:image_picker/image_picker.dart'; +import 'dart:io'; +import 'dart:convert'; + +class EditContactScreen extends StatefulWidget { + final int contactId; + final String? originalFirstName; + final String? originalLastName; + final String? originalDescription; + final String? originalAvatarUrl; + + const EditContactScreen({ + super.key, + required this.contactId, + this.originalFirstName, + this.originalLastName, + this.originalDescription, + this.originalAvatarUrl, + }); + + @override + State createState() => _EditContactScreenState(); +} + +class _EditContactScreenState extends State { + late final TextEditingController _firstNameController; + late final TextEditingController _lastNameController; + late final TextEditingController _notesController; + final GlobalKey _formKey = GlobalKey(); + bool _isLoading = true; + String? _localAvatarPath; + bool _isLoadingAvatar = false; + + @override + void initState() { + super.initState(); + _firstNameController = TextEditingController( + text: widget.originalFirstName ?? '', + ); + _lastNameController = TextEditingController( + text: widget.originalLastName ?? '', + ); + _notesController = TextEditingController(); + + _loadContactData(); + } + + Future _loadContactData() async { + try { + final prefs = await SharedPreferences.getInstance(); + final key = 'contact_${widget.contactId}'; + final savedData = prefs.getString(key); + + if (savedData != null) { + final data = jsonDecode(savedData) as Map; + + _firstNameController.text = + data['firstName'] ?? widget.originalFirstName ?? ''; + _lastNameController.text = + data['lastName'] ?? widget.originalLastName ?? ''; + _notesController.text = data['notes'] ?? ''; + + final avatarPath = data['avatarPath'] as String?; + if (avatarPath != null) { + final file = File(avatarPath); + if (await file.exists()) { + if (mounted) { + setState(() { + _localAvatarPath = avatarPath; + }); + } + } + } + } + + if (_localAvatarPath == null && mounted) { + final cachedPath = ContactLocalNamesService().getContactAvatarPath( + widget.contactId, + ); + if (cachedPath != null) { + final file = File(cachedPath); + if (await file.exists()) { + if (mounted) { + setState(() { + _localAvatarPath = cachedPath; + }); + } + } + } + } + } catch (e) { + print('Ошибка загрузки локальных данных контакта: $e'); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + Future _saveContactData() async { + if (_isLoading || !_formKey.currentState!.validate()) { + return; + } + + setState(() { + _isLoading = true; + }); + + try { + final data = { + 'firstName': _firstNameController.text.trim(), + 'lastName': _lastNameController.text.trim(), + 'notes': _notesController.text.trim(), + 'updatedAt': DateTime.now().toIso8601String(), + }; + + if (_localAvatarPath != null) { + data['avatarPath'] = _localAvatarPath!; + } + await ContactLocalNamesService().saveContactData(widget.contactId, data); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Данные контакта сохранены'), + behavior: SnackBarBehavior.floating, + ), + ); + Navigator.of(context).pop(true); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка сохранения: $e'), + behavior: SnackBarBehavior.floating, + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + Future _clearContactData() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Очистить данные?'), + content: const Text( + 'Будут восстановлены оригинальные данные контакта с сервера.', + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + style: FilledButton.styleFrom( + backgroundColor: Colors.red.shade400, + foregroundColor: Colors.white, + ), + child: const Text('Очистить'), + ), + ], + ), + ); + + if (confirmed == true) { + try { + await ContactLocalNamesService().clearContactData(widget.contactId); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Данные контакта очищены'), + behavior: SnackBarBehavior.floating, + ), + ); + Navigator.of(context).pop(true); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка: $e'), + behavior: SnackBarBehavior.floating, + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } + } + + ImageProvider? _getAvatarImage() { + if (_localAvatarPath != null) { + return FileImage(File(_localAvatarPath!)); + } else if (widget.originalAvatarUrl != null) { + return NetworkImage(widget.originalAvatarUrl!); + } + return null; + } + + Future _pickAvatar() async { + try { + final ImagePicker picker = ImagePicker(); + final XFile? image = await picker.pickImage( + source: ImageSource.gallery, + maxWidth: 1024, + maxHeight: 1024, + imageQuality: 85, + ); + + if (image == null) return; + + setState(() { + _isLoadingAvatar = true; + }); + + File imageFile = File(image.path); + + final localPath = await ContactLocalNamesService().saveContactAvatar( + imageFile, + widget.contactId, + ); + + if (localPath != null && mounted) { + setState(() { + _localAvatarPath = localPath; + _isLoadingAvatar = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Аватар сохранен'), + behavior: SnackBarBehavior.floating, + ), + ); + } else { + if (mounted) { + setState(() { + _isLoadingAvatar = false; + }); + } + } + } catch (e) { + if (mounted) { + setState(() { + _isLoadingAvatar = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка загрузки аватара: $e'), + behavior: SnackBarBehavior.floating, + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } + + Future _removeAvatar() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Удалить аватар?'), + content: const Text( + 'Локальный аватар будет удален, будет показан оригинальный.', + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + style: FilledButton.styleFrom( + backgroundColor: Colors.red.shade400, + foregroundColor: Colors.white, + ), + child: const Text('Удалить'), + ), + ], + ), + ); + + if (confirmed == true) { + try { + await ContactLocalNamesService().removeContactAvatar(widget.contactId); + + if (mounted) { + setState(() { + _localAvatarPath = null; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Аватар удален'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка удаления: $e'), + behavior: SnackBarBehavior.floating, + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('Редактировать контакт'), + centerTitle: true, + scrolledUnderElevation: 0, + actions: [ + TextButton( + onPressed: _isLoading ? null : _saveContactData, + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text( + 'Сохранить', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + size: 20, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Эти данные сохраняются только локально', + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Text( + 'Аватар', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + GestureDetector( + onTap: _pickAvatar, + child: Stack( + children: [ + CircleAvatar( + radius: 60, + backgroundColor: + theme.colorScheme.secondaryContainer, + backgroundImage: _getAvatarImage(), + child: _getAvatarImage() == null + ? Icon( + Icons.person, + size: 60, + color: theme + .colorScheme + .onSecondaryContainer, + ) + : null, + ), + if (_isLoadingAvatar) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: const Center( + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3, + ), + ), + ), + ), + Positioned( + bottom: 4, + right: 4, + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.primary, + shape: BoxShape.circle, + ), + child: const Padding( + padding: EdgeInsets.all(8.0), + child: Icon( + Icons.camera_alt, + color: Colors.white, + size: 20, + ), + ), + ), + ), + ], + ), + ), + if (_localAvatarPath != null) ...[ + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _removeAvatar, + icon: const Icon(Icons.delete_outline), + label: const Text('Удалить аватар'), + style: OutlinedButton.styleFrom( + foregroundColor: theme.colorScheme.error, + side: BorderSide(color: theme.colorScheme.error), + ), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 16), + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Локальное имя', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _firstNameController, + maxLength: 60, + decoration: InputDecoration( + labelText: 'Имя', + hintText: widget.originalFirstName ?? 'Имя', + prefixIcon: const Icon(Icons.person_outline), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + counterText: '', + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _lastNameController, + maxLength: 60, + decoration: InputDecoration( + labelText: 'Фамилия', + hintText: widget.originalLastName ?? 'Фамилия', + prefixIcon: const Icon(Icons.person_outline), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + counterText: '', + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Заметки', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _notesController, + maxLines: 4, + maxLength: 400, + decoration: InputDecoration( + labelText: 'Заметки о контакте', + hintText: 'Добавьте заметки...', + prefixIcon: const Padding( + padding: EdgeInsets.only(bottom: 60), + child: Icon(Icons.note_outlined), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + alignLabelWithHint: true, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: _clearContactData, + icon: const Icon(Icons.restore), + label: const Text('Восстановить оригинальные данные'), + style: OutlinedButton.styleFrom( + foregroundColor: theme.colorScheme.error, + side: BorderSide(color: theme.colorScheme.error), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], + ), + ), + ), + ); + } + + @override + void dispose() { + _firstNameController.dispose(); + _lastNameController.dispose(); + _notesController.dispose(); + super.dispose(); + } +} + +class ContactLocalDataHelper { + static Future?> getContactData(int contactId) async { + return ContactLocalNamesService().getContactData(contactId); + } + + static Future clearContactData(int contactId) async { + await ContactLocalNamesService().clearContactData(contactId); + } +} diff --git a/lib/screens/settings/auth_settings_screen.dart b/lib/screens/settings/auth_settings_screen.dart new file mode 100644 index 0000000..ccdf46b --- /dev/null +++ b/lib/screens/settings/auth_settings_screen.dart @@ -0,0 +1,410 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:gwid/proxy_service.dart'; +import 'package:gwid/screens/settings/proxy_settings_screen.dart'; +import 'package:gwid/screens/settings/session_spoofing_screen.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AuthSettingsScreen extends StatefulWidget { + const AuthSettingsScreen({super.key}); + + @override + State createState() => _AuthSettingsScreenState(); +} + +class _AuthSettingsScreenState extends State + with SingleTickerProviderStateMixin { + bool _hasCustomAnonymity = false; + bool _hasProxyConfigured = false; + late AnimationController _animationController; + late Animation _fadeAnimation; + late Animation _slideAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + + _fadeAnimation = CurvedAnimation( + parent: _animationController, + curve: Curves.easeOut, + ); + + _slideAnimation = + Tween(begin: const Offset(0, 0.1), end: Offset.zero).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.easeOutCubic, + ), + ); + + _checkSettings(); + _animationController.forward(); + } + + Future _checkSettings() async { + await Future.wait([_checkAnonymitySettings(), _checkProxySettings()]); + } + + Future _checkAnonymitySettings() async { + final prefs = await SharedPreferences.getInstance(); + final anonymityEnabled = prefs.getBool('anonymity_enabled') ?? false; + if (mounted) { + setState(() => _hasCustomAnonymity = anonymityEnabled); + } + } + + Future _checkProxySettings() async { + final settings = await ProxyService.instance.loadProxySettings(); + if (mounted) { + setState(() { + _hasProxyConfigured = settings.isEnabled && settings.host.isNotEmpty; + }); + } + } + + Future _navigateToAnonymitySettings() async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const SessionSpoofingScreen()), + ); + _checkAnonymitySettings(); + } + + Future _navigateToProxySettings() async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const ProxySettingsScreen()), + ); + _checkProxySettings(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Scaffold( + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color.lerp(colors.surface, colors.primary, 0.05)!, + colors.surface, + Color.lerp(colors.surface, colors.tertiary, 0.05)!, + ], + ), + ), + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + style: IconButton.styleFrom( + backgroundColor: colors.surfaceContainerHighest, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Настройки', + style: GoogleFonts.manrope( + textStyle: textTheme.headlineSmall, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Безопасность и конфиденциальность', + style: GoogleFonts.manrope( + textStyle: textTheme.bodyMedium, + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + + Expanded( + child: FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: ListView( + padding: const EdgeInsets.all(24.0), + children: [ + _SettingsCard( + icon: _hasCustomAnonymity + ? Icons.verified_user + : Icons.visibility_outlined, + title: 'Настройки анонимности', + description: _hasCustomAnonymity + ? 'Активны кастомные настройки анонимности' + : 'Настройте анонимность для скрытия данных устройства', + isConfigured: _hasCustomAnonymity, + onTap: _navigateToAnonymitySettings, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: _hasCustomAnonymity + ? [ + Color.lerp( + colors.primaryContainer, + colors.primary, + 0.2, + )!, + colors.primaryContainer, + ] + : [ + colors.surfaceContainerHighest, + colors.surfaceContainer, + ], + ), + ), + + const SizedBox(height: 16), + + _SettingsCard( + icon: _hasProxyConfigured + ? Icons.vpn_key + : Icons.vpn_key_outlined, + title: 'Настройки прокси', + description: _hasProxyConfigured + ? 'Прокси-сервер настроен и активен' + : 'Настройте прокси-сервер для безопасного подключения', + isConfigured: _hasProxyConfigured, + onTap: _navigateToProxySettings, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: _hasProxyConfigured + ? [ + Color.lerp( + colors.tertiaryContainer, + colors.tertiary, + 0.2, + )!, + colors.tertiaryContainer, + ] + : [ + colors.surfaceContainerHighest, + colors.surfaceContainer, + ], + ), + ), + + const SizedBox(height: 32), + + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withOpacity( + 0.5, + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: colors.outline.withOpacity(0.2), + ), + ), + child: Row( + children: [ + Icon( + Icons.info_outline, + color: colors.primary, + size: 24, + ), + const SizedBox(width: 16), + Expanded( + child: Text( + 'Эти настройки помогут вам безопасно и анонимно использовать приложение', + style: GoogleFonts.manrope( + textStyle: textTheme.bodyMedium, + color: colors.onSurfaceVariant, + height: 1.4, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _SettingsCard extends StatelessWidget { + final IconData icon; + final String title; + final String description; + final bool isConfigured; + final VoidCallback onTap; + final Gradient gradient; + + const _SettingsCard({ + required this.icon, + required this.title, + required this.description, + required this.isConfigured, + required this.onTap, + required this.gradient, + }); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: Container( + decoration: BoxDecoration( + gradient: gradient, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isConfigured + ? colors.primary.withOpacity(0.3) + : colors.outline.withOpacity(0.2), + width: isConfigured ? 2 : 1, + ), + boxShadow: isConfigured + ? [ + BoxShadow( + color: colors.primary.withOpacity(0.1), + blurRadius: 20, + offset: const Offset(0, 4), + ), + ] + : null, + ), + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isConfigured + ? colors.primary.withOpacity(0.15) + : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + icon, + color: isConfigured + ? colors.primary + : colors.onSurfaceVariant, + size: 28, + ), + ), + const Spacer(), + if (isConfigured) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: colors.primary.withOpacity(0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle, + color: colors.primary, + size: 16, + ), + const SizedBox(width: 4), + Text( + 'Активно', + style: GoogleFonts.manrope( + textStyle: textTheme.labelSmall, + color: colors.primary, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + title, + style: GoogleFonts.manrope( + textStyle: textTheme.titleLarge, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + description, + style: GoogleFonts.manrope( + textStyle: textTheme.bodyMedium, + color: colors.onSurfaceVariant, + height: 1.4, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Text( + isConfigured ? 'Изменить настройки' : 'Настроить', + style: GoogleFonts.manrope( + textStyle: textTheme.labelLarge, + color: isConfigured + ? colors.primary + : colors.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 8), + Icon( + Icons.arrow_forward, + color: isConfigured + ? colors.primary + : colors.onSurfaceVariant, + size: 18, + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/services/contact_local_names_service.dart b/lib/services/contact_local_names_service.dart new file mode 100644 index 0000000..831fd3b --- /dev/null +++ b/lib/services/contact_local_names_service.dart @@ -0,0 +1,243 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:path_provider/path_provider.dart'; +import 'dart:convert'; + +class ContactLocalNamesService { + static final ContactLocalNamesService _instance = + ContactLocalNamesService._internal(); + factory ContactLocalNamesService() => _instance; + ContactLocalNamesService._internal(); + final Map> _cache = {}; + + final _changesController = StreamController.broadcast(); + Stream get changes => _changesController.stream; + + bool _initialized = false; + + Future initialize() async { + if (_initialized) return; + + try { + final prefs = await SharedPreferences.getInstance(); + final keys = prefs.getKeys(); + + for (final key in keys) { + if (key.startsWith('contact_')) { + final contactIdStr = key.replaceFirst('contact_', ''); + final contactId = int.tryParse(contactIdStr); + + if (contactId != null) { + final data = prefs.getString(key); + if (data != null) { + try { + final decoded = jsonDecode(data) as Map; + final avatarPath = decoded['avatarPath'] as String?; + if (avatarPath != null) { + final file = File(avatarPath); + if (!await file.exists()) { + decoded.remove('avatarPath'); + } + } + _cache[contactId] = decoded; + } catch (e) { + print( + 'Ошибка парсинга локальных данных для контакта $contactId: $e', + ); + } + } + } + } + } + + _initialized = true; + print( + '✅ ContactLocalNamesService: загружено ${_cache.length} локальных имен', + ); + } catch (e) { + print('❌ Ошибка инициализации ContactLocalNamesService: $e'); + } + } + + Map? getContactData(int contactId) { + return _cache[contactId]; + } + + String getDisplayName({ + required int contactId, + String? originalName, + String? originalFirstName, + String? originalLastName, + }) { + final localData = _cache[contactId]; + + if (localData != null) { + final firstName = localData['firstName'] as String?; + final lastName = localData['lastName'] as String?; + + if (firstName != null && firstName.isNotEmpty || + lastName != null && lastName.isNotEmpty) { + final fullName = '${firstName ?? ''} ${lastName ?? ''}'.trim(); + if (fullName.isNotEmpty) { + return fullName; + } + } + } + + if (originalFirstName != null || originalLastName != null) { + final fullName = '${originalFirstName ?? ''} ${originalLastName ?? ''}' + .trim(); + if (fullName.isNotEmpty) { + return fullName; + } + } + + return originalName ?? 'ID $contactId'; + } + + String? getDisplayDescription({ + required int contactId, + String? originalDescription, + }) { + final localData = _cache[contactId]; + + if (localData != null) { + final notes = localData['notes'] as String?; + if (notes != null && notes.isNotEmpty) { + return notes; + } + } + + return originalDescription; + } + + Future saveContactAvatar(File imageFile, int contactId) async { + try { + final directory = await getApplicationDocumentsDirectory(); + final avatarDir = Directory('${directory.path}/contact_avatars'); + + if (!await avatarDir.exists()) { + await avatarDir.create(recursive: true); + } + + final fileName = 'contact_$contactId.jpg'; + final savePath = '${avatarDir.path}/$fileName'; + + await imageFile.copy(savePath); + + final localData = _cache[contactId] ?? {}; + localData['avatarPath'] = savePath; + _cache[contactId] = localData; + + final prefs = await SharedPreferences.getInstance(); + final key = 'contact_$contactId'; + await prefs.setString(key, jsonEncode(localData)); + + _changesController.add(contactId); + + print('✅ Локальный аватар контакта сохранен: $savePath'); + return savePath; + } catch (e) { + print('❌ Ошибка сохранения локального аватара контакта: $e'); + return null; + } + } + + String? getContactAvatarPath(int contactId) { + final localData = _cache[contactId]; + if (localData != null) { + return localData['avatarPath'] as String?; + } + return null; + } + + String? getDisplayAvatar({ + required int contactId, + String? originalAvatarUrl, + }) { + final localAvatarPath = getContactAvatarPath(contactId); + if (localAvatarPath != null) { + final file = File(localAvatarPath); + if (file.existsSync()) { + return 'file://$localAvatarPath'; + } else { + final localData = _cache[contactId]; + if (localData != null) { + localData.remove('avatarPath'); + _cache[contactId] = localData; + } + } + } + + return originalAvatarUrl; + } + + Future removeContactAvatar(int contactId) async { + try { + final localData = _cache[contactId]; + if (localData != null) { + final avatarPath = localData['avatarPath'] as String?; + if (avatarPath != null) { + final file = File(avatarPath); + if (await file.exists()) { + await file.delete(); + } + } + + localData.remove('avatarPath'); + _cache[contactId] = localData; + + final prefs = await SharedPreferences.getInstance(); + final key = 'contact_$contactId'; + await prefs.setString(key, jsonEncode(localData)); + + _changesController.add(contactId); + + print('✅ Локальный аватар контакта удален'); + } + } catch (e) { + print('❌ Ошибка удаления локального аватара контакта: $e'); + } + } + + Future saveContactData(int contactId, Map data) async { + try { + final prefs = await SharedPreferences.getInstance(); + final key = 'contact_$contactId'; + await prefs.setString(key, jsonEncode(data)); + + _cache[contactId] = data; + + _changesController.add(contactId); + + print('✅ Сохранены локальные данные для контакта $contactId'); + } catch (e) { + print('❌ Ошибка сохранения локальных данных контакта: $e'); + } + } + + Future clearContactData(int contactId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final key = 'contact_$contactId'; + await prefs.remove(key); + + _cache.remove(contactId); + + _changesController.add(contactId); + + print('✅ Очищены локальные данные для контакта $contactId'); + } catch (e) { + print('❌ Ошибка очистки локальных данных контакта: $e'); + } + } + + void clearCache() { + _cache.clear(); + } + + void dispose() { + _changesController.close(); + } +} diff --git a/lib/services/local_profile_manager.dart b/lib/services/local_profile_manager.dart new file mode 100644 index 0000000..b14a95c --- /dev/null +++ b/lib/services/local_profile_manager.dart @@ -0,0 +1,57 @@ +import 'package:gwid/models/profile.dart'; +import 'package:gwid/services/profile_cache_service.dart'; + +class LocalProfileManager { + static final LocalProfileManager _instance = LocalProfileManager._internal(); + factory LocalProfileManager() => _instance; + LocalProfileManager._internal(); + + final ProfileCacheService _profileCache = ProfileCacheService(); + bool _initialized = false; + + Future initialize() async { + if (_initialized) return; + await _profileCache.initialize(); + _initialized = true; + } + + Future getActualProfile(Profile? serverProfile) async { + await initialize(); + + final localAvatarPath = await _profileCache.getLocalAvatarPath(); + final mergedProfile = await _profileCache.getMergedProfile(serverProfile); + + if (mergedProfile != null && localAvatarPath != null) { + return Profile( + id: mergedProfile.id, + phone: mergedProfile.phone, + firstName: mergedProfile.firstName, + lastName: mergedProfile.lastName, + description: mergedProfile.description, + photoBaseUrl: 'file://$localAvatarPath', + photoId: mergedProfile.photoId, + updateTime: mergedProfile.updateTime, + options: mergedProfile.options, + accountStatus: mergedProfile.accountStatus, + profileOptions: mergedProfile.profileOptions, + ); + } + + return mergedProfile; + } + + Future getLocalAvatarPath() async { + await initialize(); + return await _profileCache.getLocalAvatarPath(); + } + + Future hasLocalChanges() async { + await initialize(); + return await _profileCache.hasLocalChanges(); + } + + Future clearLocalChanges() async { + await initialize(); + await _profileCache.clearProfileCache(); + } +} diff --git a/lib/services/profile_cache_service.dart b/lib/services/profile_cache_service.dart new file mode 100644 index 0000000..e920fad --- /dev/null +++ b/lib/services/profile_cache_service.dart @@ -0,0 +1,241 @@ +import 'dart:io'; +import 'package:gwid/services/cache_service.dart'; +import 'package:gwid/models/profile.dart'; +import 'package:path_provider/path_provider.dart'; + +class ProfileCacheService { + static final ProfileCacheService _instance = ProfileCacheService._internal(); + factory ProfileCacheService() => _instance; + ProfileCacheService._internal(); + + final CacheService _cacheService = CacheService(); + + static const String _profileKey = 'my_profile_data'; + static const String _profileAvatarKey = 'my_profile_avatar'; + static const Duration _profileTTL = Duration(days: 30); + + bool _initialized = false; + + Future initialize() async { + if (_initialized) return; + await _cacheService.initialize(); + _initialized = true; + print('✅ ProfileCacheService инициализирован'); + } + + Future saveProfileData({ + required int userId, + required String firstName, + required String lastName, + String? description, + String? photoBaseUrl, + int? photoId, + }) async { + try { + final profileData = { + 'userId': userId, + 'firstName': firstName, + 'lastName': lastName, + 'description': description, + 'photoBaseUrl': photoBaseUrl, + 'photoId': photoId, + 'updatedAt': DateTime.now().toIso8601String(), + }; + + await _cacheService.set(_profileKey, profileData, ttl: _profileTTL); + print('✅ Данные профиля сохранены в кэш: $firstName $lastName'); + } catch (e) { + print('❌ Ошибка сохранения профиля в кэш: $e'); + } + } + + Future?> getProfileData() async { + try { + final cached = await _cacheService.get>( + _profileKey, + ttl: _profileTTL, + ); + + if (cached != null) { + print('✅ Данные профиля загружены из кэша'); + return cached; + } + } catch (e) { + print('❌ Ошибка загрузки профиля из кэша: $e'); + } + return null; + } + + Future saveAvatar(File imageFile, int userId) async { + try { + final directory = await getApplicationDocumentsDirectory(); + final avatarDir = Directory('${directory.path}/avatars'); + + if (!await avatarDir.exists()) { + await avatarDir.create(recursive: true); + } + + final fileName = 'profile_$userId.jpg'; + final savePath = '${avatarDir.path}/$fileName'; + + await imageFile.copy(savePath); + + await _cacheService.set(_profileAvatarKey, savePath, ttl: _profileTTL); + + print('✅ Аватар сохранен локально: $savePath'); + return savePath; + } catch (e) { + print('❌ Ошибка сохранения аватара: $e'); + return null; + } + } + + Future getLocalAvatarPath() async { + try { + final path = await _cacheService.get( + _profileAvatarKey, + ttl: _profileTTL, + ); + + if (path != null) { + final file = File(path); + if (await file.exists()) { + print('✅ Локальный аватар найден: $path'); + return path; + } else { + await _cacheService.remove(_profileAvatarKey); + } + } + } catch (e) { + print('❌ Ошибка загрузки локального аватара: $e'); + } + return null; + } + + Future updateProfileFields({ + String? firstName, + String? lastName, + String? description, + String? photoBaseUrl, + }) async { + try { + final currentData = await getProfileData(); + if (currentData == null) { + print('⚠️ Нет сохраненных данных профиля для обновления'); + return; + } + + if (firstName != null) currentData['firstName'] = firstName; + if (lastName != null) currentData['lastName'] = lastName; + if (description != null) currentData['description'] = description; + if (photoBaseUrl != null) currentData['photoBaseUrl'] = photoBaseUrl; + + currentData['updatedAt'] = DateTime.now().toIso8601String(); + + await _cacheService.set(_profileKey, currentData, ttl: _profileTTL); + print('✅ Поля профиля обновлены в кэше'); + } catch (e) { + print('❌ Ошибка обновления полей профиля: $e'); + } + } + + Future clearProfileCache() async { + try { + await _cacheService.remove(_profileKey); + await _cacheService.remove(_profileAvatarKey); + + final avatarPath = await getLocalAvatarPath(); + if (avatarPath != null) { + final file = File(avatarPath); + if (await file.exists()) { + await file.delete(); + } + } + + print('✅ Кэш профиля очищен'); + } catch (e) { + print('❌ Ошибка очистки кэша профиля: $e'); + } + } + + Future syncWithServerProfile(Profile serverProfile) async { + try { + final cachedData = await getProfileData(); + + if (cachedData != null) { + print( + '⚠️ Локальные данные профиля уже существуют, пропускаем синхронизацию', + ); + return; + } + + await saveProfileData( + userId: serverProfile.id, + firstName: serverProfile.firstName, + lastName: serverProfile.lastName, + description: serverProfile.description, + photoBaseUrl: serverProfile.photoBaseUrl, + photoId: serverProfile.photoId, + ); + print('✅ Профиль инициализирован с сервера'); + } catch (e) { + print('❌ Ошибка синхронизации профиля: $e'); + } + } + + Future getMergedProfile(Profile? serverProfile) async { + try { + final cachedData = await getProfileData(); + + if (cachedData == null && serverProfile == null) { + return null; + } + + if (cachedData == null && serverProfile != null) { + return serverProfile; + } + + if (cachedData != null && serverProfile == null) { + return Profile( + id: cachedData['userId'] ?? 0, + phone: '', + firstName: cachedData['firstName'] ?? '', + lastName: cachedData['lastName'] ?? '', + description: cachedData['description'], + photoBaseUrl: cachedData['photoBaseUrl'], + photoId: cachedData['photoId'] ?? 0, + updateTime: 0, + options: [], + accountStatus: 0, + profileOptions: [], + ); + } + + return Profile( + id: serverProfile!.id, + phone: serverProfile.phone, + firstName: cachedData!['firstName'] ?? serverProfile.firstName, + lastName: cachedData['lastName'] ?? serverProfile.lastName, + description: cachedData['description'] ?? serverProfile.description, + photoBaseUrl: cachedData['photoBaseUrl'] ?? serverProfile.photoBaseUrl, + photoId: cachedData['photoId'] ?? serverProfile.photoId, + updateTime: serverProfile.updateTime, + options: serverProfile.options, + accountStatus: serverProfile.accountStatus, + profileOptions: serverProfile.profileOptions, + ); + } catch (e) { + print('❌ Ошибка получения объединенного профиля: $e'); + return serverProfile; + } + } + + Future hasLocalChanges() async { + try { + final cachedData = await getProfileData(); + return cachedData != null; + } catch (e) { + return false; + } + } +} diff --git a/lib/widgets/contact_avatar_widget.dart b/lib/widgets/contact_avatar_widget.dart new file mode 100644 index 0000000..8d477ff --- /dev/null +++ b/lib/widgets/contact_avatar_widget.dart @@ -0,0 +1,139 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:gwid/services/contact_local_names_service.dart'; + +class ContactAvatarWidget extends StatefulWidget { + final int contactId; + final String? originalAvatarUrl; + final double radius; + final String? fallbackText; + final Color? backgroundColor; + final Color? textColor; + + const ContactAvatarWidget({ + super.key, + required this.contactId, + this.originalAvatarUrl, + this.radius = 24, + this.fallbackText, + this.backgroundColor, + this.textColor, + }); + + @override + State createState() => _ContactAvatarWidgetState(); +} + +class _ContactAvatarWidgetState extends State { + String? _localAvatarPath; + StreamSubscription? _subscription; + + @override + void initState() { + super.initState(); + _loadLocalAvatar(); + + _subscription = ContactLocalNamesService().changes.listen((contactId) { + if (contactId == widget.contactId && mounted) { + _loadLocalAvatar(); + } + }); + } + + @override + void didUpdateWidget(ContactAvatarWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.contactId != widget.contactId || + oldWidget.originalAvatarUrl != widget.originalAvatarUrl) { + _loadLocalAvatar(); + } + } + + void _loadLocalAvatar() { + final localPath = ContactLocalNamesService().getContactAvatarPath( + widget.contactId, + ); + if (localPath != null) { + final file = File(localPath); + if (file.existsSync()) { + setState(() { + _localAvatarPath = localPath; + }); + return; + } + } + + if (mounted) { + setState(() { + _localAvatarPath = null; + }); + } + } + + ImageProvider? _getAvatarImage() { + if (_localAvatarPath != null) { + return FileImage(File(_localAvatarPath!)); + } else if (widget.originalAvatarUrl != null) { + if (widget.originalAvatarUrl!.startsWith('file://')) { + final path = widget.originalAvatarUrl!.replaceFirst('file://', ''); + return FileImage(File(path)); + } + return NetworkImage(widget.originalAvatarUrl!); + } + return null; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final avatarImage = _getAvatarImage(); + + return CircleAvatar( + radius: widget.radius, + backgroundColor: + widget.backgroundColor ?? theme.colorScheme.secondaryContainer, + backgroundImage: avatarImage, + child: avatarImage == null + ? Text( + widget.fallbackText ?? '?', + style: TextStyle( + color: + widget.textColor ?? theme.colorScheme.onSecondaryContainer, + fontSize: widget.radius * 0.8, + ), + ) + : null, + ); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } +} + +ImageProvider? getContactAvatarImage({ + required int contactId, + String? originalAvatarUrl, +}) { + final localPath = ContactLocalNamesService().getContactAvatarPath(contactId); + + if (localPath != null) { + final file = File(localPath); + if (file.existsSync()) { + return FileImage(file); + } + } + + if (originalAvatarUrl != null) { + if (originalAvatarUrl.startsWith('file://')) { + final path = originalAvatarUrl.replaceFirst('file://', ''); + return FileImage(File(path)); + } + return NetworkImage(originalAvatarUrl); + } + + return null; +} diff --git a/lib/widgets/contact_display_name.dart b/lib/widgets/contact_display_name.dart new file mode 100644 index 0000000..98165d6 --- /dev/null +++ b/lib/widgets/contact_display_name.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:gwid/screens/edit_contact_screen.dart'; + +class ContactDisplayName extends StatefulWidget { + final int contactId; + final String? originalFirstName; + final String? originalLastName; + final String? fallbackName; + final TextStyle? style; + final int? maxLines; + final TextOverflow? overflow; + + const ContactDisplayName({ + super.key, + required this.contactId, + this.originalFirstName, + this.originalLastName, + this.fallbackName, + this.style, + this.maxLines, + this.overflow, + }); + + @override + State createState() => _ContactDisplayNameState(); +} + +class _ContactDisplayNameState extends State { + String? _localFirstName; + String? _localLastName; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadLocalData(); + } + + @override + void didUpdateWidget(ContactDisplayName oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.contactId != widget.contactId) { + _loadLocalData(); + } + } + + Future _loadLocalData() async { + setState(() { + _isLoading = true; + }); + + final localData = await ContactLocalDataHelper.getContactData( + widget.contactId, + ); + + if (mounted) { + setState(() { + _localFirstName = localData?['firstName'] as String?; + _localLastName = localData?['lastName'] as String?; + _isLoading = false; + }); + } + } + + String get _displayName { + final firstName = _localFirstName ?? widget.originalFirstName ?? ''; + final lastName = _localLastName ?? widget.originalLastName ?? ''; + final fullName = '$firstName $lastName'.trim(); + + if (fullName.isNotEmpty) { + return fullName; + } + + return widget.fallbackName ?? 'ID ${widget.contactId}'; + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return Text( + widget.fallbackName ?? '...', + style: widget.style, + maxLines: widget.maxLines, + overflow: widget.overflow, + ); + } + + return Text( + _displayName, + style: widget.style, + maxLines: widget.maxLines, + overflow: widget.overflow, + ); + } +} diff --git a/lib/widgets/contact_name_widget.dart b/lib/widgets/contact_name_widget.dart new file mode 100644 index 0000000..88ceaba --- /dev/null +++ b/lib/widgets/contact_name_widget.dart @@ -0,0 +1,96 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:gwid/services/contact_local_names_service.dart'; + +class ContactNameWidget extends StatefulWidget { + final int contactId; + final String? originalName; + final String? originalFirstName; + final String? originalLastName; + final TextStyle? style; + final int? maxLines; + final TextOverflow? overflow; + + const ContactNameWidget({ + super.key, + required this.contactId, + this.originalName, + this.originalFirstName, + this.originalLastName, + this.style, + this.maxLines, + this.overflow, + }); + + @override + State createState() => _ContactNameWidgetState(); +} + +class _ContactNameWidgetState extends State { + late String _displayName; + StreamSubscription? _subscription; + + @override + void initState() { + super.initState(); + _updateDisplayName(); + + _subscription = ContactLocalNamesService().changes.listen((contactId) { + if (contactId == widget.contactId && mounted) { + _updateDisplayName(); + } + }); + } + + @override + void didUpdateWidget(ContactNameWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.contactId != widget.contactId || + oldWidget.originalName != widget.originalName || + oldWidget.originalFirstName != widget.originalFirstName || + oldWidget.originalLastName != widget.originalLastName) { + _updateDisplayName(); + } + } + + void _updateDisplayName() { + setState(() { + _displayName = ContactLocalNamesService().getDisplayName( + contactId: widget.contactId, + originalName: widget.originalName, + originalFirstName: widget.originalFirstName, + originalLastName: widget.originalLastName, + ); + }); + } + + @override + Widget build(BuildContext context) { + return Text( + _displayName, + style: widget.style, + maxLines: widget.maxLines, + overflow: widget.overflow, + ); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } +} + +String getContactDisplayName({ + required int contactId, + String? originalName, + String? originalFirstName, + String? originalLastName, +}) { + return ContactLocalNamesService().getDisplayName( + contactId: contactId, + originalName: originalName, + originalFirstName: originalFirstName, + originalLastName: originalLastName, + ); +} diff --git a/lib/widgets/message_preview_dialog.dart b/lib/widgets/message_preview_dialog.dart index c4c3a5b..56cb796 100644 --- a/lib/widgets/message_preview_dialog.dart +++ b/lib/widgets/message_preview_dialog.dart @@ -6,6 +6,7 @@ import 'package:gwid/models/contact.dart'; import 'package:gwid/models/profile.dart'; import 'package:gwid/api/api_service.dart'; import 'package:gwid/widgets/chat_message_bubble.dart'; +import 'package:gwid/widgets/contact_name_widget.dart'; import 'package:gwid/chat_screen.dart'; class ControlMessageChip extends StatelessWidget { @@ -26,8 +27,15 @@ class ControlMessageChip extends StatelessWidget { ); final eventType = controlAttach['event']; - final senderName = - contacts[message.senderId]?.name ?? 'ID ${message.senderId}'; + final senderContact = contacts[message.senderId]; + final senderName = senderContact != null + ? getContactDisplayName( + contactId: senderContact.id, + originalName: senderContact.name, + originalFirstName: senderContact.firstName, + originalLastName: senderContact.lastName, + ) + : 'ID ${message.senderId}'; final isMe = message.senderId == myId; final senderDisplayName = isMe ? 'Вы' : senderName; @@ -40,7 +48,16 @@ class ControlMessageChip extends StatelessWidget { if (id == myId) { return 'Вы'; } - return contacts[id]?.name ?? 'участник с ID $id'; + final contact = contacts[id]; + if (contact != null) { + return getContactDisplayName( + contactId: contact.id, + originalName: contact.name, + originalFirstName: contact.firstName, + originalLastName: contact.lastName, + ); + } + return 'участник с ID $id'; }) .where((name) => name.isNotEmpty) .join(', '); diff --git a/lib/widgets/user_profile_panel.dart b/lib/widgets/user_profile_panel.dart index dc85e34..b97f8af 100644 --- a/lib/widgets/user_profile_panel.dart +++ b/lib/widgets/user_profile_panel.dart @@ -1,5 +1,9 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:gwid/services/avatar_cache_service.dart'; +import 'package:gwid/widgets/contact_name_widget.dart'; +import 'package:gwid/widgets/contact_avatar_widget.dart'; +import 'package:gwid/services/contact_local_names_service.dart'; class UserProfilePanel extends StatefulWidget { final int userId; @@ -33,27 +37,62 @@ class UserProfilePanel extends StatefulWidget { class _UserProfilePanelState extends State { final ScrollController _nameScrollController = ScrollController(); + String? _localDescription; + StreamSubscription? _changesSubscription; String get _displayName { - if (widget.firstName != null || widget.lastName != null) { - final firstName = widget.firstName ?? ''; - final lastName = widget.lastName ?? ''; - final fullName = '$firstName $lastName'.trim(); - return fullName.isNotEmpty - ? fullName - : (widget.name ?? 'ID ${widget.userId}'); + final displayName = getContactDisplayName( + contactId: widget.userId, + originalName: widget.name, + originalFirstName: widget.firstName, + originalLastName: widget.lastName, + ); + return displayName; + } + + String? get _displayDescription { + if (_localDescription != null && _localDescription!.isNotEmpty) { + return _localDescription; } - return widget.name ?? 'ID ${widget.userId}'; + return widget.description; } @override void initState() { super.initState(); + _loadLocalDescription(); + + _changesSubscription = ContactLocalNamesService().changes.listen(( + contactId, + ) { + if (contactId == widget.userId && mounted) { + _loadLocalDescription(); + } + }); + WidgetsBinding.instance.addPostFrameCallback((_) { _checkNameLength(); }); } + Future _loadLocalDescription() async { + final localData = await ContactLocalNamesService().getContactData( + widget.userId, + ); + if (mounted) { + setState(() { + _localDescription = localData?['notes'] as String?; + }); + } + } + + @override + void dispose() { + _changesSubscription?.cancel(); + _nameScrollController.dispose(); + super.dispose(); + } + void _checkNameLength() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_nameScrollController.hasClients) { @@ -99,12 +138,6 @@ class _UserProfilePanelState extends State { }); } - @override - void dispose() { - _nameScrollController.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; @@ -130,11 +163,13 @@ class _UserProfilePanelState extends State { padding: const EdgeInsets.all(20), child: Column( children: [ - AvatarCacheService().getAvatarWidget( - widget.avatarUrl, - userId: widget.userId, - size: 80, - fallbackText: _displayName, + ContactAvatarWidget( + contactId: widget.userId, + originalAvatarUrl: widget.avatarUrl, + radius: 40, + fallbackText: _displayName.isNotEmpty + ? _displayName[0].toUpperCase() + : '?', backgroundColor: colors.primaryContainer, textColor: colors.onPrimaryContainer, ), @@ -213,11 +248,11 @@ class _UserProfilePanelState extends State { ), ], ), - if (widget.description != null && - widget.description!.isNotEmpty) ...[ + if (_displayDescription != null && + _displayDescription!.isNotEmpty) ...[ const SizedBox(height: 24), Text( - widget.description!, + _displayDescription!, style: TextStyle( color: colors.onSurfaceVariant, fontSize: 14,