import 'dart:convert'; import 'package:chepuhagram/core/constants.dart'; import 'package:chepuhagram/domain/services/api_service.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../screens/settings_screen.dart'; import '../screens/new_chat_screen.dart'; import '../screens/chat_screen.dart'; import 'my_profile_screen.dart'; import '/logic/contact_provider.dart'; import '/logic/auth_provider.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:chepuhagram/domain/services/crypto_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:chepuhagram/main.dart'; import 'dart:async'; import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:path_provider/path_provider.dart'; import 'package:open_filex/open_filex.dart'; import '/data/datasources/ws_client.dart'; import '/data/models/contact_model.dart'; import '/data/models/message_model.dart'; import '/data/repositories/contact_repository.dart'; import 'user_profile_screen.dart'; import 'admin_broadcast_screen.dart'; import 'package:url_launcher/url_launcher.dart'; import 'dart:math' as math; import 'package:cached_network_image/cached_network_image.dart'; class ContactsScreen extends StatefulWidget { final int? targetChatId; const ContactsScreen({super.key, this.targetChatId}); @override State createState() => _ContactsScreenState(); } class _ContactsScreenState extends State with RouteAware { static const String _notificationLaunchKey = 'notification_launch_data'; StreamSubscription? _socketSubscription; bool _isDownloading = false; double _downloadProgress = 0.0; int _downloadedBytes = 0; int _downloadTotalBytes = 0; int _apkFileSizeBytes = 0; CancelToken? _cancelToken = CancelToken(); String? _latestApkUrl; bool _showUpdateBanner = false; bool _contactsLoaded = false; Timer? _contactLoadTimer; Contact? _selectedContact; Contact? _profileContact; double _contactsPaneWidth = 290; double _profilePaneWidth = 360; final double _collapsedContactsWidth = 80; final double _minExpandedContactsWidth = 290; final double _maxExpandedContactsWidth = 500; double _dragStartWidth = 0; // Адаптивное состояние навигации int _currentIndex = 0; bool _isLeftRailExpanded = false; // Хранилище стабильно загруженных локальных имён Map _localFullNames = {}; final Map _pendingUnreadCounters = {}; bool _isSearchMode = false; final TextEditingController _searchController = TextEditingController(); final List _searchChatResults = []; final List _searchNewResults = []; bool _isSearchLoading = false; String? _searchError; Timer? _searchDebounce; @override void initState() { super.initState(); print('ContactsScreen initState, targetChatId: ${widget.targetChatId}'); _setupPushNotifications(); final socketService = Provider.of(context, listen: false); _socketSubscription = socketService.messages.listen(_handleIncomingMessage); WidgetsBinding.instance.addPostFrameCallback((_) { final authProvider = context.read(); final contactProvider = context.read(); print( 'Setting current user ID in ContactProvider: ${authProvider.currentUserId}', ); contactProvider.setCurrentUserId(authProvider.currentUserId); _startContactsLoadTimer(); }); } // Метод стабильной потокобезопасной подгрузки локальных имён из кэша Future _loadLocalNames() async { final prefs = await SharedPreferences.getInstance(); final contactProvider = context.read(); final Map tempNames = {}; for (var contact in contactProvider.contacts) { final String? fName = prefs.getString('firstname_${contact.id}'); final String? lName = prefs.getString('lastname_${contact.id}'); if (fName != null || lName != null) { tempNames[contact.id] = '${fName ?? ''} ${lName ?? ''}'.trim(); } } if (mounted) { setState(() { _localFullNames = tempNames; }); } } Future _startContactsLoadTimer() async { if (_contactLoadTimer != null && _contactLoadTimer!.isActive) return; _contactLoadTimer = Timer(const Duration(seconds: 2), () { _initContacts(); }); } Future _initContacts() async { if (_contactsLoaded) return; final contactProvider = context.read(); await contactProvider.loadContacts(); await _loadLocalNames(); // Гарантированный вызов после загрузки контактов print('Contacts loaded, checking targetChatId: ${widget.targetChatId}'); WidgetsBinding.instance.addPostFrameCallback((_) { _checkAppUpdate(); }); if (widget.targetChatId != null) { _navigateToTargetChat(); } else { _checkSavedNotificationTarget(); } _contactLoadTimer?.cancel(); _contactLoadTimer = null; _contactsLoaded = true; } @override void didChangeDependencies() { super.didChangeDependencies(); routeObserver.subscribe(this, ModalRoute.of(context) as PageRoute); } @override void didPopNext() async { print("Пользователь вернулся на этот экран!"); await _refreshData(); } @override void dispose() { routeObserver.unsubscribe(this); _socketSubscription?.cancel(); _searchController.dispose(); _searchDebounce?.cancel(); super.dispose(); } Future _refreshData() async { print("Обновляем данные контактов и сообщений..."); final contactProvider = context.read(); await contactProvider.loadContacts(); await _loadLocalNames(); // Синхронизируем локальные имена при возврате } Future _checkSavedNotificationTarget() async { final prefs = await SharedPreferences.getInstance(); final savedData = prefs.getString(_notificationLaunchKey); if (savedData == null) { print('No saved notification data found in SharedPreferences'); return; } try { final data = jsonDecode(savedData) as Map; print('Recovered saved notification data: $data'); final senderId = int.tryParse(data['sender_id']?.toString() ?? ''); final type = data['type']?.toString(); if (senderId != null && (type == null || type == 'enc_message')) { print('Recovered targetChatId from saved data: $senderId'); await prefs.remove(_notificationLaunchKey); _navigateToTargetChatWithId(senderId); return; } print('Saved notification data is not a valid payload: $data'); await prefs.remove(_notificationLaunchKey); } catch (e) { print('Error parsing saved notification data: $e'); await prefs.remove(_notificationLaunchKey); } } void _navigateToTargetChat() { if (widget.targetChatId == null) return; _navigateToTargetChatWithId(widget.targetChatId!); } void _navigateToTargetChatWithId(int targetChatId) { print('_navigateToTargetChat called with targetChatId: $targetChatId'); final contactProvider = context.read(); try { final contact = contactProvider.contacts.firstWhere( (c) => c.id == targetChatId, ); print('Auto-navigating to chat with contact: ${contact.username}'); _selectContact(contact); } catch (e) { print('Target contact with id $targetChatId not found: $e'); } } bool _isMobileLayout(BuildContext context) { return MediaQuery.of(context).size.width < 700; } bool _isTabletLayout(BuildContext context) { final width = MediaQuery.of(context).size.width; return width >= 700 && width < 1000; } bool _isDesktopLayout(BuildContext context) { return MediaQuery.of(context).size.width >= 1000; } void _selectContact(Contact contact) { setState(() { _selectedContact = contact; _pendingUnreadCounters.remove(contact.id); if (_profileContact != null && _isDesktopLayout(context)) { _profileContact = contact; } currentActiveChatContactId = contact.id; }); } void _openProfile(Contact contact) { if (_isDesktopLayout(context)) { setState(() { _profileContact = contact; }); return; } if (_isTabletLayout(context)) { showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => SizedBox( height: MediaQuery.of(context).size.height * 0.85, child: UserProfileScreen( userId: contact.id, username: contact.username, name: contact.name, ), ), ); return; } Navigator.push( context, MaterialPageRoute( builder: (_) => UserProfileScreen( userId: contact.id, username: contact.username, name: contact.name, ), ), ); } void _clearSelectedContact() { setState(() { _selectedContact = null; if (!_isDesktopLayout(context)) { _profileContact = null; } currentActiveChatContactId = null; }); } void _toggleSearchMode(bool enabled) { setState(() { _isSearchMode = enabled; if (enabled && _contactsPaneWidth <= _collapsedContactsWidth) { _contactsPaneWidth = _minExpandedContactsWidth; } if (!enabled) { _searchController.clear(); _searchChatResults.clear(); _searchError = null; _isSearchLoading = false; } }); } void _onSearchQueryChanged(String query) { setState(() {}); _searchDebounce?.cancel(); _searchDebounce = Timer(const Duration(milliseconds: 250), () { _executeSearch(query); }); } Future _executeSearch(String query) async { final trimmedQuery = query.trim(); if (trimmedQuery.isEmpty) { setState(() { _searchChatResults.clear(); _searchError = null; _isSearchLoading = false; }); return; } setState(() { _isSearchLoading = true; _searchError = null; }); try { final contactRepository = ContactRepository(); final chats = await contactRepository.fetchChatContacts( query: trimmedQuery, ); final allUsers = await contactRepository.fetchAllUsers( query: trimmedQuery, ); final activeChatIds = chats.map((contact) => contact.id).toSet(); final otherUsers = allUsers.where((contact) { return !activeChatIds.contains(contact.id) && contact.id != context.read().currentUserId; }).toList(); setState(() { _searchChatResults ..clear() ..addAll(chats); _searchNewResults ..clear() ..addAll(otherUsers); _searchError = null; _isSearchLoading = false; }); } catch (e) { setState(() { _searchError = e.toString(); _searchChatResults.clear(); _searchNewResults.clear(); _isSearchLoading = false; }); } } Widget _buildSearchTextField(ColorScheme colorScheme) { return SizedBox( height: 46, child: TextField( controller: _searchController, autofocus: true, textInputAction: TextInputAction.search, decoration: InputDecoration( hintText: 'Найти пользователя или чат', prefixIcon: const Icon(Icons.search_rounded), suffixIcon: IconButton( icon: const Icon(Icons.close_rounded), onPressed: () { _toggleSearchMode(false); }, ), filled: true, fillColor: colorScheme.surfaceVariant.withOpacity(0.16), border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none, ), contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), ), onChanged: _onSearchQueryChanged, onSubmitted: _executeSearch, ), ); } Widget _buildSearchResultTile(Contact contact, {required bool isChat}) { final localName = _localFullNames[contact.id]; final displayName = (localName != null && localName.isNotEmpty) ? localName : '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}' .trim(); final title = displayName.isNotEmpty ? displayName : contact.username; final subtitle = contact.username.isNotEmpty ? '@${contact.username}' : 'ID: ${contact.id}'; return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), leading: CircleAvatar( backgroundColor: Theme.of(context).colorScheme.primaryContainer, foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer, child: Text( title .split(RegExp(r'\s+')) .take(2) .map((part) => part.isNotEmpty ? part[0].toUpperCase() : '') .join(), ), ), title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)), subtitle: Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis), trailing: isChat ? null : const Icon(Icons.person_add_outlined, size: 20), onTap: () { _selectContact(contact); _toggleSearchMode(false); }, ); } Widget _buildSearchResults() { if (_searchError != null) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Text( 'Ошибка поиска: $_searchError', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge, ), ), ); } if (_searchController.text.trim().length < 2) { return _buildPlaceholder('Введите минимум 2 символа для поиска.'); } if (_isSearchLoading) { return const Center(child: CircularProgressIndicator()); } if (_searchChatResults.isEmpty && _searchNewResults.isEmpty) { return _buildPlaceholder('Ничего не найдено. Попробуйте другой запрос.'); } final children = []; if (_searchChatResults.isNotEmpty) { children.add( Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( 'Активные чаты', style: Theme.of(context).textTheme.titleMedium, ), ), ); children.addAll( _searchChatResults.map( (contact) => _buildSearchResultTile(contact, isChat: true), ), ); } if (_searchNewResults.isNotEmpty) { children.add( Padding( padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), child: Text( 'Пользователи', style: Theme.of(context).textTheme.titleMedium, ), ), ); children.addAll( _searchNewResults.map( (contact) => _buildSearchResultTile(contact, isChat: false), ), ); } return ListView(physics: const BouncingScrollPhysics(), children: children); } Widget _buildPlaceholder(String text) { return Center( child: Padding( padding: const EdgeInsets.all(24.0), child: Text( text, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ), ); } Widget _buildContactsPane() { return Consumer( builder: (context, contactProvider, child) { if (_isSearchMode) { return _buildSearchResults(); } if (contactProvider.isLoading) { return const Center(child: CircularProgressIndicator()); } if (contactProvider.error != null) { return Center( child: Text( '${contactProvider.error?.replaceAll('Exception: ', '')}', style: TextStyle(color: Theme.of(context).colorScheme.error), textAlign: TextAlign.center, ), ); } final isCollapsed = !_isMobileLayout(context) && (_contactsPaneWidth <= _collapsedContactsWidth); if (contactProvider.contacts.isEmpty) { return _buildPlaceholder( 'Список чатов пуст. Нажмите карандаш, чтобы начать.', ); } return ListView.separated( physics: const BouncingScrollPhysics(), itemCount: contactProvider.contacts.length, separatorBuilder: (context, index) => Divider( height: 1, indent: isCollapsed ? 12 : 84, endIndent: 12, color: Theme.of( context, ).colorScheme.outlineVariant.withOpacity(0.15), ), itemBuilder: (context, index) { final contact = contactProvider.contacts[index]; final colorScheme = Theme.of(context).colorScheme; final isSelected = _selectedContact?.id == contact.id; final localName = _localFullNames[contact.id]; final displayName = (localName != null && localName.isNotEmpty) ? localName : '${contact.name != 'Unknown' ? contact.name : ''} ${contact.surname != 'Unknown' ? contact.surname : ''}' .trim(); final contactInitials = displayName.isNotEmpty ? displayName .trim() .split(RegExp(r'\s+')) .take(2) .map((e) => e[0].toUpperCase()) .join() : '?'; String timeText = ''; if (contact.lastMessageTime != null) { final localTime = contact.lastMessageTime!.toLocal(); timeText = '${localTime.hour.toString().padLeft(2, '0')}:${localTime.minute.toString().padLeft(2, '0')}'; } final bool isLastMessageEmpty = contact.lastMessage != null && contact.lastMessage!.trim().isEmpty; final String displayLastMessage = isLastMessageEmpty ? contact.lastMessageType != null ? MessageModel.getMediaPreview(contact.lastMessageType!) : 'Вложение' : (contact.lastMessage ?? 'Нет сообщений'); return Padding( // ФИКС ОВЕРФЛОУ: уменьшаем внешний отступ при сжатии до 4px (было 8) padding: EdgeInsets.symmetric( horizontal: isCollapsed ? 4 : 8, vertical: 2, ), child: InkWell( onTap: () => _selectContact(contact), borderRadius: BorderRadius.circular(16), child: AnimatedContainer( duration: const Duration(milliseconds: 150), // ФИКС ОВЕРФЛОУ: уменьшаем внутренний отступ при сжатии до 6px (было 12) padding: EdgeInsets.symmetric( horizontal: isCollapsed ? 6 : 12, vertical: 10, ), decoration: BoxDecoration( color: isSelected ? colorScheme.primaryContainer.withOpacity(0.4) : Colors.transparent, borderRadius: BorderRadius.circular(16), ), child: Row( // Центрируем аватарку по оси, когда колонка зажата mainAxisAlignment: isCollapsed ? MainAxisAlignment.center : MainAxisAlignment.start, children: [ ContactAvatar( key: ValueKey('avatar_${contact.id}'), initials: contactInitials, avatarUrl: contact.avatarUrl, isOnline: contact.isOnline, isSelected: isSelected, colorScheme: colorScheme, ), if (!isCollapsed) ...[ const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Text( displayName, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, letterSpacing: -0.3, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), if (timeText.isNotEmpty) Text( timeText, style: TextStyle( color: colorScheme.outline, fontSize: 12, fontWeight: FontWeight.w500, ), ), ], ), const SizedBox(height: 5), const SizedBox(height: 5), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: AnimatedSwitcher( duration: const Duration( milliseconds: 600, ), reverseDuration: const Duration( milliseconds: 200, ), switchInCurve: Curves.easeInOutCubic, switchOutCurve: Curves.easeInOutCubic, transitionBuilder: ( Widget child, Animation animation, ) { return Align( alignment: Alignment.centerLeft, child: FadeTransition( opacity: animation, child: SlideTransition( position: Tween( begin: const Offset( 0.0, 0.15, ), end: Offset.zero, ).animate(animation), child: child, ), ), ); }, child: _buildAnimatedLastMessage( contact: contact, displayLastMessage: displayLastMessage, isLastMessageEmpty: isLastMessageEmpty, colorScheme: colorScheme, ), ), ), if (contact.unreadCount > 0) Container( padding: const EdgeInsets.symmetric( horizontal: 7, vertical: 3, ), decoration: BoxDecoration( color: colorScheme.primary, borderRadius: BorderRadius.circular(10), ), constraints: const BoxConstraints( minWidth: 20, ), child: Text( '${contact.unreadCount}', style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold, ), textAlign: TextAlign.center, ), ), ], ), ], ), ), ], ], ), ), ), ); }, ); }, ); } Widget _buildAnimatedLastMessage({ required Contact contact, required String displayLastMessage, required bool isLastMessageEmpty, required ColorScheme colorScheme, }) { if (contact.lastMessage == null) { return Text( 'Нет сообщений', key: ValueKey('empty_${contact.id}'), style: TextStyle( color: colorScheme.onSurfaceVariant.withOpacity(0.5), fontSize: 14, ), maxLines: 1, overflow: TextOverflow.ellipsis, ); } // Состояние дешифровки if (!contact.isLastMsgDecrypted) { return Row( key: ValueKey('decrypting_${contact.id}'), mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ SizedBox( width: 10, height: 10, child: CircularProgressIndicator( strokeWidth: 1.2, valueColor: AlwaysStoppedAnimation( colorScheme.primary.withOpacity(0.6), ), ), ), const SizedBox(width: 6), // КРИТИЧЕСКИЙ ФИКС: Flexible защищает от Overflow, если колонка чатов сильно сжата Flexible( child: Text( 'Дешифровка...', // Сократили текст, чтобы он гарантированно не ломал UI style: TextStyle( color: colorScheme.primary.withOpacity(0.7), fontSize: 13, fontWeight: FontWeight.w500, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ); } // Полностью расшифрованный текст return Text( displayLastMessage, key: ValueKey('decrypted_${contact.id}_$displayLastMessage'), style: TextStyle( color: colorScheme.onSurfaceVariant, fontSize: 14, fontStyle: isLastMessageEmpty ? FontStyle.italic : FontStyle.normal, ), maxLines: 1, overflow: TextOverflow.ellipsis, ); } Widget _buildChatPane() { if (_selectedContact == null) { return _buildPlaceholder('Выберите чат слева, чтобы начать переписку.'); } return ChatScreen( key: ValueKey(_selectedContact!.id), contact: _selectedContact!, onOpenProfile: _openProfile, onBack: _clearSelectedContact, showBackButton: false, onMessageRead: (contactId, nextAnchorId) { final contactProvider = context.read(); final contact = contactProvider.contacts.firstWhere( (c) => c.id == contactId, orElse: () => _selectedContact!, ); int currentUnread = _pendingUnreadCounters[contactId] ?? contact.unreadCount; if (contact.unreadCount > currentUnread && !_pendingUnreadCounters.containsKey(contactId)) { currentUnread = contact.unreadCount; } if (currentUnread > 0) { final newCount = currentUnread - 1; _pendingUnreadCounters[contactId] = newCount; contactProvider.updateContact( contactId, unreadCount: newCount, firstUnreadMessageId: nextAnchorId, ); } }, ); } Widget _buildProfilePane() { final contact = _profileContact; if (contact == null) { return _buildPlaceholder('Профиль выбранного пользователя будет здесь.'); } return UserProfileScreen( key: ValueKey(contact.id), userId: contact.id, username: contact.username, name: contact.name, onClose: () { setState(() { _profileContact = null; }); }, ); } Widget _buildContactsListWithScaffold(bool isPhone) { final colorScheme = Theme.of(context).colorScheme; final isCollapsed = !isPhone && (_contactsPaneWidth <= _collapsedContactsWidth); Widget bodyWidget; String titleText = "Chepuhagram"; bool showSearch = true; if (isPhone) { switch (_currentIndex) { case 2: titleText = "Профиль"; showSearch = false; bodyWidget = const MyProfileScreen(isFromList: true); break; case 1: titleText = "Настройки"; showSearch = false; bodyWidget = const SettingsScreen(isFromList: true); break; case 0: default: titleText = "Chepuhagram"; showSearch = true; bodyWidget = _buildContactsPane(); break; } } else { bodyWidget = _buildContactsPane(); } return Scaffold( backgroundColor: isPhone ? colorScheme.background : Colors.transparent, appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, scrolledUnderElevation: 0, title: _isSearchMode ? _buildSearchTextField(colorScheme) : Text( titleText, style: TextStyle( color: colorScheme.onBackground, fontWeight: FontWeight.w800, fontSize: 24, letterSpacing: -0.5, ), ), centerTitle: false, actions: [ if (showSearch && !_isSearchMode) Padding( padding: const EdgeInsets.only(right: 16.0), child: ClipOval( child: Material( child: IconButton( icon: Icon(Icons.search_rounded, size: 22), color: colorScheme.onBackground, onPressed: () { if (_isSearchMode) { _toggleSearchMode(false); } else { _toggleSearchMode(true); } }, ), ), ), ), ], ), body: Column( children: [ Expanded(child: bodyWidget), if (_showUpdateBanner) SafeArea(top: false, child: _buildUpdateBanner(isPhone)), ], ), floatingActionButton: null, /* (isCollapsed || (isPhone && _currentIndex != 0)) ? null : AnimatedPadding( duration: const Duration(milliseconds: 150), curve: Curves.easeInOut, padding: EdgeInsets.only( bottom: _showUpdateBanner ? _isDownloading ? 150.0 : 100.0 : 16.0, ), child: FloatingActionButton( onPressed: () => Navigator.push( context, MaterialPageRoute(builder: (_) => const NewChatScreen()), ), child: const Icon(Icons.edit_note_rounded), ), ),*/ bottomNavigationBar: isPhone ? BottomNavigationBar( currentIndex: _currentIndex, elevation: 8, onTap: (index) => setState(() => _currentIndex = index), items: const [ BottomNavigationBarItem( icon: Icon(Icons.chat_bubble_outline_rounded), activeIcon: Icon(Icons.chat_bubble_rounded), label: "Чаты", ), BottomNavigationBarItem( icon: Icon(Icons.settings_outlined), activeIcon: Icon(Icons.settings_rounded), label: "Настройки", ), BottomNavigationBarItem( icon: Icon(Icons.person_outline_rounded), activeIcon: Icon(Icons.person_rounded), label: "Профиль", ), ], ) : null, ); } Widget _buildUpdateBanner(bool isPhone) { final isCollapsed = !isPhone && (_contactsPaneWidth <= _collapsedContactsWidth); if (isCollapsed) return const SizedBox.shrink(); final colorScheme = Theme.of(context).colorScheme; return Container( // Сделали аккуратные отступы сверху для баннера margin: const EdgeInsets.fromLTRB(16, 4, 16, 12), child: Material( elevation: 6, borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( gradient: LinearGradient( colors: [Colors.orange.shade600, Colors.deepOrange.shade400], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(12), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Expanded( child: Text( _isDownloading ? 'Скачивание ${(_downloadProgress * 100).toStringAsFixed(0)}%' : _apkFileSizeBytes > 0 ? 'Доступно новое обновление: ${_formatBytes(_apkFileSizeBytes)}' : 'Доступно новое обновление!', style: TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ), TextButton( onPressed: () async { if (_isDownloading) { _cancelToken?.cancel("Пользователь отменил загрузку"); setState(() { _isDownloading = false; _downloadProgress = 0.0; }); } else { setState(() { _isDownloading = true; _cancelToken = CancelToken(); }); await _startDownload(); } }, style: TextButton.styleFrom( backgroundColor: colorScheme.onPrimary.withOpacity(0.12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), child: Text( _isDownloading ? "Отмена" : "Обновить", style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, ), ), ), ], ), if (_isDownloading) ...[ const SizedBox(height: 12), LinearProgressIndicator( value: _downloadProgress, color: Colors.white, backgroundColor: Colors.white54, ), const SizedBox(height: 8), Align( alignment: Alignment.centerLeft, child: Text( '${_formatBytes(_downloadedBytes)} из ${_formatBytes(_downloadTotalBytes)}', style: TextStyle(color: Colors.white, fontSize: 14), ), ), ], ], ), ), ), ); } Widget _buildWindowsNavigationRail() { final double railWidth = _isLeftRailExpanded ? 220 : 68; final colorScheme = Theme.of(context).colorScheme; return AnimatedContainer( duration: const Duration(milliseconds: 250), curve: Curves.fastOutSlowIn, width: railWidth, color: colorScheme.surfaceVariant.withOpacity(0.12), child: Column( children: [ const SizedBox(height: 12), IconButton( icon: Icon( _isLeftRailExpanded ? Icons.menu_open_rounded : Icons.menu_rounded, ), onPressed: () => setState(() => _isLeftRailExpanded = !_isLeftRailExpanded), ), const Divider(height: 24, indent: 12, endIndent: 12), _buildRailItem( Icons.chat_bubble_outline_rounded, Icons.chat_bubble_rounded, "Чаты", 0, onTap: () => setState(() => _currentIndex = 0), ), const SizedBox(height: 8), const Divider(height: 12, indent: 12, endIndent: 12), _buildRailItem( Icons.settings_outlined, Icons.settings_rounded, "Настройки", 1, onTap: () => _showSettingsDialog(), ), _buildRailItem( Icons.person_outline_rounded, Icons.person_rounded, "Профиль", 2, onTap: () => _showProfileDialog(), ), ], ), ); } Widget _buildRailItem( IconData icon, IconData activeIcon, String label, int index, { Function()? onTap, }) { final isSelected = _currentIndex == index; final colorScheme = Theme.of(context).colorScheme; final color = isSelected ? colorScheme.primary : colorScheme.onSurface; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: LayoutBuilder( builder: (itemContext, itemConstraints) { final bool showLabel = _isLeftRailExpanded && itemConstraints.maxWidth >= 140; return InkWell( onTap: onTap ?? () => setState(() => _currentIndex = index), borderRadius: BorderRadius.circular(12), child: Container( height: 48, padding: EdgeInsets.symmetric(horizontal: showLabel ? 12 : 8), decoration: BoxDecoration( color: isSelected ? colorScheme.primary.withOpacity(0.08) : Colors.transparent, borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisAlignment: showLabel ? MainAxisAlignment.start : MainAxisAlignment.center, children: [ Icon(isSelected ? activeIcon : icon, color: color, size: 22), if (showLabel) ...[ const SizedBox(width: 16), Expanded( child: Text( label, style: TextStyle( color: color, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, fontSize: 14, ), overflow: TextOverflow.ellipsis, ), ), ], ], ), ), ); }, ), ); } void _showSettingsDialog() { // Создаем нотификатор высоты с начальным значением final heightNotifier = ValueNotifier(450.0); showDialog( context: context, builder: (ctx) { return ValueListenableBuilder( valueListenable: heightNotifier, builder: (context, currentHeight, child) { return Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), clipBehavior: Clip.antiAlias, insetPadding: const EdgeInsets.symmetric( vertical: 16, horizontal: 24, ), child: SizedBox( width: 520, height: currentHeight, // Примет идеальную высоту контента child: Navigator( onGenerateRoute: (settings) { return MaterialPageRoute( builder: (ctx2) => SettingsScreen( isFromList: true, // Передаем коллбек для замера высоты onHeightMeasured: (measuredHeight) { final maxHeight = MediaQuery.of(ctx).size.height - 32; // Обновляем высоту, не превышая размеры экрана heightNotifier.value = measuredHeight.clamp( 200.0, maxHeight, ); }, ), ); }, ), ), ); }, ); }, ); } void _showProfileDialog() { final heightNotifier = ValueNotifier(400.0); showDialog( context: context, builder: (ctx) { return ValueListenableBuilder( valueListenable: heightNotifier, builder: (context, currentHeight, child) { return Dialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), clipBehavior: Clip.antiAlias, insetPadding: const EdgeInsets.symmetric( vertical: 16, horizontal: 24, ), child: SizedBox( width: 520, height: currentHeight, // Примет идеальную высоту контента child: Navigator( onGenerateRoute: (settings) { return MaterialPageRoute( builder: (ctx2) => MyProfileScreen( isFromList: true, // Передаем коллбек для замера высоты onHeightMeasured: (measuredHeight) { final maxHeight = MediaQuery.of(ctx).size.height - 32; heightNotifier.value = measuredHeight.clamp( 200.0, maxHeight, ); }, ), ); }, ), ), ); }, ); }, ); } Widget _buildResizableDivider({ required Function(DragUpdateDetails) onPanUpdate, Function(DragStartDetails)? onPanStart, }) { return GestureDetector( onPanStart: onPanStart, onPanUpdate: onPanUpdate, child: MouseRegion( cursor: SystemMouseCursors.resizeColumn, child: Container( width: 8, color: Colors.transparent, alignment: Alignment.center, child: VerticalDivider( width: 1, thickness: 1, color: Theme.of(context).dividerColor.withOpacity(0.4), ), ), ), ); } Widget _buildResponsiveBody(bool isPhone) { final media = MediaQuery.of(context); // Проверка физического форм-фактора (надежнее, чем Platform.isAndroid) final bool isPhoneFormFactor = media.size.shortestSide < 600; //final bool isPhoneFormFactor = true; // 1. ЛОГИКА ДЛЯ СМАРТФОНОВ (любая ОС, если экран маленький) if (isPhoneFormFactor) { if (_selectedContact != null) { return ChatScreen( contact: _selectedContact!, onOpenProfile: _openProfile, onBack: _clearSelectedContact, showBackButton: true, ); } return _buildContactsListWithScaffold(true); } // 2. ЛОГИКА ДЛЯ ПЛАНШЕТОВ И КОМПЬЮТЕРОВ (Широкие экраны) Widget centerPane; switch (_currentIndex) { case 1: centerPane = const Expanded(child: MyProfileScreen(isFromList: false)); break; case 2: centerPane = const Expanded(child: SettingsScreen(isFromList: false)); break; case 3: centerPane = const Expanded( child: Center(child: Text('Трекер задач (пока не реализован)')), ); break; case 4: centerPane = const Expanded( child: Center(child: Text('Хранилище файлов (пока не реализовано)')), ); break; case 0: default: centerPane = Expanded( child: LayoutBuilder( builder: (ctx, constraints) { final available = constraints.maxWidth; final bool profileRequested = _profileContact != null && _isDesktopLayout(context); const double profileMin = 280; const double chatMin = 320; const double dividerWidth = 8; final double reservedForProfile = profileRequested ? profileMin + dividerWidth : 0; // Вычисляем допустимую ширину панели контактов final double maxContactsAllowed = (available - reservedForProfile - chatMin - dividerWidth) .clamp( _collapsedContactsWidth, _maxExpandedContactsWidth, ); final double contactsWidth = _contactsPaneWidth.clamp( _collapsedContactsWidth, maxContactsAllowed, ); // Если места на экране для профиля совсем нет — скрываем его final bool showProfile = profileRequested && (available > (contactsWidth + dividerWidth + chatMin + profileMin)); return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // ИСПРАВЛЕНО: Убран Flexible. Левая панель занимает строго contactsWidth SizedBox( width: contactsWidth, child: _buildContactsListWithScaffold(false), ), _buildResizableDivider( onPanStart: (details) => _dragStartWidth = _contactsPaneWidth, onPanUpdate: (details) { setState(() { final newWidth = details.globalPosition.dx; if (_dragStartWidth > _collapsedContactsWidth) { if (newWidth < (_minExpandedContactsWidth / 2)) { _contactsPaneWidth = _collapsedContactsWidth; } else { _contactsPaneWidth = newWidth.clamp( _minExpandedContactsWidth, _maxExpandedContactsWidth, ); } } else { if (newWidth > (_minExpandedContactsWidth / 2) + 40) { _contactsPaneWidth = newWidth.clamp( _minExpandedContactsWidth, _maxExpandedContactsWidth, ); } } }); }, ), // Чат расширяется на всё оставшееся до самого края или до профиля пространство Expanded(child: _buildChatPane()), if (showProfile) ...[ _buildResizableDivider( onPanUpdate: (details) { setState(() { final screenWidth = MediaQuery.of(context).size.width; _profilePaneWidth = (screenWidth - details.globalPosition.dx).clamp( 280, 500, ); }); }, ), // ИСПРАВЛЕНО: Убран Flexible. Правая панель занимает строго _profilePaneWidth SizedBox( width: _profilePaneWidth, child: _buildProfilePane(), ), ], ], ); }, ), ); break; } return Scaffold( backgroundColor: Theme.of(context).colorScheme.background, body: SizedBox.expand( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [_buildWindowsNavigationRail(), centerPane], ), ), ); } @override Widget build(BuildContext context) { final bool isPhoneFormFactor = MediaQuery.of(context).size.shortestSide < 600; return PopScope( canPop: _selectedContact == null || !isPhoneFormFactor, onPopInvokedWithResult: (didPop, result) { if (didPop) return; if (_selectedContact != null && isPhoneFormFactor) { _clearSelectedContact(); } }, child: _buildResponsiveBody(isPhoneFormFactor), ); } Future _checkAppUpdate() async { print('Проверка обновлений...'); PackageInfo packageInfo = await PackageInfo.fromPlatform(); // Определяем платформу для сервера String platformQuery = Platform.isWindows ? 'windows' : 'android'; try { final response = await http.get( Uri.parse( '${AppConstants.baseUrl}/check-update?platform=$platformQuery', ), ); if (response.statusCode == 200) { final data = jsonDecode(response.body); final String latestVersion = data['latest_version']; if (latestVersion != packageInfo.version) { setState(() { _showUpdateBanner = true; _latestApkUrl = data['download_url'] ?? data['apk_url']; }); if (_latestApkUrl != null) { final size = await _fetchApkSize(_latestApkUrl!); if (mounted) { setState(() => _apkFileSizeBytes = size); } } } } } catch (e) { print("Ошибка проверки обновлений: $e"); } } Future _setupPushNotifications() async { try { if (Firebase.apps.isEmpty) { print('Firebase is not initialized, skipping push notification setup.'); return; } await FirebaseMessaging.instance.requestPermission(); String? token = await FirebaseMessaging.instance.getToken(); if (token != null) { ApiService apiService = ApiService(); await apiService.updateFcmToken(token); } FirebaseMessaging.instance.onTokenRefresh.listen((newToken) { ApiService apiService = ApiService(); apiService.updateFcmToken(newToken); }); FirebaseMessaging.onMessage.listen(_handleIncomingMessage); FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { if (message.data['type'] == 'enc_message') { final senderId = int.tryParse( message.data['sender_id']?.toString() ?? '', ); if (senderId != null) _navigateToChatFromNotification(senderId); } }); } catch (e) { print('Push notification setup failed: $e'); } } void _navigateToChatFromNotification(int senderId) { final contactProvider = context.read(); if (contactProvider.contacts.isEmpty) { Future.delayed(const Duration(milliseconds: 500), () { if (mounted) _navigateToChatFromNotification(senderId); }); return; } try { final contact = contactProvider.contacts.firstWhere( (c) => c.id == senderId, ); _selectContact(contact); } catch (_) {} } Future _handleIncomingMessage(dynamic data) async { if (data is RemoteMessage) { await _handleFCMMessage(data); } else if (data is Map) { print('WebSocket message received in ContactsScreen: $data'); final contactProvider = context.read(); if (data['type'] == 'private_message') { final senderId = int.tryParse(data['sender_id']?.toString() ?? ''); // Обрабатываем сообщение ТОЛЬКО если оно от другого чата if (senderId != null && senderId != currentActiveChatContactId) { var contact = contactProvider.contacts .where((c) => c.id == senderId) .firstOrNull; if (contact == null) { await contactProvider.updateContact(senderId); contact = contactProvider.contacts .where((c) => c.id == senderId) .firstOrNull; } if (contact != null) { final currentUnread = contact.unreadCount; final msgId = int.tryParse(data['id']?.toString() ?? ''); final newAnchor = (currentUnread == 0) ? msgId : contact.firstUnreadMessageId; String decryptedText = 'Новое сообщение'; bool isDecrypted = false; try { final myPrivKey = await CryptoService().getPrivateKey(); if (myPrivKey != null && contact.publicKey != null) { final sharedSecret = await CryptoService().deriveSharedSecret( myPrivKey, contact.publicKey!, contactId: senderId, ); decryptedText = await CryptoService().decryptMessage( data['content'], sharedSecret, ); isDecrypted = true; } } catch (_) {} DateTime now = DateTime.now(); Duration offset = now.timeZoneOffset; final timestamp = data['timestamp'] ?? DateTime.now().toIso8601String(); // Список недопустимых системных строк final invalidValues = {'unknown', 'uncnown', 'null', ''}; // Функция очистки полей контакта String cleanField(String? value) { if (value == null) return ''; final trimmed = value.trim(); return invalidValues.contains(trimmed.toLowerCase()) ? '' : trimmed; } String cleanName = cleanField(contact.name); String cleanSurname = cleanField(contact.surname); String cleanUsername = cleanField(contact.username); // Формируем полное имя final String localFullName = '$cleanName $cleanSurname'.trim(); // Задаем итоговый заголовок. Если всё пусто или unknown — пишем «Без имени» final String title = localFullName.isNotEmpty ? localFullName : (cleanUsername.isNotEmpty ? cleanUsername : 'Без имени'); final msgHashCode = msgId ?? (DateTime.now().microsecondsSinceEpoch % 1000000000); print('show notification from $senderId $title: $decryptedText'); await _showLocalNotification( senderId: senderId, title: title, // Теперь здесь гарантированно не будет unknown body: decryptedText, timestamp: timestamp, messageHashCode: msgHashCode, ); await contactProvider.updateContact( senderId, lastMessage: decryptedText, lastMessageTime: DateTime.tryParse( data['timestamp'] ?? '', )?.add(offset), isLastMsgDecrypted: isDecrypted, unreadCount: data['unread_count'] ?? currentUnread + 1, // Увеличиваем счетчик firstUnreadMessageId: newAnchor, // Сохраняем анкер для будущего прыжка! ); } } } if (data['type'] == 'message_read') { // message_id — сообщение, которое стало прочитанным // sender_id — ID того, чье сообщение прочитали (наш собеседник) // receiver_id — ID того, кто прочитал (наш текущий пользователь) final senderId = int.tryParse(data['sender_id']?.toString() ?? ''); final receiverId = int.tryParse(data['receiver_id']?.toString() ?? ''); final myId = context.read().currentUserId; // Проверяем: если уведомление пришло нам (мы — receiver_id) if (receiverId == myId && senderId != null) { final contactProvider = context.read(); // Ищем контакт в списке final contact = contactProvider.contacts.firstWhere( (c) => c.id == senderId, orElse: () => Contact(id: senderId, username: '', name: '', surname: ''), ); // Если этот контакт имел непрочитанные сообщения, уменьшаем счетчик if (contact.unreadCount > 0) { await contactProvider.updateContact( senderId, unreadCount: data['unread_count'] ?? contact.unreadCount - 1, ); print( 'DEBUG: Счетчик для чата с пользователем $senderId уменьшен на 1', ); } } return; } if (data['type'] == 'all_chat_read') { // message_id — сообщение, которое стало прочитанным // sender_id — ID того, чье сообщение прочитали (наш собеседник) // receiver_id — ID того, кто прочитал (наш текущий пользователь) final senderId = int.tryParse(data['sender_id']?.toString() ?? ''); final receiverId = int.tryParse(data['reader_id']?.toString() ?? ''); final myId = context.read().currentUserId; // Проверяем: если уведомление пришло нам (мы — receiver_id) if (receiverId == myId && senderId != null) { final contactProvider = context.read(); // Ищем контакт в списке final contact = contactProvider.contacts.firstWhere( (c) => c.id == senderId, orElse: () => Contact(id: senderId, username: '', name: '', surname: ''), ); // Если этот контакт имел непрочитанные сообщения, уменьшаем счетчик if (contact.unreadCount > 0) { await contactProvider.updateContact(senderId, unreadCount: 0); print( 'DEBUG: Счетчик для чата с пользователем $senderId установлен в 0', ); } } return; } if (data['type'] == 'message_sent') { final receiverId = int.tryParse(data['receiver_id']?.toString() ?? ''); if (receiverId != null) { var contact = contactProvider.contacts .where((c) => c.id == receiverId) .firstOrNull; if (contact == null) { await contactProvider.updateContact(receiverId); contact = contactProvider.contacts .where((c) => c.id == receiverId) .firstOrNull; } if (contact != null) { final messageType = MessageModel.parseMessageType( data['message_type']?.toString() ?? 'text', ); String lastMessage = MessageModel.getMediaPreview(messageType); bool isDecrypted = false; if (data['content'] != null && data['content'].toString().isNotEmpty) { try { final myPrivKey = await CryptoService().getPrivateKey(); if (myPrivKey != null && contact.publicKey != null) { final sharedSecret = await CryptoService().deriveSharedSecret( myPrivKey, contact.publicKey!, contactId: receiverId, ); final _lastMessage = await CryptoService().decryptMessage( data['content'], sharedSecret, ); if (_lastMessage.trim().isNotEmpty) { lastMessage = _lastMessage; } isDecrypted = true; } } catch (_) { lastMessage = data['content']?.toString() ?? lastMessage; } } DateTime now = DateTime.now(); Duration offset = now.timeZoneOffset; final timestamp = DateTime.tryParse( data['timestamp']?.toString() ?? '', )?.add(offset); final serverId = int.tryParse(data['server_id']?.toString() ?? ''); await contactProvider.updateContactLastMessage( receiverId, lastMessage: lastMessage.isNotEmpty ? lastMessage : null, lastMessageTime: timestamp, isLastMsgDecrypted: isDecrypted, lastMessageId: serverId, ); if (_selectedContact != null && _selectedContact!.id == receiverId) { _selectedContact = _selectedContact!.copyWith( lastMessage: lastMessage.isNotEmpty ? lastMessage : null, lastMessageTime: timestamp ?? _selectedContact!.lastMessageTime, isLastMsgDecrypted: isDecrypted || _selectedContact!.isLastMsgDecrypted, lastMessageId: serverId ?? _selectedContact!.lastMessageId, ); } } } } if (data['type'] == 'user_updated') { final userId = int.tryParse(data['user_id']?.toString() ?? ''); if (userId != null) { await contactProvider.updateContact(userId); await _loadLocalNames(); // Синхронно обновляем кэш имен на сокет } } if (data['type'] == 'user_online') { final userId = int.tryParse(data['user_id']?.toString() ?? ''); if (userId != null) { contactProvider.updateContactOnlineStatus(userId, true); if (mounted) { setState(() { if (_selectedContact != null && _selectedContact!.id == userId) { _selectedContact = contactProvider.contacts.firstWhere( (c) => c.id == userId, orElse: () => _selectedContact!, ); } }); } } } if (data['type'] == 'user_offline') { final userId = int.tryParse(data['user_id']?.toString() ?? ''); if (userId != null) { contactProvider.updateContactOnlineStatus(userId, false); if (mounted) { setState(() { if (_selectedContact != null && _selectedContact!.id == userId) { _selectedContact = contactProvider.contacts.firstWhere( (c) => c.id == userId, orElse: () => _selectedContact!, ); } }); } } } if (data['type'] == 'message_edited') { final messageId = int.tryParse(data['message_id']?.toString() ?? ''); final senderId = int.tryParse(data['sender_id']?.toString() ?? ''); if (messageId != null && senderId != null) { var contact = contactProvider.contacts .where((c) => c.id == senderId) .firstOrNull; if (contact == null) { await contactProvider.updateContact(senderId); contact = contactProvider.contacts .where((c) => c.id == senderId) .firstOrNull; } if (contact != null) { final editedAt = DateTime.tryParse( data['edited_at']?.toString() ?? '', ); String lastMessageText = contact.lastMessage ?? ''; bool isDecrypted = false; final myPrivKey = await CryptoService().getPrivateKey(); if (myPrivKey != null && contact.publicKey != null) { try { final sharedSecret = await CryptoService().deriveSharedSecret( myPrivKey, contact.publicKey!, contactId: senderId, ); lastMessageText = await CryptoService().decryptMessage( data['content']?.toString() ?? '', sharedSecret, ); isDecrypted = true; } catch (_) {} } await contactProvider.updateContactLastMessage( contact.id, lastMessage: lastMessageText, lastMessageTime: editedAt, isLastMsgDecrypted: isDecrypted, lastMessageId: messageId, isEdited: true, ); } } } if (data['type'] == 'message_deleted') { final messageId = int.tryParse(data['message_id']?.toString() ?? ''); if (messageId != null) { final contactIndex = contactProvider.contacts.indexWhere( (c) => c.lastMessageId == messageId, ); if (contactIndex != -1) { await contactProvider.refreshContactLastMessage( contactProvider.contacts[contactIndex].id, ); } } } } } Future _handleFCMMessage(RemoteMessage message) async { try { // 1. Быстрый парсинг и валидация ID отправителя на самом старте final senderIdRaw = message.data['sender_id']?.toString() ?? ''; final senderId = int.tryParse(senderIdRaw); if (senderId == null) return; // 2. Блокировка дубликатов, если чат уже открыт if (currentActiveChatContactId == senderId) return; // 3. Безопасное получение SocketService (лучше через GetIt, пример с context ниже) // Если метод вызывается строго в Foreground, проверяем mounted if (!mounted) return; final socketService = Provider.of(context, listen: false); if (socketService.isConnected()) return; // 4. Криптография final crypto = CryptoService(); final myPrivKey = await crypto.getPrivateKey(); if (myPrivKey == null) return; final publicKey = message.data['public_key']; final encryptedContent = message.data['content']; if (publicKey == null || encryptedContent == null) return; final sharedSecret = await crypto.deriveSharedSecret( myPrivKey, publicKey, contactId: senderId, ); final decryptedText = await crypto.decryptMessage( encryptedContent, sharedSecret, ); // 5. Работа с локальным кэшем имен final prefs = await SharedPreferences.getInstance(); String? firstName = prefs.getString('firstname_$senderIdRaw'); String? lastName = prefs.getString('lastname_$senderIdRaw'); // Очистка некорректных значений имен final invalidNames = {'unknown', 'uncnown', 'null', ''}; if (firstName == null || invalidNames.contains(firstName.toLowerCase().trim())) { firstName = 'Без имени'; } if (lastName == null || invalidNames.contains(lastName.toLowerCase().trim())) { lastName = ''; } final String localFullName = '$firstName $lastName'.trim(); final String title = localFullName.isNotEmpty ? localFullName : (message.data['username'] ?? 'Unknown'); final timestamp = message.data['timestamp'] ?? DateTime.now().toIso8601String(); // 6. Показ локального уведомления (ID берем от senderId, чтобы не плодить путаницу) await _showLocalNotification( senderId: senderId, title: title, body: decryptedText, timestamp: timestamp, messageHashCode: senderId.toUnsigned(31), ); // 7. Безопасное обновление провайдера контактов (только если экран активен) if (message.data['type'] == 'enc_message' && mounted) { final unreadCountRaw = message.data['unread_count']; context.read().updateContact( senderId, lastMessage: decryptedText, lastMessageTime: DateTime.tryParse(timestamp), isLastMsgDecrypted: true, unreadCount: unreadCountRaw != null ? int.tryParse(unreadCountRaw.toString()) : null, ); } } catch (e, stackTrace) { print('Error processing foreground FCM: $e'); print(stackTrace); // Поможет быстрее найти баг при изменении API } } Future _showLocalNotification({ required int senderId, required String title, required String body, required String timestamp, required int messageHashCode, }) async { const String channelId = 'Messages'; const String channelName = 'Новые сообщения'; final String groupKey = 'ru.chepuhagram.app.$senderId'; // Создаем канал (безопасно вызывать многократно) const AndroidNotificationChannel channel = AndroidNotificationChannel( channelId, channelName, description: 'Chat messages notifications', importance: Importance.high, ); await flutterLocalNotificationsPlugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >() ?.createNotificationChannel(channel); // Показываем Summary (группировщик) if (Platform.isAndroid) { await flutterLocalNotificationsPlugin.show( id: senderId, // ID группы - это ID отправителя title: '', body: '', notificationDetails: NotificationDetails( android: AndroidNotificationDetails( channelId, channelName, groupKey: groupKey, setAsGroupSummary: true, importance: Importance.high, priority: Priority.high, groupAlertBehavior: GroupAlertBehavior.all, ), ), ); } // Показываем само сообщение await flutterLocalNotificationsPlugin.show( id: messageHashCode, // Уникальный ID для каждого сообщения title: title, body: body, notificationDetails: NotificationDetails( android: AndroidNotificationDetails( channelId, channelName, groupKey: groupKey, importance: Importance.high, priority: Priority.high, showWhen: true, ), ), payload: jsonEncode({ 'type': 'enc_message', 'sender_id': senderId, 'timestamp': timestamp, }), ); } Future _startDownload() async { if (_latestApkUrl == null) return; if (Platform.isWindows) { setState(() => _isDownloading = true); final tempDir = await getTemporaryDirectory(); final savePath = '${tempDir.path}\\chepuhagram_setup.exe'; final file = File(savePath); if (await file.exists()) await file.delete(); try { setState(() { _downloadProgress = 0.0; _downloadedBytes = 0; _downloadTotalBytes = 0; }); await Dio().download( _latestApkUrl!, savePath, cancelToken: _cancelToken, onReceiveProgress: (rec, total) { if (mounted) { setState(() { _downloadProgress = total > 0 ? rec / total : 0.0; _downloadedBytes = rec; _downloadTotalBytes = total; }); } }, ); await Process.start(savePath, ['/update', '/VERYSILENT']); exit(0); } catch (e) { print("Ошибка при обновлении: $e"); setState(() => _isDownloading = false); } return; } // Логика для Android (Остается без изменений) setState(() => _isDownloading = true); Directory? dir = await getExternalStorageDirectory(); final path = '${dir!.path}/update.apk'; final file = File(path); if (await file.exists()) await file.delete(); try { setState(() { _downloadProgress = 0.0; _downloadedBytes = 0; _downloadTotalBytes = 0; }); await Dio().download( _latestApkUrl!, path, cancelToken: _cancelToken, onReceiveProgress: (rec, total) { if (mounted) { setState(() { _downloadedBytes = rec; _downloadTotalBytes = total > 0 ? total : 0; _downloadProgress = total > 0 ? rec / total : 0.0; }); } }, ); await OpenFilex.open(path); } catch (_) { } finally { if (mounted) { setState(() { _isDownloading = false; _downloadProgress = 0.0; }); } } } Future _fetchApkSize(String url) async { try { final response = await http.head(Uri.parse(url)); return int.tryParse(response.headers['content-length'] ?? '') ?? 0; } catch (_) { return 0; } } String _formatBytes(int bytes) { if (bytes <= 0) return '0 B'; const kb = 1024; const mb = kb * 1024; if (bytes < kb) return '$bytes B'; if (bytes < mb) return '${(bytes / kb).toStringAsFixed(1)} KB'; return '${(bytes / mb).toStringAsFixed(1)} MB'; } } class ContactAvatar extends StatelessWidget { final String initials; final String? avatarUrl; final bool isOnline; final bool isSelected; final ColorScheme colorScheme; const ContactAvatar({ super.key, required this.initials, required this.avatarUrl, required this.isOnline, required this.isSelected, required this.colorScheme, }); @override Widget build(BuildContext context) { return Stack( clipBehavior: Clip.none, children: [ Container( width: 52, height: 52, decoration: BoxDecoration( shape: BoxShape.circle, color: colorScheme.primaryContainer, ), child: ClipOval( child: Stack( alignment: Alignment.center, children: [ Text( initials, style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: colorScheme.onPrimaryContainer, ), ), if (avatarUrl != null) CachedNetworkImage( imageUrl: avatarUrl!, fit: BoxFit.cover, width: 52, height: 52, placeholder: (context, url) => const SizedBox.shrink(), errorWidget: (context, url, error) => const SizedBox.shrink(), ), ], ), ), ), Positioned( right: -1, bottom: -1, child: AnimatedSwitcher( duration: const Duration(milliseconds: 250), transitionBuilder: (Widget child, Animation animation) { return ScaleTransition( scale: CurvedAnimation( parent: animation, curve: Curves.easeInOut, ), child: FadeTransition(opacity: animation, child: child), ); }, child: isOnline ? Container( key: const ValueKey('online_dot'), width: 14, height: 14, decoration: BoxDecoration( color: Colors.green.shade500, shape: BoxShape.circle, border: Border.all( color: isSelected ? Color.alphaBlend( colorScheme.primaryContainer.withOpacity(0.4), colorScheme.background, ) : colorScheme.background, width: 2.5, ), ), ) : const SizedBox.shrink(key: ValueKey('offline_dot')), ), ), ], ); } }