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 '../widgets/contact_tile.dart'; import '../screens/settings_screen.dart'; import '../screens/new_chat_screen.dart'; import '../screens/chat_screen.dart'; import '/logic/contact_provider.dart'; import 'package:cached_network_image/cached_network_image.dart'; import '/logic/auth_provider.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'; 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; @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 _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(); print('Contacts loaded, checking targetChatId: ${widget.targetChatId}'); WidgetsBinding.instance.addPostFrameCallback((_) { _checkAppUpdate(); }); // Дальнейшая логика выполнится только после того, как loadContacts завершится 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() { print("Пользователь вернулся на этот экран!"); _refreshData(); } @override void dispose() { routeObserver.unsubscribe(this); _socketSubscription?.cancel(); super.dispose(); } void _refreshData() { print("Обновляем данные контактов и сообщений..."); final contactProvider = context.read(); contactProvider.loadContacts(); } 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(); // Поддерживаем старый payload (только sender_id) и новый (type+sender_id) 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) async { 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}'); currentActiveChatContactId = targetChatId; // Устанавливаем активный чат final result = await Navigator.push( context, MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)), ); if (result != null) { _refreshData(); // Обновляем данные при возвращении с чата, если нужно } } catch (e) { print('Target contact with id $targetChatId not found: $e'); } } Future _checkAppUpdate() async { print('Проверка обновлений'); PackageInfo packageInfo = await PackageInfo.fromPlatform(); try { // 1. Запрос к вашему FastAPI final response = await http.get( Uri.parse('${AppConstants.baseUrl}/check-update'), ); if (response.statusCode == 200) { final data = jsonDecode(response.body); final String latestVersion = data['latest_version']; print('444444'); print(latestVersion); print(packageInfo.version); // Сравнение версий (предположим, у вас есть способ получить текущую версию) if (latestVersion != packageInfo.version) { setState(() { _showUpdateBanner = true; _latestApkUrl = data['apk_url']; }); if (_latestApkUrl != null) { final size = await _fetchApkSize(_latestApkUrl!); if (mounted) { setState(() { _apkFileSizeBytes = size; }); } } } } } catch (e) { print("Ошибка проверки обновлений: $e"); } } Future _setupPushNotifications() async { // Request permissions await FirebaseMessaging.instance.requestPermission(); String? token = await FirebaseMessaging.instance.getToken(); if (token != null) { ApiService apiService = ApiService(); print(token); await apiService.updateFcmToken(token); } // Listen for token refresh FirebaseMessaging.instance.onTokenRefresh.listen((newToken) { ApiService apiService = ApiService(); apiService.updateFcmToken(newToken); print('FCM Token refreshed: $newToken'); }); // Listen for foreground messages FirebaseMessaging.onMessage.listen(_handleIncomingMessage); // Handle notification tap when app was terminated/backgrounded FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { print('Notification tapped, app opened: ${message.data}'); if (message.data['type'] == 'enc_message') { final senderId = int.tryParse( message.data['sender_id']?.toString() ?? '', ); if (senderId != null) { _navigateToChatFromNotification(senderId); } else { print( 'Notification tap contains invalid sender_id: ${message.data['sender_id']}', ); } } }); } void _navigateToChatFromNotification(int senderId) async { final contactProvider = context.read(); print('Navigate to chat from notification with senderId: $senderId'); // Если контакты еще не загружены, ждем их загрузки if (contactProvider.contacts.isEmpty) { print('Contacts not loaded yet, waiting...'); // Ждем немного и пробуем снова Future.delayed(const Duration(milliseconds: 500), () { if (mounted) { _navigateToChatFromNotification(senderId); } }); return; } try { final contact = contactProvider.contacts.firstWhere( (c) => c.id == senderId, ); print('Navigating to chat from notification: ${contact.username}'); currentActiveChatContactId = senderId; // Устанавливаем активный чат final result = await Navigator.push( context, MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)), ); if (result != null) { _refreshData(); // Обновляем данные при возвращении с чата, если нужно } } catch (e) { // Contact not found, stay on contacts screen print('Contact not found for notification: $senderId'); } } Future _handleIncomingMessage(dynamic data) async { if (data is RemoteMessage) { // FCM message await _handleFCMMessage(data); } else if (data is Map) { // WebSocket message print('WebSocket message received: $data'); if (data['type'] == 'user_updated') { final userId = int.tryParse(data['user_id']?.toString() ?? ''); if (userId != null) { final contactProvider = context.read(); contactProvider.updateContact(userId); } } if (data['type'] == 'user_online') { final userId = int.tryParse(data['user_id']?.toString() ?? ''); if (userId != null) { final contactProvider = context.read(); contactProvider.updateContactOnlineStatus(userId, true); } } if (data['type'] == 'user_offline') { final userId = int.tryParse(data['user_id']?.toString() ?? ''); if (userId != null) { final contactProvider = context.read(); contactProvider.updateContactOnlineStatus(userId, false); } } 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) { final contactProvider = context.read(); final 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!, ); lastMessageText = await CryptoService().decryptMessage( data['content']?.toString() ?? '', sharedSecret, ); isDecrypted = true; } catch (e) { print('Error decrypting edited message for contacts list: $e'); } } // Единая точка обновления состояния 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 contactProvider = context.read(); final contactIndex = contactProvider.contacts.indexWhere( (c) => c.lastMessageId == messageId, ); if (contactIndex != -1) { final contactId = contactProvider.contacts[contactIndex].id; await contactProvider.refreshContactLastMessage(contactId); } } } } } Future _handleFCMMessage(RemoteMessage message) async { try { // Проверяем, не находимся ли мы уже в чате с отправителем final senderId = int.tryParse( message.data['sender_id']?.toString() ?? '', ); if (senderId != null && currentActiveChatContactId == senderId) { print('Already in chat with sender $senderId, skipping notification'); return; } // Ensure notification channel exists const AndroidNotificationChannel channel = AndroidNotificationChannel( 'Messages', 'Новые сообщения', description: 'Chat messages notifications', importance: Importance.high, ); await flutterLocalNotificationsPlugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >() ?.createNotificationChannel(channel); final crypto = CryptoService(); final myPrivKey = await crypto.getPrivateKey(); if (myPrivKey == null) { print('Private key not found, cannot decrypt message'); return; } final sharedSecret = await crypto.deriveSharedSecret( myPrivKey, message.data['public_key'], ); final decryptedText = await crypto.decryptMessage( message.data['content'], sharedSecret, ); if (senderId == null) return; final String groupKey = 'ru.chepuhagram.app.$senderId'; final prefs = await SharedPreferences.getInstance(); final String? firstName = prefs.getString( 'firstname_${message.data['sender_id']}', ); final String? lastName = prefs.getString( 'lastname_${message.data['sender_id']}', ); final String localFullName = '${firstName ?? ''} ${lastName ?? ''}' .trim(); final String title = localFullName.isNotEmpty ? localFullName : (message.data['username'] ?? 'Unknown'); // Show local notification await flutterLocalNotificationsPlugin.show( senderId, '', '', NotificationDetails( android: AndroidNotificationDetails( 'Messages', 'Новые сообщения', groupKey: groupKey, setAsGroupSummary: true, importance: Importance.high, priority: Priority.high, groupAlertBehavior: GroupAlertBehavior.all, ), ), ); await flutterLocalNotificationsPlugin.show( message.hashCode, title, decryptedText, NotificationDetails( android: AndroidNotificationDetails( 'Messages', 'Новые сообщения', groupKey: groupKey, importance: Importance.high, priority: Priority.high, showWhen: true, ), ), payload: jsonEncode({ 'type': 'enc_message', 'sender_id': message.data['sender_id'], 'timestamp': message.data['timestamp'] ?? DateTime.now().toIso8601String(), }), ); if (message.data['type'] == 'enc_message') { print('Received private message FCM, updating contact $senderId'); final contactProvider = context.read(); contactProvider.updateContact( senderId, lastMessage: decryptedText, lastMessageTime: DateTime.tryParse( message.data['timestamp'] ?? DateTime.now().toIso8601String(), ), isLastMsgDecrypted: true, unreadCount: message.data['unread_count'] != null ? int.tryParse(message.data['unread_count'].toString()) ?? 1 : null, ); } } catch (e) { print('Error processing foreground FCM message: $e'); } } @override Widget build(BuildContext context) { double bannerHeight = 0.0; if (_showUpdateBanner) { bannerHeight = _isDownloading ? 152.0 : 96.0; } final double fabBottomPadding = _showUpdateBanner ? (bannerHeight + 20.0) : 16.0; return Scaffold( appBar: AppBar( title: Text( "Chepuhagram", style: TextStyle(fontWeight: FontWeight.bold), ), centerTitle: false, elevation: 0, actions: [IconButton(icon: const Icon(Icons.search), onPressed: () {})], ), body: Stack( children: [ Consumer( builder: (context, contactProvider, child) { 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, ), ); } return ListView.separated( itemCount: contactProvider.contacts.length, separatorBuilder: (context, index) => Divider( height: 1, indent: 80, color: Theme.of(context).colorScheme.primaryContainer, ), itemBuilder: (context, index) { final contact = contactProvider.contacts[index]; return ContactTile( contact: contact, onTap: () async { final result = await Navigator.push( context, MaterialPageRoute( builder: (_) => ChatScreen(contact: contact), ), ); if (result != null) { _refreshData(); // Обновляем данные при возвращении с чата, если нужно } }, ); }, ); }, ), if (_showUpdateBanner) Positioned( left: 0, right: 0, bottom: 40, child: _buildUpdateBanner(), ), ], ), floatingActionButton: AnimatedPadding( duration: const Duration(milliseconds: 100), curve: Curves.easeInOut, padding: EdgeInsets.only(bottom: fabBottomPadding), child: FloatingActionButton( onPressed: () { Navigator.push( context, MaterialPageRoute(builder: (_) => const NewChatScreen()), ); }, child: const Icon(Icons.edit), ), ), drawer: Drawer( child: ListView( padding: EdgeInsets.zero, children: [ // Шапка меню с данными юзера Consumer( builder: (context, authProvider, _) { final username = authProvider.username; final displayName = authProvider.displayName; final initials = (displayName.isNotEmpty ? displayName : (username ?? 'U')) .trim() .split(RegExp(r'\s+')) .where((p) => p.isNotEmpty) .take(2) .map((p) => p[0].toUpperCase()) .join(); return UserAccountsDrawerHeader( accountName: Text( displayName, style: TextStyle( color: Theme.of(context).colorScheme.onSurface, ), ), accountEmail: Text( username == null || username.isEmpty ? '' : '@$username', style: TextStyle( color: Theme.of(context).colorScheme.onSurface, ), ), currentAccountPicture: CircleAvatar( backgroundColor: authProvider.avatarUrl == null && authProvider.avatarPath == null ? Theme.of(context).colorScheme.onSurface : null, backgroundImage: authProvider.avatarUrl != null ? CachedNetworkImageProvider(authProvider.avatarUrl!) : authProvider.avatarPath != null ? FileImage(File(authProvider.avatarPath!)) : null, child: (authProvider.avatarUrl == null && authProvider.avatarPath == null) ? Text( initials.isEmpty ? 'U' : initials, style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: Theme.of( context, ).colorScheme.primaryContainer, ), ) : null, ), decoration: BoxDecoration( color: Theme.of(context).colorScheme.inversePrimary, ), ); }, ), ListTile( leading: const Icon(Icons.settings), title: const Text("Настройки"), onTap: () { // Закрываем Drawer и переходим на экран настроек Navigator.pop(context); Navigator.push( context, MaterialPageRoute(builder: (_) => SettingsScreen()), ); }, ), ListTile( leading: const Icon(Icons.info_outline), title: const Text("О приложении"), onTap: () { /* ... */ }, ), ], ), ), ); } Future _startDownload() async { if (_latestApkUrl == null) return; // Показываем индикатор setState(() => _isDownloading = true); final 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; }); } }, ); // После успешного скачивания — установка final result = await OpenFilex.open(path); if (result.type != ResultType.done) { print("Ошибка при установке: ${result.message}"); } } on DioException catch (e) { if (e.type != DioExceptionType.cancel) { print("Ошибка скачивания: $e"); } } catch (e) { print("Ошибка: $e"); } finally { if (mounted) { setState(() { _isDownloading = false; _downloadProgress = 0.0; _downloadedBytes = 0; _downloadTotalBytes = 0; }); } } } Future _fetchApkSize(String url) async { try { final response = await http.head(Uri.parse(url)); final lengthHeader = response.headers['content-length']; if (lengthHeader == null) return 0; return int.tryParse(lengthHeader) ?? 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'; } Widget _buildUpdateBanner() { return Container( margin: const EdgeInsets.fromLTRB( 12, 0, 12, 16, ), // Отступы от краев и снизу 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: [ const Icon( Icons.system_update_alt, color: Colors.white, size: 28, ), const SizedBox(width: 12), Expanded( child: Text( _isDownloading ? 'Скачивание ${(_downloadProgress * 100).toStringAsFixed(0)}%' : _apkFileSizeBytes > 0 ? 'Доступно новое обновление: ${_formatBytes(_apkFileSizeBytes)}' : 'Доступно новое обновление!', style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16, ), ), ), TextButton( onPressed: () async { if (_isDownloading) { // Если уже качаем — отменяем _cancelToken?.cancel("Пользователь отменил загрузку"); setState(() { _isDownloading = false; _cancelToken = null; // Обязательно обнуляем токен! _downloadProgress = 0.0; _downloadedBytes = 0; _downloadTotalBytes = 0; }); } else { // Если не качаем — запускаем setState(() { _isDownloading = true; _cancelToken = CancelToken(); // Создаем новый токен перед началом }); // ВАЖНО: вызываем саму функцию скачивания await _startDownload(); } }, style: TextButton.styleFrom( backgroundColor: Colors.white24, 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.white24, ), const SizedBox(height: 8), Align( alignment: Alignment.centerLeft, child: Text( '${_formatBytes(_downloadedBytes)} из ${_formatBytes(_downloadTotalBytes)}', style: const TextStyle(color: Colors.white70, fontSize: 14), ), ), ], ], ), ), ), ); } }