import 'package:chepuhagram/data/datasources/ws_client.dart'; import 'package:chepuhagram/logic/auth_provider.dart'; import 'package:chepuhagram/domain/services/api_service.dart'; import 'package:chepuhagram/logic/contact_provider.dart'; import 'package:chepuhagram/core/theme_manager.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'dart:io'; import 'package:firebase_core/firebase_core.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:path_provider/path_provider.dart'; import 'package:chepuhagram/domain/services/crypto_service.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'dart:convert'; import 'package:chepuhagram/data/datasources/local_db_service.dart'; import 'package:chepuhagram/presentation/screens/chat_screen.dart'; import 'package:chepuhagram/presentation/screens/contacts_screen.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:path/path.dart' as p; import 'package:chepuhagram/presentation/screens/splash_screen.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:chepuhagram/presentation/screens/call_screen.dart'; import 'package:flutter_callkit_incoming/entities/entities.dart'; import 'package:timeago/timeago.dart' as timeago; import 'package:timeago/src/messages/ru_messages.dart'; final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); final GlobalKey navigatorKey = GlobalKey(); final RouteObserver routeObserver = RouteObserver(); int? currentActiveChatContactId; RemoteMessage? initialMessage; // Ключ для SharedPreferences const String _notificationLaunchKey = 'notification_launch_data'; const String _lastHandledNotificationLaunchPayloadKey = 'notification_last_handled_payload'; Future _onSelectNotification( NotificationResponse notificationResponse, ) async { final payload = notificationResponse.payload; if (payload != null) { try { final data = jsonDecode(payload); final senderId = int.tryParse(data['sender_id']?.toString() ?? ''); if (senderId != null) { print('Notification selected, payload sender_id=$senderId'); final context = navigatorKey.currentContext; final prefs = await SharedPreferences.getInstance(); final canonicalPayload = jsonEncode(data); if (context == null) { final lastHandled = prefs.getString( _lastHandledNotificationLaunchPayloadKey, ); if (lastHandled != canonicalPayload) { await prefs.setString(_notificationLaunchKey, canonicalPayload); await prefs.setString( _lastHandledNotificationLaunchPayloadKey, canonicalPayload, ); } print( 'Navigator context is null, saved notification payload to SharedPreferences', ); } else { await prefs.remove(_notificationLaunchKey); } _navigateToChat(senderId); } else { print( 'Notification payload has invalid sender_id: ${data['sender_id']}', ); } } catch (e) { print('Error parsing notification payload: $e'); } } } void _navigateToChat(int senderId) { print('Navigating to chat with senderId: $senderId'); final context = navigatorKey.currentContext; if (context != null) { final contactProvider = Provider.of( context, listen: false, ); // ВАЖНОЕ ИЗМЕНЕНИЕ: // Независимо от того, загружены контакты или нет, на десктопе/планшете мы ВСЕГДА // сбрасываем навигацию до экрана ContactsScreen, передавая ему targetChatId. // Это заставит приложение открыть чат в его родном правом окошке, сохранив боковую панель. currentActiveChatContactId = senderId; // Устанавливаем активный чат // Очищаем весь стек экранов и открываем ContactsScreen со встроенным параметром чата Navigator.pushAndRemoveUntil( context, MaterialPageRoute(builder: (_) => ContactsScreen(targetChatId: senderId)), (route) => false, // Удаляет все предыдущие полноэкранные оверлеи ); } else { print('Navigator context is null'); } } bool firebaseInitialized = false; void main() async { // Initialize timeago for Russian locale timeago.setLocaleMessages('ru', timeago.RuMessages()); WidgetsFlutterBinding.ensureInitialized(); if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { sqfliteFfiInit(); // ИСПОЛЬЗУЕМ СУППОРТ-ДИРЕКТОРИЮ (App Data / Roaming) вместо Документов final appSupportDir = await getApplicationSupportDirectory(); // Создаем подпапку внутри AppData, там Windows никогда не выдаст ошибку 2 final sqfliteDbPath = p.join(appSupportDir.path, 'sqflite_databases'); await Directory(sqfliteDbPath).create(recursive: true); // Привязываем фабрику sqflite к новому безопасному пути databaseFactory = databaseFactoryFfi; await databaseFactory.setDatabasesPath(sqfliteDbPath); print('Безопасный путь для SQFlite на Windows: $sqfliteDbPath'); } try { print('Initializing LocalDbService in main...'); final db = LocalDbService(); print('База данных сохранена по пути: ${db.connection.connectionData}'); print('LocalDbService initialized successfully.'); } catch (e, st) { print('LocalDbService init failed in main: $e'); print(st); } if (Platform.isAndroid || Platform.isIOS) { await Firebase.initializeApp(); firebaseInitialized = true; initialMessage = await FirebaseMessaging.instance.getInitialMessage(); print('Initial message from main(): $initialMessage'); } else { print('Skipping Firebase initialization on desktop.'); } // Сохраняем информацию в SharedPreferences для надежности final prefs = await SharedPreferences.getInstance(); if (initialMessage != null) { print('App launched from notification: ${initialMessage!.data}'); print('Message type: ${initialMessage!.data['type']}'); print('Sender ID: ${initialMessage!.data['sender_id']}'); final payloadString = jsonEncode(initialMessage!.data); final lastHandled = prefs.getString( _lastHandledNotificationLaunchPayloadKey, ); if (lastHandled != payloadString) { // Сохраняем данные уведомления await prefs.setString(_notificationLaunchKey, payloadString); await prefs.setString( _lastHandledNotificationLaunchPayloadKey, payloadString, ); print('Saved notification data to SharedPreferences'); } else { print('InitialMessage payload already handled earlier, skipping'); } } else { print('No initial message - app launched normally'); // Очищаем сохраненные данные, если приложение запущено нормально await prefs.remove(_notificationLaunchKey); } const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('@mipmap/ic_launcher'); const WindowsInitializationSettings initializationSettingsWindows = WindowsInitializationSettings( appName: 'Chepuhagram', appUserModelId: 'ru.ArturKarasevich.Chepuhagram', guid: '6c0af055-e0b5-4f10-9aed-c12dc078f949', ); final InitializationSettings initializationSettings = InitializationSettings( android: initializationSettingsAndroid, windows: initializationSettingsWindows, ); await flutterLocalNotificationsPlugin.initialize( settings: initializationSettings, onDidReceiveNotificationResponse: _onSelectNotification, ); // Если приложение было запущено из локального уведомления, сохраним payload final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin .getNotificationAppLaunchDetails(); if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) { final payload = notificationAppLaunchDetails?.notificationResponse?.payload; print('App launched from local notification, payload: $payload'); if (payload != null && payload.isNotEmpty) { try { final lastHandled = prefs.getString( _lastHandledNotificationLaunchPayloadKey, ); if (lastHandled != payload) { final data = jsonDecode(payload); await prefs.setString(_notificationLaunchKey, jsonEncode(data)); await prefs.setString( _lastHandledNotificationLaunchPayloadKey, payload, ); print('Saved local notification launch payload to SharedPreferences'); } else { print('Local notification payload already handled earlier, skipping'); } } catch (e) { print('Failed to save notification launch payload: $e'); } } // Create notification channel for Android 8+ const AndroidNotificationChannel channel = AndroidNotificationChannel( 'chat_id', // id 'Messages', // title description: 'Chat messages notifications', // description importance: Importance.high, ); await flutterLocalNotificationsPlugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >() ?.createNotificationChannel(channel); if (firebaseInitialized) { FirebaseMessaging.onBackgroundMessage( _firebaseMessagingBackgroundHandler, ); } } if (Platform.isAndroid || Platform.isIOS) { initCallkitListener(); } else { print('Skipping CallKit listener on desktop platform.'); } runApp( MultiProvider( providers: [ Provider(create: (_) => CryptoService()), Provider(create: (_) => SocketService()), ChangeNotifierProvider(create: (_) => AuthProvider()), ChangeNotifierProvider(create: (_) => ThemeProvider()), Provider(create: (_) => SocketService()), ChangeNotifierProvider( create: (context) => ContactProvider(context.read()), ), ], child: const MyApp(), ), ); } Future showIncomingCallKit({ required String callId, required String callerName, required int callerId, }) async { final params = CallKitParams( id: callId, nameCaller: callerName, appName: 'Chepuhagram', avatar: '', handle: callerName, type: 0, // 0 - audio, 1 - video duration: 30000, textAccept: 'Принять', textDecline: 'Отклонить', missedCallNotification: const NotificationParams( showNotification: true, subtitle: 'Пропущенный звонок', ), extra: { 'callerId': callerId, }, android: const AndroidParams( isCustomNotification: true, isShowLogo: false, ringtonePath: 'system_ringtone_default', backgroundColor: '#0F0F12', actionColor: '#4CAF50', textColor: '#ffffff', incomingCallNotificationChannelName: 'Входящие звонки', ), ios: const IOSParams( handleType: 'generic', supportsVideo: false, maximumCallGroups: 1, maximumCallsPerCallGroup: 1, audioSessionMode: 'default', audioSessionActive: true, audioSessionPreferredSampleRate: 44100.0, audioSessionPreferredIOBufferDuration: 0.005, supportsDTMF: false, supportsHolding: false, supportsGrouping: false, supportsUngrouping: false, ringtonePath: 'system_ringtone_default', ), ); await FlutterCallkitIncoming.showCallkitIncoming(params); } void initCallkitListener() { if (!(Platform.isAndroid || Platform.isIOS)) { print('Skipping CallKit event listener on non-mobile platform.'); return; } try { FlutterCallkitIncoming.onEvent.listen((event) { if (event == null) return; final callerId = int.tryParse(event.body['extra']?['callerId']?.toString() ?? '') ?? int.tryParse(event.body['callerId']?.toString() ?? '') ?? 0; switch (event.event) { case Event.actionCallIncoming: // Звонок получен, но CallKit уже показал экран. print("Incoming call: ${event.body['id']}"); break; case Event.actionCallStart: // Исходящий звонок начат break; case Event.actionCallAccept: // ПОЛЬЗОВАТЕЛЬ НАЖАЛ "ПРИНЯТЬ" Future(() async { final context = navigatorKey.currentContext; var myId = 0; if (context != null) { try { myId = Provider.of(context, listen: false).currentUserId ?? 0; } catch (_) {} } if (myId == 0) { final storage = FlutterSecureStorage(); final userIdStr = await storage.read(key: 'user_id'); myId = int.tryParse(userIdStr ?? '') ?? 0; } final socket = SocketService(); if (!socket.isConnected()) { print("CallKit action: Socket is not connected, connecting..."); try { await socket.connect(ApiService()); } catch (e) { print("CallKit action error connecting socket: $e"); } } // 1. Уведомляем сервер (чтобы другая сторона узнала о начале звонка) socket.sendMessage({ "type": "call_accepted", "call_id": event.body['id'], "receiver_id": callerId, "sender_id": myId, }); // 2. Переходим на экран звонка final callId = event.body['id']?.toString() ?? ''; if (CallScreen.currentActiveCallId != null) { print("CallKit action: A call is already active (${CallScreen.currentActiveCallId}), skipping push for $callId"); } else { CallScreen.currentActiveCallId = callId; navigatorKey.currentState?.push( MaterialPageRoute( builder: (_) => CallScreen( callId: callId, isIncoming: true, callerName: event.body['nameCaller'] ?? 'Unknown', targetUserId: callerId, startAccepted: true, ), ), ); } }); break; case Event.actionCallDecline: // ПОЛЬЗОВАТЕЛЬ НАЖАЛ "ОТКЛОНИТЬ" Future(() async { final socket = SocketService(); if (!socket.isConnected()) { print("CallKit action: Socket is not connected for decline, connecting..."); try { await socket.connect(ApiService()); } catch (e) { print("CallKit action error connecting socket: $e"); } } socket.sendMessage({ "type": "decline", "call_id": event.body['id'], "receiver_id": callerId, }); }); break; case Event.actionCallEnded: case Event.actionCallTimeout: // Звонок завершен или пропущен print("Call ended or timeout"); break; default: print("Event unhandled: ${event.event}"); break; } }); } catch (e, st) { print('CallKit listener initialization failed: $e'); print(st); } } void _handleHangupGlobal(String callId) { SocketService().sendMessage({"type": "hangup", "call_id": callId}); } @pragma('vm:entry-point') Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { print("Фоновый пуш получен: ${message.data}"); if (message.data['type'] == 'enc_message') { try { // Initialize notifications for background const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('@mipmap/ic_launcher'); const WindowsInitializationSettings initializationSettingsWindows = WindowsInitializationSettings( appName: 'Chepuhagram', appUserModelId: 'ru.ArturKarasevich.Chepuhagram', guid: '6c0af055-e0b5-4f10-9aed-c12dc078f949', ); final InitializationSettings initializationSettings = InitializationSettings( android: initializationSettingsAndroid, windows: initializationSettingsWindows, ); await flutterLocalNotificationsPlugin.initialize( settings: initializationSettings, onDidReceiveNotificationResponse: _onSelectNotification, ); // Create notification channel const AndroidNotificationChannel channel = AndroidNotificationChannel( 'chat_id', 'Messages', description: 'Chat messages notifications', importance: Importance.high, ); await flutterLocalNotificationsPlugin .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin >() ?.createNotificationChannel(channel); // Try to decrypt String notificationText = 'New encrypted message'; try { // 1. Инициализируем крипто-сервис final crypto = CryptoService(); // 2. Достаем ключи (они должны быть в SecureStorage) final myPrivKey = await crypto.getPrivateKey(); print('Private key retrieved: ${myPrivKey != null}'); if (myPrivKey == null) { print('Private key not found, showing encrypted message'); notificationText = 'Encrypted message: ${message.data['content']?.substring(0, 50) ?? 'N/A'}...'; } else { // 3. Расшифровываем final sharedSecret = await crypto.deriveSharedSecret( myPrivKey, message.data['public_key'], ); final decryptedText = await crypto.decryptMessage( message.data['content'], sharedSecret, ); notificationText = decryptedText; } } catch (e) { print('Decryption failed: $e'); notificationText = 'Failed to decrypt: ${e.toString()}'; } final String senderIdRaw = message.data['sender_id']?.toString() ?? ''; final senderId = int.tryParse(senderIdRaw); // ========================================================== // ОБРАБОТКА ИМЕНИ: КЭШ УСТРОЙСТВА + ДАННЫЕ ОТ СЕРВЕРА // ========================================================== final prefs = await SharedPreferences.getInstance(); // 1. Пытаемся прочитать из локального кэша устройства final String? cachedFirstName = prefs.getString('firstname_$senderIdRaw'); final String? cachedLastName = prefs.getString('lastname_$senderIdRaw'); // Список «мусорных» системных строк для фильтрации final invalidValues = {'unknown', 'uncnown', 'null', ''}; // Функция очистки строк от пробелов и мусора String cleanField(String? value) { if (value == null) return ''; final trimmed = value.trim(); return invalidValues.contains(trimmed.toLowerCase()) ? '' : trimmed; } // Очищаем данные из кэша String firstName = cleanField(cachedFirstName); String lastName = cleanField(cachedLastName); // 2. Если в кэше пусто, берем данные, которые сервер прислал в push-уведомлении if (firstName.isEmpty) { firstName = cleanField(message.data['firstname']?.toString()); } if (lastName.isEmpty) { lastName = cleanField(message.data['lastname']?.toString()); } // Собираем полное имя String title = '$firstName $lastName'.trim(); // 3. Если имени нет ни в кэше, ни в полях сервера, берем username if (title.isEmpty) { title = cleanField(message.data['username']?.toString()); } // 4. Если вообще всё было "unknown" или пусто — ставим заглушку if (title.isEmpty) { title = 'Без имени'; } // ========================================================== // 4. Показываем локальное уведомление final String groupKey = 'ru.chepuhagram.app.$senderId'; await flutterLocalNotificationsPlugin.show( id: senderId ?? 0, title: '', body: '', notificationDetails: NotificationDetails( android: AndroidNotificationDetails( 'Messages', 'Новые сообщения', groupKey: groupKey, setAsGroupSummary: true, importance: Importance.high, priority: Priority.high, groupAlertBehavior: GroupAlertBehavior.all, ), ), ); await flutterLocalNotificationsPlugin.show( id: message.hashCode, title: title, // Отвалидированное имя с сервера или из кэша body: notificationText, notificationDetails: NotificationDetails( android: AndroidNotificationDetails( 'chat_id', '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(), }), ); print('Notification shown successfully'); } catch (e) { print('Error processing background message: $e'); } } else if (message.data['type'] == 'call_init') { try { final callId = message.data['call_id']?.toString() ?? ''; final callerName = message.data['caller_username']?.toString() ?? 'Пользователь'; final callerId = int.tryParse(message.data['caller_id']?.toString() ?? '') ?? 0; print("Фоновый звонок через FCM: callId=$callId, callerName=$callerName, callerId=$callerId"); await showIncomingCallKit( callId: callId, callerName: callerName, callerId: callerId, ); } catch (e) { print("Error showing incoming call kit in background: $e"); } } else { print('Message type is not recognized: ${message.data['type']}'); } } class MyApp extends StatefulWidget { const MyApp({super.key}); @override State createState() => _MyAppState(); } class _MyAppState extends State with WidgetsBindingObserver { @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { // Закрываем сокет, как только приложение сворачивается. if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive || state == AppLifecycleState.detached) { try { //context.read().closeRealtime(); } catch (_) {} return; } // На возврате в приложение — пробуем переподключиться (если есть токен). if (state == AppLifecycleState.resumed) { try { context.read().initRealtime(); } catch (_) {} } } @override Widget build(BuildContext context) { final themeProvider = context.watch(); return MaterialApp( title: 'Chepuhagram', debugShowCheckedModeBanner: false, themeAnimationDuration: const Duration(milliseconds: 300), themeAnimationCurve: Curves.easeInOut, theme: themeProvider.themeData, themeMode: themeProvider.themeMode, navigatorKey: navigatorKey, navigatorObservers: [routeObserver], routes: {'/splash': (context) => const SplashScreen()}, initialRoute: '/splash', ); } }